import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/locale_controller.dart'; import '../core/api_client.dart'; import '../features/albums/providers.dart'; import '../features/uploads/engine_scope.dart'; import '../features/uploads/queue_controller.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/language_sheet.dart'; import '../ui/components/section_header.dart'; import '../ui/tokens.dart'; import 'albums_tab.dart'; import 'queue_tab.dart'; /// 应用展示版本(About 分组):与 pubspec version 对齐的缺省值, /// `--dart-define=APP_VERSION=...` 覆盖(未引入 package_info,零新依赖)。 const String kAppVersion = String.fromEnvironment( 'APP_VERSION', defaultValue: '1.0.0', ); /// 主壳:底部导航「相册 / 上传 / 设置」(UI-01 §4.1)。 /// /// IndexedStack 保持三页签状态;「上传」页签角标 = 当前账号未结束任务数 /// ([unfinishedCountOf],TaskStore 账号硬隔离,不跨账号),为 0 时隐藏。 class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override ConsumerState createState() => _HomePageState(); } class _HomePageState extends ConsumerState { int _selectedIndex = 0; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final badgeCount = ref.watch(uploadBadgeCountProvider); return Scaffold( body: IndexedStack( index: _selectedIndex, children: const [AlbumsTab(), QueueTab(), SettingsTab()], ), bottomNavigationBar: NavigationBar( selectedIndex: _selectedIndex, onDestinationSelected: (index) => setState(() => _selectedIndex = index), destinations: [ NavigationDestination( icon: const Icon(Icons.photo_library_outlined), selectedIcon: const Icon(Icons.photo_library), label: l10n.navAlbums, ), NavigationDestination( icon: _UploadTabIcon( count: badgeCount, icon: Icons.cloud_upload_outlined, ), selectedIcon: _UploadTabIcon( count: badgeCount, icon: Icons.cloud_upload, ), label: l10n.navUploads, ), NavigationDestination( icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings), label: l10n.navSettings, ), ], ), ); } } /// 上传页签图标 + 未完成任务角标(≤0 时隐藏 Badge,仅图标)。 /// /// Semantics 携带本地化计数文案(读屏不依赖角标视觉)。 class _UploadTabIcon extends StatelessWidget { const _UploadTabIcon({required this.count, required this.icon}); final int count; final IconData icon; @override Widget build(BuildContext context) { if (count <= 0) return Icon(icon); return Semantics( label: AppLocalizations.of(context).badgeUploading(count), // UI06:app-preview .badge——红底 #F15B64 白字 + 白边(与导航白底分隔)。 child: Badge.count( count: count, backgroundColor: const Color(0xFFF15B64), textStyle: const TextStyle( fontSize: 10, color: Colors.white, fontWeight: FontWeight.w600, ), smallSize: 16, child: Icon(icon), ), ); } } /// 设置页(UI-03 页面 02 重设计,图01):账号摘要卡(首字母头像 + 账号) /// → 通用分组(Language / Change password / Upload help / App version) /// → 退出登录(低强调红色,单独分离)。 /// /// 账号卡不是「编辑资料」入口(无编辑功能,无导航箭头);会话契约无 /// 自身显示名/角色字段,卡片只展示真实可得的账号。语言行显示档位 /// 自身名称;版本取 [kAppVersion]。上传帮助为本地说明面板(不承诺 /// 后台百分百持续)。退出若存在进行中任务,确认弹窗明确任务保留、 /// 重新登录后继续——不承诺退出后后台继续上传。 class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); /// 档位 → 展示名(语言名固定以自身语言书写,不随当前界面语言变化)。 static String _choiceLabel(AppLocalizations l10n, AppLocaleChoice choice) => switch (choice) { AppLocaleChoice.system => l10n.localeFollowSystem, AppLocaleChoice.zh => l10n.localeChinese, AppLocaleChoice.en => l10n.localeEnglish, }; /// 上传帮助面板(本地说明:网络等待/系统后台限制/强退恢复)。 Future _showUploadHelp(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return showModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( top: false, child: SingleChildScrollView( // 长文案/大字体下允许滚动,面板不做固定高度。 child: Padding( padding: const EdgeInsets.fromLTRB(kSpace24, 0, kSpace24, kSpace24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.settingsUploadHelpTitle, style: theme.textTheme.titleMedium, ), const SizedBox(height: kSpace12), Text( l10n.settingsUploadHelpBody, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, height: 1.5, ), ), const SizedBox(height: kSpace16), FilledButton( onPressed: () => Navigator.of(sheetContext).pop(), child: Text(l10n.ok), ), ], ), ), ), ), ); } /// 退出确认:有进行中任务时明确本地任务保留、重新登录后继续 /// (不承诺后台继续上传);无任务时为常规确认。 Future _confirmLogout( BuildContext context, WidgetRef ref, int unfinishedTasks, ) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.logoutConfirmTitle), content: Text( unfinishedTasks > 0 ? l10n.logoutConfirmBody : l10n.logoutConfirmBodyIdle, ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.logout), ), ], ), ); if (confirmed != true || !context.mounted) return; await _logout(context, ref); } Future _logout(BuildContext context, WidgetRef ref) async { // api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。 await ref.read(apiClientProvider).logout(); // 会话账号失效 → 引擎 stop(任务保留库内,见 engine_scope.dart)。 await ref.read(sessionAccountProvider.notifier).refresh(); // 账号级缓存失效(2026-09-23 用户报告:换账号后相册列表仍是上一账号数据 // ——首页 IndexedStack 三 tab 常驻不销毁,非 autoDispose 的列表 Provider // 必须显式失效;任务库已按账号隔离键隔离,无需清)。 ref.invalidate(albumListProvider); if (!context.mounted) return; Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false); } @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final choice = ref.watch(localeSettingsProvider); final account = ref.watch(sessionAccountProvider).value; // 进行中任务数(底部导航角标同口径):退出确认文案分流。 final unfinishedTasks = ref.watch(uploadBadgeCountProvider); final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: Text(l10n.settings)), body: ListView( padding: const EdgeInsets.fromLTRB(0, kSpace8, 0, kSpace24), children: [ // ── 账号摘要卡(只读展示,非编辑入口) ───────────────── Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, kSpace8, ), child: SectionHeader(title: l10n.settingsSectionAccount), ), Card( margin: const EdgeInsets.symmetric(horizontal: kSpace16), child: Padding( padding: const EdgeInsets.all(kSpace16), child: Row( children: [ _AccountAvatar(account: account), const SizedBox(width: kSpace16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( account ?? l10n.notSet, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall, ), const SizedBox(height: kSpace4), Text( l10n.settingsSignedInAccount, style: theme.textTheme.bodySmall, ), ], ), ), ], ), ), ), // ── 通用:语言 / 改密 / 上传帮助 / 版本 ────────────────── Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace24, kSpace16, kSpace8, ), child: SectionHeader(title: l10n.settingsSectionGeneral), ), Card( margin: const EdgeInsets.symmetric(horizontal: kSpace16), clipBehavior: Clip.antiAlias, child: Column( children: [ _SettingsRow( icon: Icons.language, label: l10n.language, trailing: Text( _choiceLabel(l10n, choice), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), chevron: true, onTap: () => showLanguageSheet(context), ), const Divider(indent: kSpace16, endIndent: kSpace16), _SettingsRow( icon: Icons.password_outlined, label: l10n.passwordChangeTitle, chevron: true, onTap: () => Navigator.of(context).pushNamed('/password-change'), ), const Divider(indent: kSpace16, endIndent: kSpace16), _SettingsRow( icon: Icons.help_outline, label: l10n.settingsSectionUploadHelp, chevron: true, onTap: () => _showUploadHelp(context), ), const Divider(indent: kSpace16, endIndent: kSpace16), _SettingsRow( icon: Icons.info_outline, label: l10n.settingsVersion, trailing: Text( kAppVersion, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ), // ── 退出登录:低强调红色,与常规分组分离 ───────────────── Padding( padding: const EdgeInsets.only(top: kSpace24), child: Align( alignment: Alignment.center, child: TextButton( style: TextButton.styleFrom( foregroundColor: theme.colorScheme.error, minimumSize: const Size(48, 48), ), onPressed: () => _confirmLogout(context, ref, unfinishedTasks), child: Text(l10n.logout), ), ), ), ], ), ); } } /// 账号首字母头像占位(无真实头像数据;缺失回退「?」)。 class _AccountAvatar extends StatelessWidget { const _AccountAvatar({required this.account}); final String? account; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final initial = (account == null || account!.isEmpty) ? '?' : account!.characters.first.toUpperCase(); return CircleAvatar( radius: 24, backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, child: Text(initial, style: Theme.of(context).textTheme.titleMedium), ); } } /// 设置行:图标 + 标题 + 可选尾值/箭头,触控高 ≥48。 class _SettingsRow extends StatelessWidget { const _SettingsRow({ required this.icon, required this.label, this.trailing, this.chevron = false, this.onTap, }); final IconData icon; final String label; final Widget? trailing; /// 是否显示导航箭头(仅可进入的行显示)。 final bool chevron; final VoidCallback? onTap; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return InkWell( onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 56), child: Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace16, vertical: kSpace12, ), child: Row( children: [ Icon(icon, size: 24, color: scheme.onSurfaceVariant), const SizedBox(width: kSpace16), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge, ), ), if (trailing != null) ...[ const SizedBox(width: kSpace8), trailing!, ], if (chevron) ...[ const SizedBox(width: kSpace4), Icon( Icons.chevron_right, size: 24, color: scheme.onSurfaceVariant, ), ], ], ), ), ), ); } }