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: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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user