// Author: fruit.com.cn import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/config/locale_controller.dart'; import '../../core/config/server_endpoint.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; enum MyPageAction { home, discover, resume } class MineLogic extends ChangeNotifier { MineLogic({ required this.history, required this.drama, required int initialSection, }) : section = initialSection { refreshPosition(); } final WatchHistoryRepository history; Drama drama; late Future position; List recent = const []; bool recentLoading = false; String? recentError; /// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始) bool positionFailed = false; int section; int filter = 0; bool _disposed = false; void notify() { if (!_disposed) notifyListeners(); } void refreshPosition() { position = history .resumePointFor(drama.id) .then((point) { positionFailed = false; return point?.position; }) .catchError((Object _) { positionFailed = true; notify(); return null; }); refreshRecent(); } /// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。 Future refreshRecent() async { recentLoading = true; recentError = null; notify(); try { recent = await history.recent(limit: 20); } catch (_) { recentError = 'progressError'; } finally { recentLoading = false; notify(); } } void selectSection(int index) { section = index; notify(); } void selectFilter(int index) { filter = index; notify(); } void syncPage({ required bool active, required bool wasActive, required Drama nextDrama, required bool dramaChanged, required int sectionRequest, required int previousSectionRequest, required int initialSection, }) { if (active && (!wasActive || dramaChanged)) { drama = nextDrama; refreshPosition(); notify(); } if (sectionRequest != previousSectionRequest) { section = initialSection; notify(); } } void act( BuildContext context, MyPageAction action, { required bool embedded, ValueChanged? onAction, }) { if (embedded) { onAction?.call(action); } else { Navigator.pop(context, action); } } Future showLanguage( BuildContext context, { bool showServerSetting = kDebugMode, }) async { final l10n = AppLocalizations.of(context)!; await showModalBottomSheet( context: context, backgroundColor: const Color(0xFF1C1D22), showDragHandle: true, isScrollControlled: true, builder: (sheetContext) => SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile(title: Text(l10n.language)), for (final (code, label) in [ ('en', l10n.english), ('zh', l10n.simplifiedChinese), ('id', l10n.bahasaIndonesia), ]) ListTile( title: Text(label), onTap: () { context.read().select(code); Navigator.pop(sheetContext); }, ), if (showServerSetting) ...[ const Divider(height: 1), ListTile( key: const Key('server-endpoint-setting'), leading: const Icon(Icons.dns_outlined), title: Text(l10n.serverAddress), subtitle: Text(AppConfig.apiBaseUrl), trailing: const Icon(Icons.edit_outlined), onTap: () => _editServerEndpoint(context, l10n), ), ], const SizedBox(height: 8), ], ), ), ), ), ); } Future _editServerEndpoint( BuildContext context, AppLocalizations l10n, ) async { final changed = await showDialog( context: context, builder: (_) => _ServerEndpointDialog(l10n: l10n), ); if (changed == true && context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.restartToApply))); } } @override void dispose() { _disposed = true; super.dispose(); } } class _ServerEndpointDialog extends StatefulWidget { const _ServerEndpointDialog({required this.l10n}); final AppLocalizations l10n; @override State<_ServerEndpointDialog> createState() => _ServerEndpointDialogState(); } class _ServerEndpointDialogState extends State<_ServerEndpointDialog> { late final TextEditingController _controller = TextEditingController( text: AppConfig.apiBaseUrl, ); String? _error; bool _saving = false; @override void dispose() { _controller.dispose(); super.dispose(); } Future _save() async { final normalized = ServerEndpoint.normalize(_controller.text); if (normalized == null) { setState(() => _error = widget.l10n.invalidServerAddress); return; } setState(() { _saving = true; _error = null; }); final saved = await ServerEndpoint.save(normalized); if (!mounted) return; if (!saved) { setState(() { _saving = false; _error = widget.l10n.invalidServerAddress; }); return; } Navigator.pop(context, true); } Future _restoreDefault() async { setState(() => _saving = true); await ServerEndpoint.clear(); if (mounted) Navigator.pop(context, true); } @override Widget build(BuildContext context) { final l10n = widget.l10n; return AlertDialog( title: Text(l10n.serverAddress), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( key: const Key('server-endpoint-input'), controller: _controller, enabled: !_saving, keyboardType: TextInputType.url, autocorrect: false, decoration: InputDecoration( hintText: l10n.serverAddressHint, errorText: _error, errorMaxLines: 2, ), ), const SizedBox(height: 10), Text( l10n.restartToApply, key: const Key('server-endpoint-restart-hint'), style: const TextStyle(color: AppColors.muted, fontSize: 12), ), const SizedBox(height: 4), TextButton( key: const Key('server-endpoint-restore'), onPressed: _saving ? null : _restoreDefault, child: Text(l10n.restoreDefault), ), ], ), ), actions: [ TextButton( onPressed: _saving ? null : () => Navigator.pop(context, false), child: Text(l10n.cancel), ), FilledButton( key: const Key('server-endpoint-save'), onPressed: _saving ? null : _save, child: Text(l10n.saveAddress), ), ], ); } }