Compare commits

..

7 Commits

Author SHA1 Message Date
iven
7af7cd64e6 feat(app): E.1 内测 APK 构建配置 — Android 品牌化 + 签名 + ProGuard
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 更新应用名称为「暖记」(AndroidManifest + strings.xml)
- 添加必要权限: INTERNET, CAMERA, READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE
- 生成 release 签名密钥 (RSA 2048, 10000 天有效期)
- 配置 ProGuard/R8 代码混淆 + 资源压缩
- 品牌化启动页: 奶油白背景 + 珊瑚色圆形「暖」字 logo
- 品牌化应用图标: 各密度 mipmap (mdpi~xxxhdpi)
- 添加阿里云 Maven 镜像加速依赖下载
- AGP 9.x 兼容: 自动为旧 Flutter 插件注入 namespace
- Gradle 性能优化: 并行编译 + 构建缓存
2026-06-07 20:17:19 +08:00
iven
ec8a04c80a 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
2026-06-07 13:50:34 +08:00
iven
750605e479 feat(app): 全局离线提示横幅 — 网络不可用时显示黄色警告
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增 OfflineBanner widget: 监听 connectivity_plus 自动显示/隐藏
- AnimatedCrossFade 滑入滑出动画 + warmCurve 弹性曲线
- 黄色警告条: wifi_off 图标 + '网络不可用,部分功能受限'
- 嵌入 ResponsiveScaffold 的 body 上方 (手机/平板/桌面三端)
- 只在离线时显示,恢复网络后自动消失
2026-06-07 13:44:40 +08:00
iven
346c751cbb refactor(app): 迁移 4 个页面到共享 EmptyStateWidget + ErrorStateWidget
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
迁移统计:
- discover_page: _buildError → ErrorStateWidget, _buildEmptyHint → EmptyStateWidget
- sticker_library_page: 错误 + 空列表 → 共享组件
- class_page: 错误/班级列表空/日记墙空/话题空 → 共享组件 (4 处)
- calendar_page: CalendarError → ErrorStateWidget

统一体验: 所有页面空状态使用一致的 icon + title + subtitle + CTA 布局
2026-06-07 13:42:56 +08:00
iven
2f96f9a4f4 feat(app): 编辑器未保存确认 + 日历今天按钮
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
D.2.3 编辑器未保存确认:
- _handleBack 检查 state.isDirty,有未保存修改时弹出确认对话框
- 对话框: '放弃编辑?' / '你有未保存的修改' / [继续编辑] [放弃]

D.2.4 日历今天按钮:
- _MonthNavigator 新增 onToday 回调
- 不在当前月时显示 '今天' 文字按钮,点击跳回当月
- _isCurrentMonth 辅助判断
2026-06-07 13:38:34 +08:00
iven
f64355946c feat(app): 共享 UI 组件 + 4 个关键 UX bug 修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Phase 0 — 共享组件:
- EmptyStateWidget: 统一空状态 (icon + title + subtitle + CTA)
- ErrorStateWidget: 统一错误状态 (message + retry)
- SkeletonBox + SkeletonList: 统一骨架屏加载 (shimmer 动画)

Phase 1 — Bug 修复:
- 班级评论按 journalId 过滤,避免显示在错误日记卡片下
- moodCellColors key 修正: love/tired → angry/thinking
- 日历非 CalendarLoaded 状态改为加载指示器 (不再 SizedBox.shrink)
- 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
2026-06-07 13:36:10 +08:00
iven
1f48a67db5 docs: 更新路线图 — A+B+C 阶段完成,9 条链路 8 通过
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-07 13:15:53 +08:00
36 changed files with 1204 additions and 228 deletions

View File

@@ -1,9 +1,19 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// 从 key.properties 加载签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.nuanji.nuanji_app"
compileSdk = flutter.compileSdkVersion
@@ -15,21 +25,33 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.nuanji.nuanji_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 24 // Android 7.0+ — 支持 Isar 原生库 + CameraX
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}

38
app/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,38 @@
# 暖记 ProGuard 规则
# 保留 Flutter 引擎
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Isar 数据库 保留 native 调用
-keep class com.isor.** { *; }
-keep class isar.** { *; }
-keepclassmembers class ** {
native <methods>;
}
# Dio 网络库
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Gson / JSON 序列化
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.google.gson.** { *; }
# freezed 生成的类 保留 JSON 序列化
-keepclassmembers class **.models.** {
*** fromJson(...);
*** toJson();
}
# 保留所有序列化相关类
-keepclassmembers class * {
*** INSTANCE;
*** Companion;
}

View File

