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

View File

@@ -337,6 +337,9 @@ class _EditorViewState extends State<_EditorView> {
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式 /// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
bool _isViewMode = false; bool _isViewMode = false;
/// 保存中状态 — 用于显示"保存中..."指示器
bool _isSaving = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -595,9 +598,16 @@ class _EditorViewState extends State<_EditorView> {
); );
} }
/// 保存处理 /// 保存处理 — 显示"保存中..."后触发保存
void _handleSave(BuildContext context, EditorState state) { 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) { 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) { if (state.lastSavedAt == null) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
@@ -634,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
), ),
); );
} }
// 已保存 — 绿色点
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row( 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,
),
),
);
},
);
}
}

View File

@@ -71,7 +71,7 @@ class EditorToolbar extends StatelessWidget {
onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)), onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Container( child: Container(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -1,9 +1,12 @@
// 设置 BLoC — 主题切换 + 应用设置管理 // 设置 BLoC — 主题切换 + 应用设置管理
// //
// ChangeNotifier 模式(同 MoodBloc通过 ListenableBuilder 消费。 // ChangeNotifier 模式(同 MoodBloc通过 ListenableBuilder 消费。
// Phase 1: 内存态 + TODO 持久化到 SharedPreferences。 // 主题偏好持久化到 SharedPreferences。
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kThemeMode = 'settings_theme_mode';
// ===== State ===== // ===== State =====
@@ -28,14 +31,34 @@ class SettingsState {
/// 设置管理器 — 全局单例,在 NuanjiApp 中创建 /// 设置管理器 — 全局单例,在 NuanjiApp 中创建
class SettingsBloc extends ChangeNotifier { class SettingsBloc extends ChangeNotifier {
SettingsBloc({SharedPreferences? prefs}) : _prefs = prefs {
_loadSavedTheme();
}
final SharedPreferences? _prefs;
SettingsState _state = const SettingsState(); SettingsState _state = const SettingsState();
SettingsState get state => _state; 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) { void changeTheme(ThemeMode mode) {
_state = _state.copyWith(themeMode: mode); _state = _state.copyWith(themeMode: mode);
notifyListeners(); notifyListeners();
// TODO: 持久化到 SharedPreferences _prefs?.setString(_kThemeMode, mode.name);
} }
/// 循环切换: system → light → dark → system /// 循环切换: system → light → dark → system