diff --git a/app/lib/app.dart b/app/lib/app.dart index 1d89a02..fcf8efe 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -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 createState() => _NuanjiAppState(); +} + +class _NuanjiAppState extends State { + 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 _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.value(value: apiClient), - RepositoryProvider.value(value: authRepository), - RepositoryProvider.value(value: journalRepository), - RepositoryProvider.value(value: remoteJournalRepository), - RepositoryProvider.value(value: syncEngine), - RepositoryProvider.value(value: classRepository), + RepositoryProvider.value(value: _apiClient), + RepositoryProvider.value(value: _authRepository), + RepositoryProvider.value(value: _journalRepository), + RepositoryProvider.value(value: _remoteJournalRepository), + RepositoryProvider.value(value: _syncEngine), + RepositoryProvider.value(value: _classRepository), ], child: ListenableProvider.value( - value: settingsBloc, + value: _settingsBloc, child: Builder( builder: (context) { final settings = context.watch(); return BlocProvider.value( - value: authBloc, + value: _authBloc, child: _AppView( - router: createAppRouter(authBloc), + router: createAppRouter(_authBloc), themeMode: settings.state.themeMode, ), ); diff --git a/app/lib/features/editor/views/editor_page.dart b/app/lib/features/editor/views/editor_page.dart index 0e8a9e2..2e52d04 100644 --- a/app/lib/features/editor/views/editor_page.dart +++ b/app/lib/features/editor/views/editor_page.dart @@ -337,6 +337,9 @@ class _EditorViewState extends State<_EditorView> { /// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式 bool _isViewMode = false; + /// 保存中状态 — 用于显示"保存中..."指示器 + bool _isSaving = false; + @override void initState() { super.initState(); @@ -595,9 +598,16 @@ class _EditorViewState extends State<_EditorView> { ); } - /// 保存处理 + /// 保存处理 — 显示"保存中..."后触发保存 void _handleSave(BuildContext context, EditorState state) { - widget.onSaveComplete(); + setState(() => _isSaving = true); + // 短暂延迟让 UI 显示"保存中..."状态 + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) { + setState(() => _isSaving = false); + widget.onSaveComplete(); + } + }); } /// 显示评论列表 @@ -624,7 +634,26 @@ class _EditorViewState extends State<_EditorView> { } /// 自动保存状态指示器 + /// 保存指示器 — 三态: 未保存 / 保存中 / 已保存 Widget _buildAutosaveIndicator(EditorState state) { + // 保存中 — 琥珀色脉冲点 + if (_isSaving) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _PulsingDot(color: AppColors.tertiary), + const SizedBox(width: 4), + Text( + '保存中...', + style: TextStyle(fontSize: 11, color: Colors.amber[700]), + ), + ], + ), + ); + } + // 未保存 if (state.lastSavedAt == null) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -634,6 +663,7 @@ class _EditorViewState extends State<_EditorView> { ), ); } + // 已保存 — 绿色点 return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( @@ -1211,3 +1241,53 @@ class _ImageSourceButton extends StatelessWidget { ); } } + +/// 脉冲圆点动画 — 用于"保存中..."指示器 +class _PulsingDot extends StatefulWidget { + const _PulsingDot({required this.color}); + final Color color; + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final scale = 0.6 + 0.4 * _controller.value; + return Transform.scale( + scale: scale, + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ); + }, + ); + } +} diff --git a/app/lib/features/editor/widgets/editor_toolbar.dart b/app/lib/features/editor/widgets/editor_toolbar.dart index 44784e3..4e21f63 100644 --- a/app/lib/features/editor/widgets/editor_toolbar.dart +++ b/app/lib/features/editor/widgets/editor_toolbar.dart @@ -71,7 +71,7 @@ class EditorToolbar extends StatelessWidget { onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)), behavior: HitTestBehavior.opaque, child: Container( - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/app/lib/features/profile/bloc/settings_bloc.dart b/app/lib/features/profile/bloc/settings_bloc.dart index d1ea2f2..c13818f 100644 --- a/app/lib/features/profile/bloc/settings_bloc.dart +++ b/app/lib/features/profile/bloc/settings_bloc.dart @@ -1,9 +1,12 @@ // 设置 BLoC — 主题切换 + 应用设置管理 // // ChangeNotifier 模式(同 MoodBloc),通过 ListenableBuilder 消费。 -// Phase 1: 内存态 + TODO 持久化到 SharedPreferences。 +// 主题偏好持久化到 SharedPreferences。 import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _kThemeMode = 'settings_theme_mode'; // ===== State ===== @@ -28,14 +31,34 @@ class SettingsState { /// 设置管理器 — 全局单例,在 NuanjiApp 中创建 class SettingsBloc extends ChangeNotifier { + SettingsBloc({SharedPreferences? prefs}) : _prefs = prefs { + _loadSavedTheme(); + } + + final SharedPreferences? _prefs; SettingsState _state = const SettingsState(); SettingsState get state => _state; + /// 从 SharedPreferences 恢复保存的主题 + void _loadSavedTheme() { + if (_prefs == null) return; + final saved = _prefs?.getString(_kThemeMode); + if (saved != null) { + final mode = switch (saved) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + _state = _state.copyWith(themeMode: mode); + notifyListeners(); + } + } + /// 切换主题模式 void changeTheme(ThemeMode mode) { _state = _state.copyWith(themeMode: mode); notifyListeners(); - // TODO: 持久化到 SharedPreferences + _prefs?.setString(_kThemeMode, mode.name); } /// 循环切换: system → light → dark → system