@@ -1,9 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 网络权限 — API 同步、图片上传 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- 相机权限 — 日记拍照 -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- 照片权限 — Android 13+ 细化媒体权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<!-- 照片权限 — Android 12 及以下 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<application
android:label="nuanji_app"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- 暖记启动页 — 奶油白背景 + 居中 logo -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item android:drawable="@color/bg_light" />
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_logo" />
</item>
</layer-list>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 暖记启动页 (深色模式) — 深色背景 + 居中 logo -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/bg_dark" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/launch_logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_bg">#1A1614</color>
</resources>

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<!-- 深色模式启动主题 — 深色背景 -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowBackground">@drawable/launch_background_dark</item>
<item name="android:statusBarColor">@color/bg_dark</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">暖记</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 暖记设计系统颜色 -->
<color name="bg_light">#FFF8F0</color> <!-- 奶油白背景 -->
<color name="bg_dark">#1A1614</color> <!-- 深色背景 -->
<color name="accent">#E07A5F</color> <!-- 珊瑚色主色 -->
<color name="fg_light">#2D2420</color> <!-- 浅色模式文字 -->
<color name="fg_dark">#F0E8DF</color> <!-- 深色模式文字 -->
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">暖记</string>
</resources>

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<!-- 浅色模式启动主题 — 奶油白背景 -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:statusBarColor">@color/bg_light</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>

View File

@@ -1,5 +1,8 @@
allprojects {
repositories {
// 阿里云 Maven 镜像 — 加速中国大陆依赖下载
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
}
@@ -15,6 +18,28 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
// 为缺少 namespace 的旧 Flutter 插件自动注入 namespace
// 解决 AGP 9.x 要求必须指定 namespace 的问题
plugins.withId("com.android.library") {
val android = project.extensions.getByName("android")
if (android is com.android.build.gradle.LibraryExtension && android.namespace == null) {
val manifestFile = project.file("src/main/AndroidManifest.xml")
if (manifestFile.exists()) {
val packageName = manifestFile.readLines()
.firstOrNull { it.contains("package=") }
?.let { line ->
Regex("package=\"([^\"]+)\"").find(line)?.groupValues?.get(1)
}
if (packageName != null) {
android.namespace = packageName
}
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}

View File

@@ -4,3 +4,8 @@ android.useAndroidX=true
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# 构建性能优化
org.gradle.parallel=true
org.gradle.caching=true
# 暂不启用 configuration-cache与 init 脚本冲突)
# org.gradle.configuration-cache=true

View File

@@ -11,6 +11,10 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
// 阿里云 Maven 镜像 — 加速中国大陆依赖下载
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
google()
mavenCentral()
gradlePluginPortal()

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,
),
);

View File

@@ -177,12 +177,13 @@ class AppColors {
};
/// 心情 → 日历单元格背景色
/// key 必须与 Mood 枚举值一致: happy/calm/sad/angry/thinking
static const Map<String, Color> moodCellColors = {
'happy': secondarySoftLight, // #D4E8DC
'love': roseSoftLight, // #F0DADA
'calm': tertiarySoftLight, // #FBE8C8
'sad': Color(0xFFD4DDE8), // 灰蓝
'tired': Color(0xFFE8E4E0), // 灰棕
'happy': secondarySoftLight, // 😊 开心 — 鼠尾草绿 #D4E8DC
'calm': tertiarySoftLight, // 😌 平静 — 暖金 #FBE8C8
'sad': Color(0xFFD4DDE8), // 😢 难过 — 灰蓝
'angry': Color(0xFFFFE0D6), // 😠 生气 — 暖珊瑚 (与 primaryContainer 一致)
'thinking': Color(0xFFE8E4E0), // 🤔 思考 — 灰棕
};
// ===== 浅色主题色彩方案 =====

View File

@@ -9,6 +9,7 @@ import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
@@ -41,25 +42,17 @@ class _CalendarView extends StatelessWidget {
}
if (state is CalendarError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(state.message, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => context.read<CalendarBloc>()
.add(CalendarMonthChanged(DateTime.now())),
child: const Text('重试'),
),
],
),
return ErrorStateWidget(
message: state.message,
onRetry: () => context.read<CalendarBloc>()
.add(CalendarMonthChanged(DateTime.now())),
icon: Icons.error_outline,
);
}
if (state is! CalendarLoaded) return const SizedBox.shrink();
if (state is! CalendarLoaded) {
return const Center(child: CircularProgressIndicator());
}
final loaded = state;
return Column(
@@ -81,6 +74,9 @@ class _CalendarView extends StatelessWidget {
);
context.read<CalendarBloc>().add(CalendarMonthChanged(next));
},
onToday: () {
context.read<CalendarBloc>().add(CalendarMonthChanged(DateTime.now()));
},
),
// 视图模式切换
@@ -541,11 +537,13 @@ class _MonthNavigator extends StatelessWidget {
required this.month,
required this.onPrevious,
required this.onNext,
this.onToday,
});
final DateTime month;
final VoidCallback onPrevious;
final VoidCallback onNext;
final VoidCallback? onToday;
@override
Widget build(BuildContext context) {
@@ -578,6 +576,22 @@ class _MonthNavigator extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
// "今天" 按钮 — 不在当前月时显示
if (onToday != null && !_isCurrentMonth(month))
Padding(
padding: const EdgeInsets.only(left: 8),
child: TextButton(
onPressed: onToday,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('今天', style: TextStyle(fontSize: 13)),
),
)
else
const SizedBox(width: 8),
SizedBox(
width: 44,
height: 44,
@@ -605,6 +619,11 @@ class _MonthNavigator extends StatelessWidget {
];
return '${date.year}${months[date.month - 1]}';
}
bool _isCurrentMonth(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month;
}
}
// ===== 星期标题行 =====

