Files
photo-live/apps/mobile/lib/app/app.dart
T
浅念 ae17776be6 feat(app): session restore on startup — no forced re-login after app restart
用户反馈:关闭 App 重开每次都要登录。根因:会话已持久化(安全存储,
服务端 TTL 30 天),但启动固定落在登录页——无人消费已存会话。

- _StartupGate(home):watch sessionAccount——在场直进 HomePage,离场
  LoginPage;状态驱动(401/登出清会话后 gate 自动重建,不依赖导航时序)
- onUnauthorized 保留 push 兜底(home 路由被显式导航替换后的 401 场景)
- 测试适配(boot 401 两项重写通过;397/397 全绿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:48:27 +08:00

125 lines
5.2 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 '../core/api_client.dart';
import '../features/uploads/engine_scope.dart';
import '../l10n/app_localizations.dart';
import '../pages/home_page.dart';
import '../pages/login_page.dart';
import '../pages/password_change_page.dart';
import '../pages/server_history_page.dart';
import 'locale_controller.dart';
import 'theme.dart';
/// 根导航键:[ApiClient.onUnauthorized](非 UI 上下文触发)跳转 /login 用。
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线 + 引擎生命周期。
///
/// 基线路由为命名路由(/login → /home → /password-change);随页面增长再
/// 评估是否迁移 go_router,当前保持零额外依赖。
/// initState 注册 [ApiClient.onUnauthorized]:任意业务端点 401(会话过期)
/// 即清空栈回到 /login(登录/修改密码端点自身的 401 不触发,见 ApiClient),
/// 并同步刷新会话账号(引擎 stop,任务保留库内)。
/// build watch [uploadEngineScopeProvider]——App 存续期间引擎随登录态
/// start/stop(Task 9 接线,见 engine_scope.dart)。
class PhotoLiveApp extends ConsumerStatefulWidget {
const PhotoLiveApp({super.key});
@override
ConsumerState<PhotoLiveApp> createState() => _PhotoLiveAppState();
}
class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
@override
void initState() {
super.initState();
ref.read(apiClientProvider).onUnauthorized = () {
rootNavigatorKey.currentState?.pushNamedAndRemoveUntil(
'/login',
(route) => false,
);
// 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop;启动门
// (home 路由仍在栈上时)随会话离场重建为登录页。
ref.read(sessionAccountProvider.notifier).refresh();
};
}
/// 语言解析(P3 Task 10):
/// - 显式选择(locale 非 null)优先,按 languageCode 匹配;
/// - system 档位下用系统 locale 匹配(zh_* → zh、en_* → en);
/// - 均不匹配(如 fr)回退模板语言 zh——不依赖生成代码 supportedLocales
/// 的排序(gen-l10n 按字母序 en 在前,默认解析会回退 en,与模板不符)。
static Locale _resolveLocale(
Locale? deviceLocale,
Iterable<Locale> supportedLocales,
) {
Locale? requested;
for (final supported in supportedLocales) {
if (supported.languageCode == deviceLocale?.languageCode) {
requested = supported;
break;
}
}
// 语言码不匹配 → 模板语言(l10n.yaml template-arb-file=app_zh.arb)。
return requested ?? const Locale('zh');
}
@override
Widget build(BuildContext context) {
// select 只取 Locale?:未引起 locale 变化的状态更新不重建 MaterialApp。
final locale = ref.watch(localeSettingsProvider.select((c) => c.locale));
// 引擎生命周期绑定(常驻 watch;账号在场 start / 离场 stop)。
ref.watch(uploadEngineScopeProvider);
return MaterialApp(
navigatorKey: rootNavigatorKey,
onGenerateTitle: (context) => AppLocalizations.of(context).appName,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
locale: locale,
localeResolutionCallback: _resolveLocale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
routes: {
'/login': (context) => const LoginPage(),
'/home': (context) => const HomePage(),
'/password-change': (context) => const PasswordChangePage(),
'/server-history': (context) => const ServerHistoryPage(),
},
// 启动门(UI06 用户反馈 2026-09-23:重启 App 不应强制重登):有未过期
// 会话直接进主页,否则登录页。
home: const _StartupGate(),
);
}
}
/// 启动门:读本地会话——未过期直接进 HomePage,否则 LoginPage。
///
/// 会话已持久化(SecureSessionStore,服务端 TTL 默认 30 天);此前的缺口
/// 是启动固定落在登录页。token 实际失效(服务端已撤销)时由 ApiClient 401
/// 兜底:清会话并整栈回登录页(onUnauthorized),过期本地判断只用于避免
/// 「必 401 的闪跳」。
class _StartupGate extends ConsumerStatefulWidget {
const _StartupGate();
@override
ConsumerState<_StartupGate> createState() => _StartupGateState();
}
class _StartupGateState extends ConsumerState<_StartupGate> {
@override
Widget build(BuildContext context) {
// 状态驱动(声明式):watch 会话账号——在场进 HomePage,离场回
// LoginPage。401/登出清会话后 gate 自动重建,不依赖导航时序;home
// 路由已被显式导航替换(登录成功 pushReplacement '/home' 等)时,
// 401 兜底走 onUnauthorized 的 push。
final account = ref.watch(sessionAccountProvider);
return account.maybeWhen(
skipLoadingOnReload: false,
orElse: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
data: (account) => account != null ? const HomePage() : const LoginPage(),
);
}
}