Files
photo-live/apps/mobile/lib/pages/home_page.dart
T
2026-09-21 17:20:43 +08:00

278 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/locale_controller.dart';
import '../core/api_client.dart';
import '../features/uploads/engine_scope.dart';
import '../features/uploads/queue_controller.dart';
import '../l10n/app_localizations.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<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
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),
child: Badge.count(count: count, child: Icon(icon)),
);
}
}
/// 设置页(UI-01 §4.5 分组重构):账号 / 语言 / 安全(改密 + 退出)/
/// 上传帮助 / 关于(版本)。退出登录确认弹窗明确「本机任务保留,重新
/// 登录后继续」——不承诺退出后后台继续上传。
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<void> _pickLocale(
BuildContext context,
WidgetRef ref,
AppLocaleChoice current,
) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final l10n = AppLocalizations.of(sheetContext);
// 打开时刻的快照即可:选中即 pop,无滞留期间的陈旧选中态。
return SafeArea(
child: RadioGroup<AppLocaleChoice>(
groupValue: current,
onChanged: (choice) {
if (choice == null) return;
ref.read(localeSettingsProvider.notifier).setChoice(choice);
Navigator.of(sheetContext).pop();
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final choice in AppLocaleChoice.values)
RadioListTile<AppLocaleChoice>(
value: choice,
title: Text(_choiceLabel(l10n, choice)),
),
],
),
),
);
},
);
}
/// 退出确认:明确本地任务保留、重新登录后继续(不承诺后台继续上传)。
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.logoutConfirmTitle),
content: Text(l10n.logoutConfirmBody),
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<void> _logout(BuildContext context, WidgetRef ref) async {
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
await ref.read(apiClientProvider).logout();
// 会话账号失效 → 引擎 stop(任务保留库内,见 engine_scope.dart)。
await ref.read(sessionAccountProvider.notifier).refresh();
if (!context.mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final choice = ref.watch(localeSettingsProvider);
final account = ref.watch(sessionAccountProvider).value;
return Scaffold(
appBar: AppBar(title: Text(l10n.settings)),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: kSpace8)
.copyWith(bottom: kSpace16),
children: [
// ── 账号 ────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, kSpace8,
),
child: SectionHeader(title: l10n.settingsSectionAccount),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(account ?? l10n.notSet),
subtitle: Text(l10n.settingsSignedInAccount),
),
// ── 语言 ────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, kSpace8,
),
child: SectionHeader(title: l10n.settingsSectionLanguage),
),
// 分组已名「语言」,行标题直接显示当前档位(避免同名重复)。
ListTile(
leading: const Icon(Icons.language),
title: Text(_choiceLabel(l10n, choice)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _pickLocale(context, ref, choice),
),
// ── 安全:改密 + 退出(确认后清会话) ────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, kSpace8,
),
child: SectionHeader(title: l10n.settingsSectionSecurity),
),
ListTile(
leading: const Icon(Icons.password_outlined),
title: Text(l10n.passwordChangeTitle),
onTap: () => Navigator.of(context).pushNamed('/password-change'),
),
ListTile(
leading: const Icon(Icons.logout),
title: Text(l10n.logout),
onTap: () => _confirmLogout(context, ref),
),
// ── 上传帮助:系统限制 / 强退 / 网络等待三分态说明 ────────
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, kSpace8,
),
child: SectionHeader(title: l10n.settingsSectionUploadHelp),
),
ExpansionTile(
leading: const Icon(Icons.help_outline),
title: Text(l10n.settingsUploadHelpTitle),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, 0, kSpace16, kSpace16,
),
child: Text(
l10n.settingsUploadHelpBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
// ── 关于:版本 ──────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, kSpace8,
),
child: SectionHeader(title: l10n.settingsSectionAbout),
),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(l10n.settingsVersion),
trailing: Text(
kAppVersion,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}