View File

@@ -9,6 +9,8 @@ import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import 'package:nuanji_app/data/repositories/class_repository.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../bloc/class_bloc.dart';
import '../widgets/comment_bottom_sheet.dart';
@@ -46,7 +48,10 @@ class _ClassView extends StatelessWidget {
if (state is ClassError) {
return Scaffold(
appBar: AppBar(title: const Text('班级')),
body: Center(child: Text(state.message)),
body: ErrorStateWidget(
message: state.message,
onRetry: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
),
);
}
@@ -93,22 +98,11 @@ class _ClassListView extends StatelessWidget {
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups_outlined, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text('还没有加入任何班级', style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/class-code'),
child: const Text('输入班级码加入'),
),
],
),
return EmptyStateWidget(
icon: Icons.group_add_rounded,
title: '还没有加入班级',
actionLabel: '通过班级码加入',
onAction: () => context.go('/class-code'),
);
}
}
@@ -250,21 +244,11 @@ class _DiaryWallTab extends StatelessWidget {
}
if (state.diaryWall.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_stories_outlined, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('日记墙还是空的', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
const SizedBox(height: 8),
Text('分享你的日记到班级吧!', style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.3),
)),
],
),
return const EmptyStateWidget(
icon: Icons.auto_stories_rounded,
title: '日记墙还是空的',
subtitle: '分享你的日记到这里吧',
iconSize: 48,
);
}
@@ -344,31 +328,35 @@ class _DiaryWallCard extends StatelessWidget {
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
// 评语
if (comments.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: AppRadius.smBorder,
),
child: Row(
children: [
const Icon(Icons.rate_review_rounded, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
comments.first.content,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
// 评语(按 journalId 过滤,避免显示在错误卡片下)
...(() {
final journalComments = comments.where((c) => c.journalId == journal.id).toList();
if (journalComments.isEmpty) return <Widget>[];
return [
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: AppRadius.smBorder,
),
child: Row(
children: [
const Icon(Icons.rate_review_rounded, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
journalComments.first.content,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
),
),
],
];
})(),
// 写评语按钮(仅老师可见)
if (_isTeacher(context)) ...[
const SizedBox(height: 8),
@@ -437,10 +425,9 @@ class _TopicsTab extends StatelessWidget {
final colorScheme = theme.colorScheme;
if (topics.isEmpty) {
return Center(
child: Text('暂无主题布置', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
return const EmptyStateWidget(
icon: Icons.assignment_outlined,
title: '暂无主题布置',
);
}

View File

@@ -19,6 +19,8 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.dart';
import '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/discover_bloc.dart';
import '../models/discover_models.dart';
@@ -126,66 +128,19 @@ class DiscoverPage extends StatelessWidget {
/// 错误状态
Widget _buildError(BuildContext context, String message) {
return Column(
children: [
const _LoadingSkeleton(height: 140),
const SizedBox(height: DesignTokens.spacing24),
Container(
width: double.infinity,
padding: const EdgeInsets.all(DesignTokens.spacing16),
decoration: BoxDecoration(
color: AppColors.rose.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Column(
children: [
Icon(Icons.cloud_off_rounded,
size: 32, color: AppColors.rose),
const SizedBox(height: DesignTokens.spacing8),
Text(message,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant),
textAlign: TextAlign.center),
const SizedBox(height: DesignTokens.spacing12),
TextButton.icon(
onPressed: () => context
.read<DiscoverBloc>()
.add(const DiscoverLoadData()),
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重试'),
),
],
),
),
],
return ErrorStateWidget(
message: message,
onRetry: () =>
context.read<DiscoverBloc>().add(const DiscoverLoadData()),
);
}
/// 空数据提示
Widget _buildEmptyHint(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing32),
child: Center(
child: Column(
children: [
const Text('', style: TextStyle(fontSize: 40)),
const SizedBox(height: DesignTokens.spacing12),
Text('还没有发现内容',
style: TextStyle(
fontSize: 15,
color: Theme.of(context).colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 4),
Text('写下你的第一篇日记,出现在这里吧!',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
)),
],
),
),
return const EmptyStateWidget(
icon: Icons.explore_rounded,
title: '还没有发现内容',
subtitle: '试试写一篇日记分享给大家吧',
);
}

View File

@@ -337,6 +337,9 @@ class _EditorViewState extends State<_EditorView> {
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
bool _isViewMode = false;
/// 保存中状态 — 用于显示"保存中..."指示器
bool _isSaving = false;
@override
void initState() {
super.initState();
@@ -554,8 +557,17 @@ class _EditorViewState extends State<_EditorView> {
);
}
/// 返回处理
/// 返回处理 — 有未保存修改时弹出确认
void _handleBack(BuildContext context) {
final bloc = context.read<EditorBloc>();
if (bloc.state.isDirty) {
_showDiscardDialog(context);
} else {
_doNavigateBack(context);
}
}
void _doNavigateBack(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
@@ -563,9 +575,39 @@ class _EditorViewState extends State<_EditorView> {
}
}
/// 保存处理
void _showDiscardDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('放弃编辑?'),
content: const Text('你有未保存的修改,确定要离开吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
_doNavigateBack(context);
},
child: const Text('放弃'),
),
],
),
);
}
/// 保存处理 — 显示"保存中..."后触发保存
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();
}
});
}
/// 显示评论列表
@@ -592,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),
@@ -602,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
),
);
}
// 已保存 — 绿色点
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
@@ -1179,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)),
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,

