Files
photo-live/apps/mobile/lib/pages/home_page.dart
T
浅念 33f347cdea feat(app): P0 batch 3 — offline detection with engine pause/resume + UI banner
P0-4 离线感知:
  - connectivity_plus → NetworkAvailability(online/offline/unknown)
  - engine_scope 三重绑定:有账号+有网 → start;任一失去 → stop(在途不中断)
  - 网络恢复 → start + onNetworkRestored(waitingNetwork 即时重试)
  - HomePage 顶部离线黄条(wifi_off + i18n 双语)

P0-5 更正:EXIF 方向已由服务端 sharp .rotate() 自动转正——v2 分析误报

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:23:51 +08:00

467 lines
16 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 '../core/network_status.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<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);
// P0-4:离线黄条(网络恢复自动消失)。
final network = ref.watch(networkStatusProvider).value;
final offline = network == NetworkAvailability.offline;
return Scaffold(
body: Column(
children: [
if (offline)
Material(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.wifi_off_rounded,
size: 16,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.networkOfflineBanner,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
Expanded(
child: 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<void> _showUploadHelp(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return showModalBottomSheet<void>(
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<void> _confirmLogout(
BuildContext context,
WidgetRef ref,
int unfinishedTasks,
) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<void> _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,
),
],
],
),
),
),
);
}
}