feat(app): D.3 中等优先级 UX 改进 — 保存指示器 + 触摸目标 + 主题持久化
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

D.3.2 三态保存指示器:
- 未保存 (灰色) → 保存中 (琥珀色脉冲点) → 已保存 (绿色点)
- _PulsingDot 动画组件,800ms 呼吸效果
- 点击'完成'时显示保存中状态

D.3.3 工具栏触摸目标:
- BoxConstraints 36x36 → 44x44,符合 WCAG 标准

D.3.4 主题偏好持久化:
- SettingsBloc 接受 SharedPreferences,保存/恢复 themeMode
- NuanjiApp 改为 StatefulWidget,异步初始化 SharedPreferences
- 启动时显示 loading,初始化完成后渲染 app
This commit is contained in:
iven
2026-06-07 13:50:34 +08:00
parent 750605e479
commit ec8a04c80a
4 changed files with 175 additions and 42 deletions

View File

@@ -15,6 +15,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart' show ListenableProvider;
import 'package:shared_preferences/shared_preferences.dart';
import 'config/app_config.dart';
import 'core/theme/app_theme.dart';
@@ -31,70 +32,99 @@ import 'features/auth/bloc/auth_bloc.dart';
import 'features/profile/bloc/settings_bloc.dart';
/// 暖记 App — 根组件
class NuanjiApp extends StatelessWidget {
class NuanjiApp extends StatefulWidget {
const NuanjiApp({super.key});
@override
Widget build(BuildContext context) {
// 创建全局依赖App 生命周期内单例)
// 开发模式默认使用 localhost可通过 --dart-define 覆盖
State<NuanjiApp> createState() => _NuanjiAppState();
}
class _NuanjiAppState extends State<NuanjiApp> {
late final ApiClient _apiClient;
late final AuthRepository _authRepository;
late final JournalRepository _journalRepository;
late final RemoteJournalRepository _remoteJournalRepository;
late final SyncEngine _syncEngine;
late final ClassRepository _classRepository;
late final SettingsBloc _settingsBloc;
late final AuthBloc _authBloc;
bool _initialized = false;
@override
void initState() {
super.initState();
_initApp();
}
Future<void> _initApp() async {
final config = kDebugMode ? AppConfig.dev : AppConfig.fromEnvironment();
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
_apiClient = ApiClient(baseUrl: config.apiBaseUrl);
final tokenStore = createSecureTokenStore();
final authRepository = AuthRepository(apiClient: apiClient, tokenStore: tokenStore);
// 离线优先Isar 为主要本地仓库Remote 供 SyncEngine 推送
// Web 平台Isar 3.x 不支持 Web直接使用远程仓库
final journalRepository = kIsWeb
? RemoteJournalRepository(api: apiClient)
_authRepository = AuthRepository(apiClient: _apiClient, tokenStore: tokenStore);
_journalRepository = kIsWeb
? RemoteJournalRepository(api: _apiClient)
: IsarJournalRepository();
final remoteJournalRepository = RemoteJournalRepository(api: apiClient);
final syncEngine = SyncEngine(apiClient: apiClient);
final classRepository = ClassRepository(api: apiClient);
final settingsBloc = SettingsBloc();
final authBloc = AuthBloc(
authRepository: authRepository,
classRepository: classRepository,
_remoteJournalRepository = RemoteJournalRepository(api: _apiClient);
_syncEngine = SyncEngine(apiClient: _apiClient);
_classRepository = ClassRepository(api: _apiClient);
_settingsBloc = SettingsBloc(
prefs: await SharedPreferences.getInstance(),
);
_authBloc = AuthBloc(
authRepository: _authRepository,
classRepository: _classRepository,
);
// 启动时检查认证状态
authBloc.add(const AppStarted());
_authBloc.add(const AppStarted());
// 异步恢复 SyncEngine 持久化队列fire-and-forget不阻塞 UI
syncEngine.restorePendingQueue();
// 启动网络监听 — 网络恢复时自动触发 trySync()
syncEngine.startAutoSync();
// 异步恢复 SyncEngine 持久化队列
_syncEngine.restorePendingQueue();
_syncEngine.startAutoSync();
// 认证状态监听:登出时清除 token
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient
authBloc.stream.listen((state) {
_authBloc.stream.listen((state) {
if (state is! Authenticated) {
apiClient.clearToken();
_apiClient.clearToken();
}
});
// Token 刷新彻底失败时 → 派发 AuthExpired → 路由重定向到登录页
apiClient.onAuthFailed = () {
authBloc.add(const AuthExpired());
// Token 刷新彻底失败时 → 派发 AuthExpired
_apiClient.onAuthFailed = () {
_authBloc.add(const AuthExpired());
};
setState(() => _initialized = true);
}
@override
Widget build(BuildContext context) {
if (!_initialized) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
return MultiRepositoryProvider(
providers: [
RepositoryProvider<ApiClient>.value(value: apiClient),
RepositoryProvider<AuthRepository>.value(value: authRepository),
RepositoryProvider<JournalRepository>.value(value: journalRepository),
RepositoryProvider<RemoteJournalRepository>.value(value: remoteJournalRepository),
RepositoryProvider<SyncEngine>.value(value: syncEngine),
RepositoryProvider<ClassRepository>.value(value: classRepository),
RepositoryProvider<ApiClient>.value(value: _apiClient),
RepositoryProvider<AuthRepository>.value(value: _authRepository),
RepositoryProvider<JournalRepository>.value(value: _journalRepository),
RepositoryProvider<RemoteJournalRepository>.value(value: _remoteJournalRepository),
RepositoryProvider<SyncEngine>.value(value: _syncEngine),
RepositoryProvider<ClassRepository>.value(value: _classRepository),
],
child: ListenableProvider<SettingsBloc>.value(
value: settingsBloc,
value: _settingsBloc,
child: Builder(
builder: (context) {
final settings = context.watch<SettingsBloc>();
return BlocProvider<AuthBloc>.value(
value: authBloc,
value: _authBloc,
child: _AppView(
router: createAppRouter(authBloc),
router: createAppRouter(_authBloc),
themeMode: settings.state.themeMode,
),
);