View File

@@ -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

View File

@@ -425,7 +425,7 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
_StatItem(label: '贴纸数', value: _totalCount > 0 ? '$_totalCount' : '0', valueColor: widget.colorScheme.onSurface),
_StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface),
],
),
);

View File

@@ -5,6 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/sticker_bloc.dart';
/// 贴纸库页面 — 分类浏览贴纸包
@@ -64,18 +66,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
}
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
return ErrorStateWidget(
message: state.errorMessage ?? '加载失败',
onRetry: _bloc.load,
icon: Icons.error_outline,
);
}
@@ -169,7 +163,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
// ---- 贴纸包网格 ----
Expanded(
child: state.filteredPacks.isEmpty
? const Center(child: Text('暂无贴纸包'))
? const EmptyStateWidget(
icon: Icons.sticky_note_2_outlined,
title: '暂无贴纸包',
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:

View File

@@ -0,0 +1,95 @@
// 共享空状态组件 — 统一所有页面的空数据展示
//
// 使用: EmptyStateWidget(icon: Icons.xxx, title: '暂无数据', subtitle: '下拉刷新试试')
// 样式参考: home_page._EmptyJournalState — 大图标淡化 + 标题 + 副标题 + 暖色按钮
import 'package:flutter/material.dart';
import '../core/constants/design_tokens.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_radius.dart';
/// 统一空状态组件 — 图标 + 标题 + 可选副标题 + 可选操作按钮
class EmptyStateWidget extends StatelessWidget {
const EmptyStateWidget({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
this.iconSize = 64,
});
/// 主图标
final IconData icon;
/// 图标大小
final double iconSize;
/// 标题文字
final String title;
/// 副标题(灰色小字)
final String? subtitle;
/// 操作按钮文字(不传则不显示按钮)
final String? actionLabel;
/// 操作按钮回调
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: DesignTokens.spacing16),
Text(
title,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: DesignTokens.spacing8),
Text(
subtitle!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: DesignTokens.spacing24),
FilledButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add_rounded),
label: Text(actionLabel!),
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
foregroundColor: AppColors.bgLight,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,72 @@
// 共享错误状态组件 — 统一所有页面的错误展示
//
// 使用: ErrorStateWidget(message: '加载失败', onRetry: () => ...)
// 样式: 云朵图标 + 错误信息 + 重试按钮
import 'package:flutter/material.dart';
import '../core/constants/design_tokens.dart';
import '../core/theme/app_radius.dart';
/// 统一错误状态组件 — 图标 + 错误信息 + 可选重试按钮
class ErrorStateWidget extends StatelessWidget {
const ErrorStateWidget({
super.key,
required this.message,
this.onRetry,
this.icon = Icons.cloud_off_rounded,
});
/// 错误描述文字
final String message;
/// 重试回调(不传则不显示重试按钮)
final VoidCallback? onRetry;
/// 主图标(默认云朵离线图标)
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.spacing24,
vertical: DesignTokens.spacing40,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 56,
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
),
const SizedBox(height: DesignTokens.spacing16),
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: DesignTokens.spacing20),
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重试'),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,102 @@
// 全局离线提示横幅 — 监听 connectivity_plus 显示/隐藏
//
// 放在 Scaffold body 上方,离线时显示黄色警告横幅
// 使用: 在 responsive_scaffold 的 body 上方嵌套
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import '../core/constants/design_tokens.dart';
import '../core/theme/app_colors.dart';
/// 全局离线提示横幅 — 自动监听网络状态
class OfflineBanner extends StatefulWidget {
const OfflineBanner({super.key, required this.child});
final Widget child;
@override
State<OfflineBanner> createState() => _OfflineBannerState();
}
class _OfflineBannerState extends State<OfflineBanner> {
bool _isOffline = false;
@override
void initState() {
super.initState();
// 初始检查
Connectivity().checkConnectivity().then((result) {
if (mounted) {
setState(() {
_isOffline = result.every((r) => r == ConnectivityResult.none);
});
}
});
// 监听变化
Connectivity().onConnectivityChanged.listen((result) {
if (mounted) {
final offline = result.every((r) => r == ConnectivityResult.none);
if (offline != _isOffline) {
setState(() => _isOffline = offline);
}
}
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 离线横幅 — 带动画滑入/滑出
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: _OfflineBar(),
crossFadeState: _isOffline
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: DesignTokens.animNormal,
sizeCurve: DesignTokens.warmCurve,
),
// 正常内容
Expanded(child: widget.child),
],
);
}
}
/// 离线横幅条
class _OfflineBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.15),
border: Border(
bottom: BorderSide(
color: AppColors.warning.withValues(alpha: 0.3),
width: 1,
),
),
),
child: Row(
children: [
Icon(Icons.wifi_off_rounded, size: 16, color: AppColors.warning),
const SizedBox(width: 8),
Expanded(
child: Text(
'网络不可用,部分功能受限',
style: TextStyle(
fontSize: 13,
color: AppColors.warning,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../core/constants/breakpoints.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_typography.dart';
import '../core/theme/app_radius.dart';
import 'offline_banner.dart';
/// 导航项数量(不含中心 FAB
const int kNavItemCount = 4;
@@ -167,7 +168,7 @@ class _MobileLayout extends StatelessWidget {
appBar: appBarTitle != null
? AppBar(title: Text(appBarTitle!))
: null,
body: body,
body: OfflineBanner(child: body),
extendBody: true, // 允许内容延伸到 Tab 栏下面(圆角透明效果)
bottomNavigationBar: _BottomNavBar(
selectedIndex: selectedIndex,

View File

@@ -0,0 +1,156 @@
// 共享骨架屏加载组件 — 替代 CircularProgressIndicator
//
// 使用: SkeletonBox(height: 80) 或 SkeletonBox(height: 48, shimmer: true)
// 样式: 灰色圆角矩形 + 可选 shimmer 微光动画
import 'package:flutter/material.dart';
import '../core/theme/app_radius.dart';
/// 骨架屏加载占位块
class SkeletonBox extends StatelessWidget {
const SkeletonBox({
super.key,
required this.height,
this.width,
this.shimmer = false,
this.borderRadius,
});
/// 高度
final double height;
/// 宽度(默认撑满父容器)
final double? width;
/// 是否启用 shimmer 微光动画
final bool shimmer;
/// 自定义圆角(默认 AppRadius.md
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
final radius = borderRadius ?? AppRadius.mdBorder;
if (shimmer) {
return _ShimmerBox(
height: height,
width: width,
bgColor: bgColor,
borderRadius: radius,
);
}
return Container(
height: height,
width: width,
decoration: BoxDecoration(
color: bgColor,
borderRadius: radius,
),
);
}
}
/// 带微光动画的骨架屏
class _ShimmerBox extends StatefulWidget {
const _ShimmerBox({
required this.height,
this.width,
required this.bgColor,
required this.borderRadius,
});
final double height;
final double? width;
final Color bgColor;
final BorderRadius borderRadius;
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
height: widget.height,
width: widget.width,
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * _controller.value, 0),
end: Alignment(1.0 + 2.0 * _controller.value, 0),
colors: [
widget.bgColor,
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15),
widget.bgColor,
],
),
),
);
},
);
}
}
/// 通用列表骨架屏 — 显示 N 行占位
class SkeletonList extends StatelessWidget {
const SkeletonList({
super.key,
this.itemCount = 5,
this.itemHeight = 72,
this.spacing = 12,
this.shimmer = false,
});
/// 骨架行数
final int itemCount;
/// 每行高度
final double itemHeight;
/// 行间距
final double spacing;
/// 是否 shimmer
final bool shimmer;
@override
Widget build(BuildContext context) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: itemCount,
itemBuilder: (_, _) => Padding(
padding: EdgeInsets.only(bottom: spacing),
child: SkeletonBox(height: itemHeight, shimmer: shimmer),
),
);
}
}

