feat(app): D.3 中等优先级 UX 改进 — 保存指示器 + 触摸目标 + 主题持久化
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:
104
app/lib/app.dart
104
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<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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user