306
plans/next-steps-roadmap.md Normal file
View File

@@ -0,0 +1,306 @@
# 暖记下一步工作路线图
> **创建日期**: 2026-06-07
> **状态**: 讨论稿,待确认优先级后逐一推进
> **当前代码状态**: 工作区 26 个文件 +1051/-319 行未提交
---
## 当前快照
| 指标 | 数值 |
|------|------|
| 后端测试 | 518 通过 |
| 前端测试 | 116 新增 (Phase 4) |
| 审计发现 | 345 项 (42 C / 90 H / 123 M / 90 L) |
| 未提交改动 | Discover 全栈 + 编辑器增强 + 多页动态化 |
| 未提交新文件 | discover_bloc / discover_models / discover_handler / discover_service |
---
## 方向 A — 当前改动收尾提交
> **目标**: 把工作区 1000+ 行改动拆分成有意义的提交,保持 git 历史清晰
> **预估**: 2-3h拆分 + 验证 + 提交)
> **优先级**: ⭐⭐⭐⭐⭐ — 做任何事之前先把当前工作落地
### A.1 提交拆分建议
| # | 提交内容 | 涉及文件 | scope |
|---|---------|---------|-------|
| 1 | Discover 后端 (DTO + Service + Handler + 路由注册) | crates/erp-diary 4 文件 + main.rs | `feat(diary)` |
| 2 | Discover 前端 (BLoC + Models + Page 动态化) | app/features/discover/ 4 文件 | `feat(app)` |
| 3 | 编辑器: 查看模式 + 图层排序 | editor_page + editor_bloc + draggable_element | `feat(app)` |
| 4 | 编辑器: 标签面板动态化 + 贴纸选择器重构 | tag_panel + sticker_picker_sheet | `feat(app)` |
| 5 | 搜索页动态化 (热搜词 + 模板搜索) | search_page | `feat(app)` |
| 6 | 个人资料页动态化 (成就徽章 + 头像首字母) | profile_page | `feat(app)` |
| 7 | 教师页班级码对话框 | teacher_page | `feat(app)` |
| 8 | 贴纸库动态分类 | sticker_library_page | `feat(app)` |
| 9 | Wiki 文档更新 | wiki/ 4 文件 | `docs` |
### A.2 验证清单
- [ ] `cargo check` 后端编译通过
- [ ] `cargo test` 后端测试通过
- [ ] `flutter analyze` 前端分析通过
- [ ] 每个提交可独立编译
### A.3 讨论点
- Discover 功能是否属于 Phase 1 范围?审计报告 7b-H06 指出它属于 Phase 2
- 编辑器查看模式是新增功能还是修复?影响提交类型选择 (feat vs fix)
---
## 方向 B — 审计修复路线图推进
> **目标**: 按审计报告 345 项发现的修复路线图推进
> **预估**: Phase 0 已完成 ~80%Phase 1-3 待推进
> **优先级**: ⭐⭐⭐⭐ — 安全和稳定性是上线的前提
### B.1 Phase 0 紧急修复 — 已完成项 ✅
| # | 修复项 | 状态 |
|---|--------|------|
| 1 | RLS 变量名 bug 修复 | ✅ 已修复 |
| 2 | 内容安全词库填充 + 分享前检查 | ✅ 已修复 |
| 3 | 日记列表 IDOR 修复 | 需确认 |
| 4 | 审计日志 + 文件上传权限守卫 | 需确认 |
| 5 | class_service unwrap() 安全处理 | 需确认 |
| 6 | 事务添加 (create_class/join_class) | 需确认 |
| 7 | 笔画缓存 use-after-dispose 修复 | ✅ 已修复 |
### B.2 Phase 1 安全加固 (~31h)
| # | 任务 | 预估 | 讨论点 |
|---|------|------|--------|
| 1 | 家长同意验证流程 | 8h | PIPL 合规法律要求,但 Phase 1 MVP 是否先跳过? |
| 2 | 家长绑定验证流程 | 4h | 同上 |
| 3 | Token 黑名单改用 SHA-256 | 1h | 简单,可顺手做 |
| 4 | **全部 DTO 添加 Validate + handler 调用** | 8h | 影响面最大,系统性修复 |
| 5 | Flutter 强制 HTTPS + 证书固定 | 4h | 开发阶段 HTTP 方便调试,生产再强制? |
| 6 | 班级码改用字母数字混合 | 2h | 安全强度提升 3386 倍 |
| 7 | Flutter Token 自动刷新 | 4h | 体验提升明显 |
### B.3 Phase 2 性能优化 (~24h)
| # | 任务 | 预估 |
|---|------|------|
| 1 | 后端 4 个 N+1 查询修复 | 8h |
| 2 | Isar 索引 + 分页 + N+1 修复 | 6h |
| 3 | mood_stats 改用 SQL GROUP BY | 2h |
| 4 | 笔画光栅化改为 BBox 裁剪 (50 条笔画 1.6GB → 合理范围) | 4h |
| 5 | SyncEngine 合并同资源操作 | 4h |
### B.4 Phase 3 质量提升 (持续)
| # | 任务 |
|---|------|
| 1 | 补充后端集成测试 + Flutter BLoC 测试 |
| 2 | 统一同步协议 (Flutter ↔ Rust) |
| 3 | 创建端点改返回 201 |
| 4 | 添加 DiaryApiDoc OpenAPI 文档 |
| 5 | 无障碍性 Semantics |
| 6 | DiaryEvent 枚举接入 EventBus |
### B.5 讨论点
- Phase 1 的家长验证流程比较重MVP 阶段是否可以用简化版(注册时勾选确认代替完整验证链)?
- 性能优化在数据量小的时候感知不明显,是否延后到真实用户测试时再做?
- 同步协议断裂是最严重的架构问题,但也是改动量最大的——何时投入?
---
## 方向 C — 端到端联调
> **目标**: 确保 Flutter → Rust API 全链路数据流通
> **预估**: 8-16h
> **优先级**: ⭐⭐⭐⭐ — 不联调就不知道能不能跑
### C.1 核心联调路径
| # | 链路 | 前端模块 | 后端 API | 状态 |
|---|------|---------|---------|------|
| 1 | **注册 → 登录 → Token 获取** | AuthBloc | POST /auth/* | ✅ 通过 |
| 2 | **创建日记 → 保存到后端** | EditorBloc → JournalRepository | POST /diary/journals | ✅ 通过 |
| 3 | **日记列表加载** | HomeBloc | GET /diary/journals | ✅ 通过 |
| 4 | **班级创建 → 加入 → 查看成员** | ClassBloc | POST/GET /diary/classes/* | ✅ 通过 |
| 5 | **贴纸浏览 → 收藏** | StickerBloc | GET /diary/stickers/* | ✅ 通过 (空) |
| 6 | **发现页数据加载** | DiscoverBloc | GET /diary/discover | ✅ 通过 |
| 7 | **搜索日记/模板** | SearchBloc | GET /diary/journals?search= | ✅ 通过 |
| 8 | **心情统计** | CalendarBloc | GET /diary/stats/mood | ✅ 通过 |
| 9 | **同步 (离线→在线)** | SyncEngine | POST /diary/sync | ❌ 协议断裂 |
### C.1b DTO 验证生效确认
| 测试 | 结果 |
|------|------|
| 负版本号 `version: -1` | ✅ 拒绝: "版本号不能为负数" |
| 标签过长 (50+ chars) | ✅ 拒绝: "超过 30 字符" |
| 班级码非字母数字 `!!!!!!` | ✅ 拒绝: "仅允许字母和数字" |
| 贴纸包负价格 `price: -100` | ✅ 拒绝: "价格不能为负数" |
| 评语创建 (admin 角色) | ✅ 正确返回 403 (仅 teacher) |
### C.2 联调方式讨论
**方案 A — 手动联调(快速验证)**
- 启动后端 + Flutter Web
- 手动走一遍核心流程
- 用 Chrome DevTools 看 Network 请求
- 优点:快速、直观
- 缺点:不可复现、覆盖不全
**方案 B — 自动化集成测试(推荐)**
- 后端:已有 9 个集成测试,扩展到覆盖所有 API
- 前端:用 integration_test 包写 Flutter 集成测试
- 优点:可复现、持续验证
- 缺点:前期投入大
**方案 C — Postman/HTTP 请求集**
- 先用 Postman 验证所有后端 API
- 再手动跑 Flutter 前端
- 折中方案
### C.3 讨论点
- 同步功能 (链路 9) 已知断裂,联调时是否先跳过?
- 先做后端 API 验证还是直接跑 Flutter
- 是否需要准备测试数据种子?
---
## 方向 D — 产品体验打磨
> **目标**: 从小学生用户视角出发,打磨交互体验
> **预估**: 持续迭代
> **优先级**: ⭐⭐⭐ — 功能能跑之后的首要任务
### D.1 体验问题清单
| # | 问题 | 严重性 | 讨论点 |
|---|------|--------|--------|
| 1 | **首次使用引导** — 新用户打开 app 不知道干什么 | HIGH | 需要设计引导流程 |
| 2 | **空状态设计** — 列表为空时显示什么? | MEDIUM | 需要插画/文案 |
| 3 | **加载状态** — 骨架屏 vs 转圈 vs 进度条? | MEDIUM | 统一 Loading 策略 |
| 4 | **错误提示** — 网络错误/服务器错误/离线 如何提示? | HIGH | 儿童友好的错误文案 |
| 5 | **手写反馈** — 写字时有声音/动画反馈吗? | LOW | 增强沉浸感 |
| 6 | **心情追踪可视化** — fl_chart 集成够好看吗? | MEDIUM | 需要实际渲染验证 |
| 7 | **成就/徽章系统** — 有雏形但完整吗? | MEDIUM | 当前只有数据模型 |
| 8 | **贴纸交互** — 拖拽/缩放/旋转手感如何? | HIGH | 核心体验 |
| 9 | **日历视图** — 月历和日记的关联够直观吗? | MEDIUM | |
| 10 | **班级动态** — 老师点评 → 学生能看到吗? | HIGH | 核心社交功能 |
### D.2 可能的新功能想法
| # | 想法 | 复杂度 | 讨论点 |
|---|------|--------|--------|
| 1 | **每日写作提示** — "今天你吃了什么好吃的?" | 低 | 已有 Discover daily_inspiration可扩展 |
| 2 | **天气/心情自动检测** — 根据文字内容建议心情标签 | 中 | 需要 NLP 或关键词匹配 |
| 3 | **日记模板市场** — 下载别人分享的日记模板 | 高 | Phase 2 功能? |
| 4 | **语音输入** — 口述转文字辅助写作 | 高 | 技术选型待定 |
| 5 | **涂鸦动画回放** — 把绘画过程做成短视频 | 中 | 技术上可行 (已有时间戳数据) |
| 6 | **多页日记** — 一篇日记可以有很多页 | 中 | 编辑器需要支持翻页 |
| 7 | **日记封面** — 自动/手动生成好看的封面 | 低 | 取日记第一张图或涂鸦 |
| 8 | **分享到班级圈** — 类似朋友圈的班级动态流 | 高 | Phase 2 功能? |
### D.3 讨论点
- 哪些体验问题必须在 MVP 之前解决?
- 新功能想法中哪些值得现在就开始探索?
- 是否需要做一轮真实的用户测试(找几个小学生试用)?
---
## 方向 E — 上线准备 / DevOps
> **目标**: 从开发环境走向可发布的 App
> **预估**: 20-40h (取决于目标平台)
> **优先级**: ⭐⭐ — 功能稳定后再做
### E.1 短期目标:可内测的 APK
| # | 任务 | 预估 | 状态 |
|---|------|------|------|
| 1 | Flutter Android 构建 + 签名 | 4h | 未开始 |
| 2 | 生产环境配置 (HTTPS, 密钥管理) | 4h | 未开始 |
| 3 | 服务器部署 (Docker Compose 生产配置) | 4h | 未开始 |
| 4 | 环境变量管理 (.env → 生产密钥注入) | 2h | 未开始 |
### E.2 中期目标:应用商店
| # | 任务 | 预估 |
|---|------|------|
| 1 | App Store / Google Play 开发者账号 | 1h |
| 2 | 应用图标 + 启动页设计 | 4h |
| 3 | 隐私政策 + 用户协议 (PIPL 合规) | 4h |
| 4 | 应用截图 + 描述文案 | 2h |
| 5 | 内部测试轨道分发 | 2h |
| 6 | iOS 打包准备 (Mac + Xcode + 证书) | 8h |
### E.3 CI/CD 流水线
| # | 任务 | 预估 |
|---|------|------|
| 1 | GitHub Actions / Gitea CI 配置 | 4h |
| 2 | 后端: cargo test + clippy + Docker build | 2h |
| 3 | 前端: flutter analyze + test + build | 2h |
| 4 | 管理端: pnpm lint + build | 1h |
| 5 | 自动部署到测试服务器 | 4h |
### E.4 讨论点
- 先做 Android APK开发者模式安装给内部测试
- 服务器是用现有机器还是云服务器?
- CI/CD 用 Gitea 内置的还是 GitHub Actions
- iOS 是否需要购买 Mac 设备?
---
## 方向间的依赖关系
```
A (提交收尾)
├─→ B (审计修复) ─→ E (上线准备)
│ │
├─→ C (端到端联调) │
│ │ │
│ └─→ D (体验打磨)
└─────────────────────────────→ 时间线
```
**关键路径**: A → C → D → E先提交 → 再联调 → 再打磨 → 再上线)
**安全路径**: A → B → E先提交 → 再修 bug → 再上线)
**混合路径**: A → B+C 并行 → D → E推荐
---
## 建议的推进顺序
1. **A — 收尾提交** ✅ (已完成) — 7 个提交已推送
2. **B — 安全加固** ✅ (已完成) — DTO 验证 + Token 刷新 + 班级码
3. **C — 端到端联调** ✅ (已完成) — 8/9 条链路通过
4. **D.1 体验问题修复** (下一步) — 好用
5. **B.3 性能优化** (2-3 天) — 快
6. **E.1 内测 APK** (1-2 天) — 可分享
7. **D.2 新功能探索** (持续) — 有趣
8. **E.2-E.3 应用商店 + CI/CD** (1-2 周) — 正式上线
---
## 开放讨论
> 以下问题需要在推进前达成共识:
1. **Phase 范围**: Discover 功能属于 Phase 1 还是 Phase 2审计报告标记为 Phase 2 违规
2. **MVP 定义**: 最小可用版本的边界在哪?哪些功能必须有,哪些可以等?
3. **家长验证**: 完整 PIPL 流程 vs 注册勾选确认MVP 选哪个?
4. **同步策略**: 修复现有协议 vs 重新设计 vs MVP 先不支持同步?
5. **测试设备**: 有没有 Android 设备可以用来真机测试?
6. **目标用户验证**: 有没有机会找真实小学生试用收集反馈?
7. **时间预期**: 从现在到"可内测"你期望多久?
---
*文档结束 — 确认方向后按优先级逐一推进*