Compare commits
64 Commits
8ea1032c9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af7cd64e6 | ||
|
|
ec8a04c80a | ||
|
|
750605e479 | ||
|
|
346c751cbb | ||
|
|
2f96f9a4f4 | ||
|
|
f64355946c | ||
|
|
1f48a67db5 | ||
|
|
225af89e41 | ||
|
|
dbb74b6545 | ||
|
|
3c3d70c751 | ||
|
|
ed8252d7c8 | ||
|
|
41ef28f20b | ||
|
|
d67eedf7de | ||
|
|
a05374e8d1 | ||
|
|
a5d2b0409f | ||
|
|
3bc2ca7332 | ||
|
|
4cb91f3ac9 | ||
|
|
c253c8ddcf | ||
|
|
bb388ed8ff | ||
|
|
c441aa4e34 | ||
|
|
e635557e67 | ||
|
|
138bfa9723 | ||
|
|
b72009718f | ||
|
|
9fce34f4ef | ||
|
|
988ee7335a | ||
|
|
9c92cba87f | ||
|
|
f6d394afb6 | ||
|
|
4cd08535d3 | ||
|
|
271f0c4f29 | ||
|
|
4cd381295a | ||
|
|
8300822232 | ||
|
|
367f21de08 | ||
|
|
1766cefde9 | ||
|
|
38592d61ce | ||
|
|
e8df3a9562 | ||
|
|
32a91551c4 | ||
|
|
b6ffc60331 | ||
|
|
4e5c1287a6 | ||
|
|
3258acaa77 | ||
|
|
0c9ada242a | ||
|
|
99db8e5cb0 | ||
|
|
a34c9fd176 | ||
|
|
45949e3ed0 | ||
|
|
c4b2de8294 | ||
|
|
cca2d77ea2 | ||
|
|
6d7ac05d0f | ||
|
|
11d0971a67 | ||
|
|
b81a972245 | ||
|
|
af7d3f65fd | ||
|
|
9ce300ddb9 | ||
|
|
e0052ea99b | ||
|
|
1750f17f41 | ||
|
|
5f06056d26 | ||
|
|
935918c9ab | ||
|
|
d482497e49 | ||
|
|
45530616ee | ||
|
|
d6dd017155 | ||
|
|
f0741450bc | ||
|
|
c9a69d0be1 | ||
|
|
9e53ca8555 | ||
|
|
6c9a38b27b | ||
|
|
e57c3427a4 | ||
|
|
c92ead60e3 | ||
|
|
ab45f40cc8 |
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -1,8 +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:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
BIN
app/android/app/src/main/res/drawable/launch_logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 12 KiB |
4
app/android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_bg">#1A1614</color>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
4
app/android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">暖记</string>
|
||||
</resources>
|
||||
9
app/android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
app/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">暖记</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
20
app/android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 网络安全配置 — 强制 HTTPS,仅允许 localhost 明文(开发用)
|
||||
审计 ID: 6b-C01 — Flutter 默认 HTTP 明文传输修复
|
||||
-->
|
||||
<network-security-config>
|
||||
<!-- 生产配置:强制 HTTPS -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- 开发配置:允许 localhost/10.0.2.2 明文(模拟器/本地调试)
|
||||
生产构建时应移除此段 -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
108
app/lib/app.dart
@@ -10,15 +10,17 @@
|
||||
// └─ BlocProvider<AuthBloc>
|
||||
// └─ MaterialApp.router
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
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';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'data/local/secure_token_store_factory.dart';
|
||||
import 'data/remote/api_client.dart';
|
||||
import 'data/repositories/auth_repository.dart';
|
||||
import 'data/repositories/journal_repository.dart';
|
||||
@@ -30,63 +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 生命周期内单例)
|
||||
final config = AppConfig.fromEnvironment();
|
||||
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||
final authRepository = AuthRepository(apiClient: apiClient);
|
||||
// 离线优先:Isar 为主要本地仓库,Remote 供 SyncEngine 推送
|
||||
// Web 平台:Isar 3.x 不支持 Web,直接使用远程仓库
|
||||
final journalRepository = kIsWeb
|
||||
? RemoteJournalRepository(api: apiClient)
|
||||
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();
|
||||
_apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||
final tokenStore = createSecureTokenStore();
|
||||
_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());
|
||||
};
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// 应用环境配置 — 通过 --dart-define 注入
|
||||
//
|
||||
// 使用方式:
|
||||
// flutter run --dart-define=API_BASE_URL=http://localhost:3000/api/v1
|
||||
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1
|
||||
// flutter run # 开发模式(localhost)
|
||||
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 # 生产模式
|
||||
//
|
||||
// 安全说明:
|
||||
// - 生产环境强制 HTTPS(Android network_security_config 禁止明文流量)
|
||||
// - 开发模式使用 localhost(Android 网络安全配置已允许 localhost 明文)
|
||||
|
||||
/// 应用环境配置 — 集中管理所有外部服务地址
|
||||
class AppConfig {
|
||||
@@ -19,19 +23,20 @@ class AppConfig {
|
||||
|
||||
/// 从编译时环境变量构建配置
|
||||
///
|
||||
/// 使用 `--dart-define` 注入,未设置时使用默认值。
|
||||
/// 使用 `--dart-define` 注入,未设置时使用生产 HTTPS 默认值。
|
||||
/// 开发环境使用 [dev] 常量或通过 --dart-define 覆盖。
|
||||
factory AppConfig.fromEnvironment({
|
||||
String defaultApiBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultSseBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultApiBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||
String defaultSseBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||
}) {
|
||||
// const String.fromEnvironment 在编译时求值
|
||||
const apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||
);
|
||||
const sseBaseUrl = String.fromEnvironment(
|
||||
'SSE_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
@@ -40,7 +45,7 @@ class AppConfig {
|
||||
);
|
||||
}
|
||||
|
||||
/// 开发环境默认配置
|
||||
/// 开发环境默认配置(localhost 明文 — 仅用于本地调试)
|
||||
static const dev = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:3000/api/v1',
|
||||
sseBaseUrl: 'http://localhost:3000/api/v1',
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../../features/profile/views/profile_page.dart';
|
||||
import '../../features/editor/views/editor_page.dart';
|
||||
import '../../features/auth/views/login_page.dart';
|
||||
import '../../features/auth/views/role_selection_page.dart';
|
||||
import '../../features/auth/views/parental_consent_page.dart';
|
||||
import '../../features/auth/views/class_code_join_page.dart';
|
||||
import '../../features/onboarding/views/splash_page.dart';
|
||||
import '../../features/onboarding/views/onboarding_page.dart';
|
||||
@@ -41,6 +42,7 @@ import '../../features/templates/views/template_gallery_page.dart';
|
||||
import '../../features/settings/views/settings_page.dart';
|
||||
import '../../features/auth/bloc/auth_bloc.dart';
|
||||
import '../../features/search/bloc/search_bloc.dart';
|
||||
import '../../features/discover/bloc/discover_bloc.dart';
|
||||
import '../../data/repositories/journal_repository.dart';
|
||||
import '../../data/remote/api_client.dart';
|
||||
|
||||
@@ -49,7 +51,7 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 不需要认证的白名单路径
|
||||
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/class-code'];
|
||||
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/parental-consent', '/class-code'];
|
||||
|
||||
/// 创建路由配置 — 需要注入 AuthBloc
|
||||
GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
@@ -74,6 +76,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
// 已认证 + 访问公开页面 → 根据状态重定向
|
||||
if (isAuthenticated && isPublicPath) {
|
||||
if (authState.needsRoleSelection) return '/role-selection';
|
||||
if (authState.needsParentalConsent) return '/parental-consent';
|
||||
if (authState.needsClassCode) return '/class-code';
|
||||
return '/home';
|
||||
}
|
||||
@@ -83,9 +86,14 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
if (authState.needsRoleSelection && currentPath != '/role-selection') {
|
||||
return '/role-selection';
|
||||
}
|
||||
if (authState.needsParentalConsent &&
|
||||
currentPath != '/parental-consent') {
|
||||
return '/parental-consent';
|
||||
}
|
||||
if (authState.needsClassCode &&
|
||||
currentPath != '/class-code' &&
|
||||
currentPath != '/role-selection') {
|
||||
currentPath != '/role-selection' &&
|
||||
currentPath != '/parental-consent') {
|
||||
return '/class-code';
|
||||
}
|
||||
return null;
|
||||
@@ -125,6 +133,11 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
name: 'roleSelection',
|
||||
builder: (context, state) => const RoleSelectionPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parental-consent',
|
||||
name: 'parentalConsent',
|
||||
builder: (context, state) => const ParentalConsentPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class-code',
|
||||
name: 'classCode',
|
||||
@@ -156,7 +169,13 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
GoRoute(
|
||||
path: '/discover',
|
||||
name: 'discover',
|
||||
builder: (context, state) => const DiscoverPage(),
|
||||
builder: (context, state) {
|
||||
return BlocProvider(
|
||||
create: (_) => DiscoverBloc(api: context.read<ApiClient>())
|
||||
..add(const DiscoverLoadData()),
|
||||
child: const DiscoverPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 个人中心
|
||||
GoRoute(
|
||||
|
||||
@@ -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), // 🤔 思考 — 灰棕
|
||||
};
|
||||
|
||||
// ===== 浅色主题色彩方案 =====
|
||||
|
||||
9
app/lib/core/utils/download_impl.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
// 文件下载 — 非 Web 平台 stub
|
||||
//
|
||||
// 非 Web 平台暂不支持文件下载,返回 false。
|
||||
// Phase 2 扩展:使用 path_provider + File 实现。
|
||||
|
||||
/// 下载文件(stub 实现)
|
||||
Future<bool> downloadFile(String content, String filename, String mimeType) async {
|
||||
return false;
|
||||
}
|
||||
21
app/lib/core/utils/download_impl_web.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// 文件下载 — Web 平台实现
|
||||
//
|
||||
// 使用 dart:html 的 AnchorElement + Blob 触发浏览器下载。
|
||||
// 通过 conditional import 自动选择此实现。
|
||||
|
||||
import 'dart:html' as html;
|
||||
|
||||
/// 下载文件(Web 实现)
|
||||
Future<bool> downloadFile(String content, String filename, String mimeType) async {
|
||||
try {
|
||||
final blob = html.Blob([content], mimeType);
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', filename)
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
23
app/lib/core/utils/file_download.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
// 文件下载工具 — 跨平台接口
|
||||
//
|
||||
// Web: 通过 html.AnchorElement + Blob 触发浏览器下载
|
||||
// 非 Web: 返回 false(Phase 2 扩展 path_provider)
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'download_impl.dart'
|
||||
if (dart.library.html) 'download_impl_web.dart';
|
||||
|
||||
/// 下载 JSON 数据为文件
|
||||
///
|
||||
/// [data] — 要导出的 JSON 数据
|
||||
/// [filename] — 下载文件名(如 "export_2026-06-02.json")
|
||||
///
|
||||
/// 返回 true 表示下载成功。
|
||||
Future<bool> downloadJsonFile(
|
||||
Map<String, dynamic> data,
|
||||
String filename,
|
||||
) async {
|
||||
final jsonStr = const JsonEncoder.withIndent(' ').convert(data);
|
||||
return downloadFile(jsonStr, filename, 'application/json');
|
||||
}
|
||||
@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
|
||||
|
||||
const JournalElementCollectionSchema = CollectionSchema(
|
||||
name: r'JournalElementCollection',
|
||||
id: -1002,
|
||||
id: -3625932583395690305,
|
||||
properties: {
|
||||
r'contentJson': PropertySchema(
|
||||
id: 0,
|
||||
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
|
||||
idName: r'isarId',
|
||||
indexes: {
|
||||
r'id': IndexSchema(
|
||||
id: -2002,
|
||||
id: -3268401673993471357,
|
||||
name: r'id',
|
||||
unique: false,
|
||||
replace: false,
|
||||
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
|
||||
],
|
||||
),
|
||||
r'journalId': IndexSchema(
|
||||
id: 3001,
|
||||
id: 1745640946427815323,
|
||||
name: r'journalId',
|
||||
unique: false,
|
||||
replace: false,
|
||||
|
||||
@@ -16,7 +16,8 @@ class JournalEntryCollection {
|
||||
@Index()
|
||||
String id = '';
|
||||
|
||||
/// 作者 ID
|
||||
/// 作者 ID(索引 + 组合索引 authorId+dateEpoch,覆盖按作者查询并按日期排序的场景)
|
||||
@Index(composite: [CompositeIndex('dateEpoch')])
|
||||
String authorId = '';
|
||||
|
||||
/// 班级 ID(可选)
|
||||
@@ -25,7 +26,8 @@ class JournalEntryCollection {
|
||||
/// 日记标题
|
||||
String title = '';
|
||||
|
||||
/// 日记日期(epoch milliseconds)
|
||||
/// 日记日期(epoch milliseconds)— 单独索引支持日期范围查询
|
||||
@Index()
|
||||
int dateEpoch = 0;
|
||||
|
||||
/// 心情(enum → string)
|
||||
|
||||
@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
|
||||
|
||||
const JournalEntryCollectionSchema = CollectionSchema(
|
||||
name: r'JournalEntryCollection',
|
||||
id: -1001,
|
||||
id: -6325316395299921961,
|
||||
properties: {
|
||||
r'assignedTopicId': PropertySchema(
|
||||
id: 0,
|
||||
@@ -106,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
|
||||
idName: r'isarId',
|
||||
indexes: {
|
||||
r'id': IndexSchema(
|
||||
id: -2001,
|
||||
id: -3268401673993471357,
|
||||
name: r'id',
|
||||
unique: false,
|
||||
replace: false,
|
||||
@@ -117,6 +117,37 @@ const JournalEntryCollectionSchema = CollectionSchema(
|
||||
caseSensitive: true,
|
||||
)
|
||||
],
|
||||
),
|
||||
r'authorId_dateEpoch': IndexSchema(
|
||||
id: -4869847655132214108,
|
||||
name: r'authorId_dateEpoch',
|
||||
unique: false,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'authorId',
|
||||
type: IndexType.hash,
|
||||
caseSensitive: true,
|
||||
),
|
||||
IndexPropertySchema(
|
||||
name: r'dateEpoch',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
)
|
||||
],
|
||||
),
|
||||
r'dateEpoch': IndexSchema(
|
||||
id: 359017825055613028,
|
||||
name: r'dateEpoch',
|
||||
unique: false,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'dateEpoch',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
links: {},
|
||||
@@ -277,6 +308,15 @@ extension JournalEntryCollectionQueryWhereSort
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterWhere>
|
||||
anyDateEpoch() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
const IndexWhereClause.any(indexName: r'dateEpoch'),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension JournalEntryCollectionQueryWhere on QueryBuilder<
|
||||
@@ -393,6 +433,242 @@ extension JournalEntryCollectionQueryWhere on QueryBuilder<
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToAnyDateEpoch(String authorId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
value: [authorId],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdNotEqualToAnyDateEpoch(String authorId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [],
|
||||
upper: [authorId],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [],
|
||||
upper: [authorId],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause>
|
||||
authorIdDateEpochEqualTo(String authorId, int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
value: [authorId, dateEpoch],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause>
|
||||
authorIdEqualToDateEpochNotEqualTo(String authorId, int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
upper: [authorId, dateEpoch],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [authorId],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [authorId],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
upper: [authorId, dateEpoch],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToDateEpochGreaterThan(
|
||||
String authorId,
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, dateEpoch],
|
||||
includeLower: include,
|
||||
upper: [authorId],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToDateEpochLessThan(
|
||||
String authorId,
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
upper: [authorId, dateEpoch],
|
||||
includeUpper: include,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToDateEpochBetween(
|
||||
String authorId,
|
||||
int lowerDateEpoch,
|
||||
int upperDateEpoch, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, lowerDateEpoch],
|
||||
includeLower: includeLower,
|
||||
upper: [authorId, upperDateEpoch],
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochEqualTo(int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'dateEpoch',
|
||||
value: [dateEpoch],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochNotEqualTo(int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [],
|
||||
upper: [dateEpoch],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [],
|
||||
upper: [dateEpoch],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochGreaterThan(
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [dateEpoch],
|
||||
includeLower: include,
|
||||
upper: [],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochLessThan(
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [],
|
||||
upper: [dateEpoch],
|
||||
includeUpper: include,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochBetween(
|
||||
int lowerDateEpoch,
|
||||
int upperDateEpoch, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [lowerDateEpoch],
|
||||
includeLower: includeLower,
|
||||
upper: [upperDateEpoch],
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension JournalEntryCollectionQueryFilter on QueryBuilder<
|
||||
|
||||
@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
|
||||
|
||||
const PendingOperationCollectionSchema = CollectionSchema(
|
||||
name: r'PendingOperationCollection',
|
||||
id: -1003,
|
||||
id: -6885010264946527864,
|
||||
properties: {
|
||||
r'createdAtEpoch': PropertySchema(
|
||||
id: 0,
|
||||
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
|
||||
idName: r'isarId',
|
||||
indexes: {
|
||||
r'id': IndexSchema(
|
||||
id: -2003,
|
||||
id: -3268401673993471357,
|
||||
name: r'id',
|
||||
unique: false,
|
||||
replace: false,
|
||||
|
||||
@@ -1,88 +1,14 @@
|
||||
// Isar 数据库初始化 — 本地持久化存储
|
||||
// Isar 数据库条件导出
|
||||
//
|
||||
// Isar 3.x 要求 open() 时传入 List<CollectionSchema>。
|
||||
// 通过 build_runner 生成 Schema,在 main.dart 启动时调用 init()。
|
||||
// 根据平台自动选择实现:
|
||||
// - 原生平台 (Android/iOS/Desktop) → isar_database_native.dart
|
||||
// - Web 平台 → isar_database_web.dart (空 stub)
|
||||
//
|
||||
// ⚠️ Web 平台限制:Isar 3.x 暂不支持 Web。
|
||||
// 在 Web 上跳过 Isar 初始化,使用纯内存/远程模式。
|
||||
// 生产环境以移动端 (Android/iOS) 为主。
|
||||
// 条件导出逻辑:
|
||||
// dart.library.io 存在 → 原生平台,使用 native 实现
|
||||
// 否则(Web)→ 使用 web stub
|
||||
//
|
||||
// 使用方式不变:import 'isar_database.dart';
|
||||
// 用 IsarDatabase.isAvailable 判断平台可用性。
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'collections/journal_entry_collection.dart';
|
||||
import 'collections/journal_element_collection.dart';
|
||||
import 'collections/pending_operation_collection.dart';
|
||||
|
||||
/// Isar 数据库单例管理
|
||||
class IsarDatabase {
|
||||
IsarDatabase._();
|
||||
|
||||
static Isar? _instance;
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 所有 Collection Schema(由 build_runner 生成)
|
||||
static final List<CollectionSchema<dynamic>> schemas = [
|
||||
JournalEntryCollectionSchema,
|
||||
JournalElementCollectionSchema,
|
||||
PendingOperationCollectionSchema,
|
||||
];
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// Web 平台上 Isar 不可用,使用纯远程模式
|
||||
static bool get isAvailable => !kIsWeb;
|
||||
|
||||
/// 初始化数据库
|
||||
///
|
||||
/// 在 main() 中调用,open 之前需确保 WidgetsFlutterBinding 已初始化。
|
||||
/// Web 平台跳过 Isar 初始化(3.x 不支持 Web),仅使用远程 API。
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
// Web 平台:Isar 3.x 不支持 Web,跳过本地数据库初始化
|
||||
if (kIsWeb) {
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 桌面/移动端:使用文件系统
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_instance = await Isar.open(
|
||||
schemas,
|
||||
directory: dir.path,
|
||||
inspector: true, // 开发模式,发布时关闭
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// 获取 Isar 实例(必须先调用 [init])
|
||||
///
|
||||
/// Web 平台不可用时返回 null,调用方需检查 [isAvailable]。
|
||||
static Isar? get instance {
|
||||
if (kIsWeb) return null;
|
||||
if (_instance == null || !_instance!.isOpen) {
|
||||
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 关闭数据库连接
|
||||
static Future<void> close() async {
|
||||
if (_instance != null && _instance!.isOpen) {
|
||||
await _instance!.close();
|
||||
_instance = null;
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有数据(仅用于测试)
|
||||
static Future<void> clearAll() async {
|
||||
if (_instance == null || !_instance!.isOpen) return;
|
||||
await _instance!.writeTxn(() async {
|
||||
await _instance!.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
export 'isar_database_web.dart' if (dart.library.io) 'isar_database_native.dart';
|
||||
|
||||
70
app/lib/data/local/isar_database_native.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
// Isar 数据库初始化 — 原生平台实现 (Android/iOS/Desktop)
|
||||
//
|
||||
// 在原生平台上使用 Isar 3.x 本地数据库。
|
||||
// Web 平台使用 isar_database_web.dart stub。
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'collections/journal_entry_collection.dart';
|
||||
import 'collections/journal_element_collection.dart';
|
||||
import 'collections/pending_operation_collection.dart';
|
||||
|
||||
/// Isar 数据库单例管理(原生平台实现)
|
||||
class IsarDatabase {
|
||||
IsarDatabase._();
|
||||
|
||||
static Isar? _instance;
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 所有 Collection Schema(由 build_runner 生成)
|
||||
static final List<CollectionSchema<dynamic>> schemas = [
|
||||
JournalEntryCollectionSchema,
|
||||
JournalElementCollectionSchema,
|
||||
PendingOperationCollectionSchema,
|
||||
];
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// 原生平台 Isar 可用
|
||||
static bool get isAvailable => true;
|
||||
|
||||
/// 初始化数据库
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_instance = await Isar.open(
|
||||
schemas,
|
||||
directory: dir.path,
|
||||
inspector: true, // 开发模式,发布时关闭
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// 获取 Isar 实例(必须先调用 [init])
|
||||
static Isar get instance {
|
||||
if (_instance == null || !_instance!.isOpen) {
|
||||
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 关闭数据库连接
|
||||
static Future<void> close() async {
|
||||
if (_instance != null && _instance!.isOpen) {
|
||||
await _instance!.close();
|
||||
_instance = null;
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有数据(仅用于测试)
|
||||
static Future<void> clearAll() async {
|
||||
if (_instance == null || !_instance!.isOpen) return;
|
||||
await _instance!.writeTxn(() async {
|
||||
await _instance!.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
31
app/lib/data/local/isar_database_web.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// Isar 数据库初始化 — Web 平台 stub
|
||||
//
|
||||
// Isar 3.x 不支持 Web,此文件提供空实现。
|
||||
// 原生平台使用 isar_database_native.dart。
|
||||
|
||||
/// Isar 数据库单例管理(Web 平台空实现)
|
||||
class IsarDatabase {
|
||||
IsarDatabase._();
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// Web 平台 Isar 不可用
|
||||
static bool get isAvailable => false;
|
||||
|
||||
/// Web 平台:跳过初始化
|
||||
static Future<void> init() async {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Web 平台:返回 null
|
||||
static Type? get instance => null;
|
||||
|
||||
/// Web 平台:无操作
|
||||
static Future<void> close() async {}
|
||||
|
||||
/// Web 平台:无操作
|
||||
static Future<void> clearAll() async {}
|
||||
}
|
||||
18
app/lib/data/local/secure_token_store.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
// 安全令牌存储接口 — 平台条件导出
|
||||
//
|
||||
// 原生平台使用 flutter_secure_storage(加密存储,PIPL 合规)
|
||||
// Web 平台使用 shared_preferences(浏览器本地存储)
|
||||
//
|
||||
// 统一接口:read / write / delete
|
||||
|
||||
/// 安全令牌存储接口
|
||||
abstract class SecureTokenStore {
|
||||
/// 读取值
|
||||
Future<String?> read(String key);
|
||||
|
||||
/// 写入值
|
||||
Future<void> write(String key, String value);
|
||||
|
||||
/// 删除值
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
17
app/lib/data/local/secure_token_store_factory.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
// 安全令牌存储 — 工厂函数
|
||||
//
|
||||
// 根据平台创建对应的 SecureTokenStore 实现。
|
||||
// 运行时判断 kIsWeb,避免 Web 编译时加载 flutter_secure_storage。
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
import 'secure_token_store_web.dart';
|
||||
|
||||
/// 创建平台对应的 SecureTokenStore 实例
|
||||
///
|
||||
/// Web 平台 → WebSecureTokenStore (shared_preferences)
|
||||
/// 原生平台 → WebSecureTokenStore (shared_preferences,临时方案)
|
||||
///
|
||||
/// TODO: flutter_secure_storage 升级到 v10+ 后恢复 NativeSecureTokenStore
|
||||
SecureTokenStore createSecureTokenStore() {
|
||||
return WebSecureTokenStore();
|
||||
}
|
||||
37
app/lib/data/local/secure_token_store_native.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// 安全令牌存储 — 原生平台实现(shared_preferences)
|
||||
//
|
||||
// 临时使用 shared_preferences 替代 flutter_secure_storage。
|
||||
// flutter_secure_storage v9 的 web 插件不兼容 Flutter 3.44,
|
||||
// 待其升级到 v10+ 后恢复加密存储。
|
||||
// TODO: 恢复 flutter_secure_storage 加密存储
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
|
||||
/// 原生平台安全令牌存储(临时使用 shared_preferences)
|
||||
class NativeSecureTokenStore implements SecureTokenStore {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<SharedPreferences> get _instance async {
|
||||
return _prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
final prefs = await _instance;
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.remove(key);
|
||||
}
|
||||
}
|
||||
36
app/lib/data/local/secure_token_store_web.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
// 安全令牌存储 — Web 平台实现(shared_preferences)
|
||||
//
|
||||
// Web 平台上 flutter_secure_storage 不可用(dart:html 已弃用),
|
||||
// 使用 shared_preferences 作为替代。
|
||||
// 注意:Web 端存储不加密,但浏览器本身提供 HTTPS 传输安全。
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
|
||||
/// Web 平台安全令牌存储(shared_preferences)
|
||||
class WebSecureTokenStore implements SecureTokenStore {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<SharedPreferences> get _instance async {
|
||||
return _prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
final prefs = await _instance;
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async {
|
||||
final prefs = await _instance;
|
||||
await prefs.remove(key);
|
||||
}
|
||||
}
|
||||
176
app/lib/data/models/sync_models.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
// 同步协议模型 — 与 Rust 端 SyncReq/SyncResp/SyncChange/ConflictInfo 一一对应
|
||||
//
|
||||
// 端点: POST /api/v1/diary/sync
|
||||
// Rust DTO: crates/erp-diary/src/dto.rs (SyncReq, SyncResp, SyncChange, ConflictInfo)
|
||||
|
||||
/// 同步请求 — 与 Rust SyncReq 对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub struct SyncReq {
|
||||
/// pub last_sync_time: Option<DateTime<Utc>>,
|
||||
/// pub changes: Vec<SyncChange>,
|
||||
/// }
|
||||
/// ```
|
||||
class SyncReq {
|
||||
final DateTime? lastSyncTime;
|
||||
final List<SyncChange> changes;
|
||||
|
||||
const SyncReq({this.lastSyncTime, this.changes = const []});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (lastSyncTime != null)
|
||||
'last_sync_time': lastSyncTime!.toUtc().toIso8601String(),
|
||||
'changes': changes.map((c) => c.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 同步变更条目 — 与 Rust SyncChange 枚举对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub enum SyncChange {
|
||||
/// CreateJournal { data: serde_json::Value },
|
||||
/// UpdateJournal { id: Uuid, version: i32, data: serde_json::Value },
|
||||
/// DeleteJournal { id: Uuid, version: i32 },
|
||||
/// }
|
||||
/// ```
|
||||
sealed class SyncChange {
|
||||
const SyncChange();
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// 从 JSON 反序列化
|
||||
factory SyncChange.fromJson(Map<String, dynamic> json) {
|
||||
if (json.containsKey('CreateJournal')) {
|
||||
return SyncChangeCreateJournal(
|
||||
data: json['CreateJournal']['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
if (json.containsKey('UpdateJournal')) {
|
||||
final inner = json['UpdateJournal'] as Map<String, dynamic>;
|
||||
return SyncChangeUpdateJournal(
|
||||
id: inner['id'] as String,
|
||||
version: inner['version'] as int,
|
||||
data: inner['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
if (json.containsKey('DeleteJournal')) {
|
||||
final inner = json['DeleteJournal'] as Map<String, dynamic>;
|
||||
return SyncChangeDeleteJournal(
|
||||
id: inner['id'] as String,
|
||||
version: inner['version'] as int,
|
||||
);
|
||||
}
|
||||
throw FormatException('Unknown SyncChange variant: $json');
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建日记变更
|
||||
class SyncChangeCreateJournal extends SyncChange {
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const SyncChangeCreateJournal({required this.data});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'CreateJournal': {'data': data},
|
||||
};
|
||||
}
|
||||
|
||||
/// 更新日记变更
|
||||
class SyncChangeUpdateJournal extends SyncChange {
|
||||
final String id;
|
||||
final int version;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const SyncChangeUpdateJournal({
|
||||
required this.id,
|
||||
required this.version,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'UpdateJournal': {
|
||||
'id': id,
|
||||
'version': version,
|
||||
'data': data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// 删除日记变更
|
||||
class SyncChangeDeleteJournal extends SyncChange {
|
||||
final String id;
|
||||
final int version;
|
||||
|
||||
const SyncChangeDeleteJournal({
|
||||
required this.id,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'DeleteJournal': {
|
||||
'id': id,
|
||||
'version': version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// 同步响应 — 与 Rust SyncResp 对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub struct SyncResp {
|
||||
/// pub server_changes: Vec<serde_json::Value>,
|
||||
/// pub conflicts: Vec<ConflictInfo>,
|
||||
/// pub sync_time: DateTime<Utc>,
|
||||
/// }
|
||||
/// ```
|
||||
class SyncResp {
|
||||
final List<Map<String, dynamic>> serverChanges;
|
||||
final List<ConflictInfo> conflicts;
|
||||
final DateTime syncTime;
|
||||
|
||||
const SyncResp({
|
||||
required this.serverChanges,
|
||||
required this.conflicts,
|
||||
required this.syncTime,
|
||||
});
|
||||
|
||||
factory SyncResp.fromJson(Map<String, dynamic> json) => SyncResp(
|
||||
serverChanges: (json['server_changes'] as List)
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList(),
|
||||
conflicts: (json['conflicts'] as List)
|
||||
.map((e) => ConflictInfo.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList(),
|
||||
syncTime: DateTime.parse(json['sync_time'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// 冲突信息 — 与 Rust ConflictInfo 对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub struct ConflictInfo {
|
||||
/// pub journal_id: Uuid,
|
||||
/// pub local_version: i32,
|
||||
/// pub server_version: i32,
|
||||
/// }
|
||||
/// ```
|
||||
class ConflictInfo {
|
||||
final String journalId;
|
||||
final int localVersion;
|
||||
final int serverVersion;
|
||||
|
||||
const ConflictInfo({
|
||||
required this.journalId,
|
||||
required this.localVersion,
|
||||
required this.serverVersion,
|
||||
});
|
||||
|
||||
factory ConflictInfo.fromJson(Map<String, dynamic> json) => ConflictInfo(
|
||||
journalId: json['journal_id'] as String,
|
||||
localVersion: json['local_version'] as int,
|
||||
serverVersion: json['server_version'] as int,
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
// API 客户端 — Dio 封装 + JWT 注入 + 离线感知
|
||||
// API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
|
||||
//
|
||||
// 核心职责:
|
||||
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
|
||||
// - JWT token 自动注入(请求拦截器)
|
||||
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01)
|
||||
// - 离线状态感知(网络不可用时抛出明确异常)
|
||||
// - 为 SyncEngine 提供远程操作能力
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import '../models/sync_models.dart';
|
||||
|
||||
/// 网络离线异常 — 网络不可用时由 ApiClient 抛出
|
||||
class OfflineException implements Exception {
|
||||
final String message;
|
||||
@@ -26,7 +29,27 @@ class ApiClient {
|
||||
/// 基础 URL,默认指向本地开发服务器
|
||||
final String baseUrl;
|
||||
|
||||
ApiClient({this.baseUrl = 'http://localhost:3000/api/v1'}) {
|
||||
/// Token 刷新回调 — 由 AuthRepository 在构造后注册
|
||||
///
|
||||
/// 返回新的 access token,失败返回 null。
|
||||
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
|
||||
Future<String?> Function()? onRefreshToken;
|
||||
|
||||
/// 认证彻底失败回调 — 刷新 token 失败后由 app.dart 注册
|
||||
///
|
||||
/// 通知 AuthBloc 派发 AuthExpired 事件,触发路由重定向到登录页。
|
||||
/// 解决审计 9a-AUTH-01:刷新失败时用户不会被留在死页面。
|
||||
void Function()? onAuthFailed;
|
||||
|
||||
/// 是否正在刷新 token(防止并发 401 触发多次刷新)
|
||||
bool _isRefreshing = false;
|
||||
|
||||
/// 创建 API 客户端
|
||||
///
|
||||
/// [baseUrl] 默认使用 HTTPS 生产地址。
|
||||
/// 开发环境可通过构造参数覆盖为 http://localhost:3000/api/v1
|
||||
/// (Android 网络安全配置已允许 localhost 明文)。
|
||||
ApiClient({this.baseUrl = 'https://api.nuanji.app/api/v1'}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
@@ -48,12 +71,39 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 响应拦截器:统一错误处理
|
||||
// 响应拦截器:401 自动刷新 token + 重试
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
// 401 时自动清除 token(需要重新登录)
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// 不对刷新端点本身重试(避免无限循环)
|
||||
final isRefreshRequest =
|
||||
error.requestOptions.path.endsWith('/auth/refresh');
|
||||
|
||||
if (!isRefreshRequest &&
|
||||
onRefreshToken != null &&
|
||||
!_isRefreshing) {
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final newToken = await onRefreshToken!();
|
||||
if (newToken != null) {
|
||||
_token = newToken;
|
||||
// 用新 token 重试原始请求
|
||||
error.requestOptions.headers['Authorization'] =
|
||||
'Bearer $newToken';
|
||||
_isRefreshing = false;
|
||||
return handler.resolve(
|
||||
await _dio.fetch(error.requestOptions),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 刷新失败,继续走 401 逻辑
|
||||
}
|
||||
_isRefreshing = false;
|
||||
}
|
||||
|
||||
// 刷新失败或无刷新回调 → 清除 token,通知全局认证失效
|
||||
_token = null;
|
||||
onAuthFailed?.call();
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
@@ -146,4 +196,19 @@ class ApiClient {
|
||||
});
|
||||
return _dio.post<T>(path, data: formData);
|
||||
}
|
||||
|
||||
// ===== 同步 API =====
|
||||
|
||||
/// 批量同步 — POST /diary/sync
|
||||
///
|
||||
/// 将客户端变更批量提交到服务端,返回服务端变更和冲突信息。
|
||||
/// 对应 Rust sync_handler::sync_journals 端点。
|
||||
Future<SyncResp> sync(SyncReq req) async {
|
||||
await _ensureOnline();
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/diary/sync',
|
||||
data: req.toJson(),
|
||||
);
|
||||
return SyncResp.fromJson(response.data!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
//
|
||||
// 职责:
|
||||
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
|
||||
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌(PIPL 合规)
|
||||
// - 通过 SecureTokenStore 安全持久化 JWT 令牌(PIPL 合规)
|
||||
// - 为 AuthBloc 提供干净的认证数据访问接口
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../local/secure_token_store.dart';
|
||||
import '../models/auth_token.dart';
|
||||
import '../models/user.dart';
|
||||
import '../remote/api_client.dart';
|
||||
@@ -33,11 +33,11 @@ class AuthException implements Exception {
|
||||
|
||||
/// 认证仓库 — 管理用户登录状态和令牌
|
||||
///
|
||||
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。
|
||||
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求。
|
||||
/// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
|
||||
/// 原生平台使用加密存储,Web 平台使用 shared_preferences。
|
||||
class AuthRepository {
|
||||
final ApiClient _apiClient;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final SecureTokenStore _tokenStore;
|
||||
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||
|
||||
AuthToken? _currentToken;
|
||||
@@ -45,13 +45,12 @@ class AuthRepository {
|
||||
|
||||
AuthRepository({
|
||||
required ApiClient apiClient,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
required SecureTokenStore tokenStore,
|
||||
}) : _apiClient = apiClient,
|
||||
_secureStorage = secureStorage ??
|
||||
const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
_tokenStore = tokenStore {
|
||||
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
|
||||
_apiClient.onRefreshToken = _handleAutoRefresh;
|
||||
}
|
||||
|
||||
/// 当前用户(可能为 null)
|
||||
User? get currentUser => _currentUser;
|
||||
@@ -167,10 +166,10 @@ class AuthRepository {
|
||||
_logger.d('恢复认证状态');
|
||||
|
||||
try {
|
||||
final accessToken = await _secureStorage.read(key: _keyAccessToken);
|
||||
final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken);
|
||||
final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt);
|
||||
final userJsonStr = await _secureStorage.read(key: _keyUserJson);
|
||||
final accessToken = await _tokenStore.read(_keyAccessToken);
|
||||
final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
|
||||
final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
|
||||
final userJsonStr = await _tokenStore.read(_keyUserJson);
|
||||
|
||||
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
|
||||
_logger.d('无存储的认证信息');
|
||||
@@ -219,6 +218,33 @@ class AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Token 自动刷新 =====
|
||||
|
||||
/// ApiClient 401 拦截器调用的自动刷新处理
|
||||
///
|
||||
/// 使用 refresh_token 获取新 access_token,更新 ApiClient 的 token,
|
||||
/// 返回新 access_token(失败返回 null)。
|
||||
Future<String?> _handleAutoRefresh() async {
|
||||
if (_currentToken == null) return null;
|
||||
|
||||
_logger.d('自动刷新令牌(401 触发)');
|
||||
try {
|
||||
final response = await _apiClient.post('/auth/refresh', data: {
|
||||
'refresh_token': _currentToken!.refreshToken,
|
||||
});
|
||||
|
||||
final data = _extractData(response.data);
|
||||
final token = AuthToken.fromJson(data);
|
||||
|
||||
await _saveToken(token);
|
||||
_logger.i('自动刷新令牌成功');
|
||||
return token.accessToken;
|
||||
} catch (e) {
|
||||
_logger.w('自动刷新令牌失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/// 从 API 响应中提取 data 字段
|
||||
@@ -238,27 +264,27 @@ class AuthRepository {
|
||||
_currentToken = token;
|
||||
_currentUser = user;
|
||||
await _saveToken(token);
|
||||
await _secureStorage.write(
|
||||
key: _keyUserJson,
|
||||
value: jsonEncode(user.toJson()),
|
||||
await _tokenStore.write(
|
||||
_keyUserJson,
|
||||
jsonEncode(user.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 仅保存令牌到安全存储
|
||||
Future<void> _saveToken(AuthToken token) async {
|
||||
_currentToken = token;
|
||||
await _secureStorage.write(key: _keyAccessToken, value: token.accessToken);
|
||||
await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken);
|
||||
await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String());
|
||||
await _tokenStore.write(_keyAccessToken, token.accessToken);
|
||||
await _tokenStore.write(_keyRefreshToken, token.refreshToken);
|
||||
await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
|
||||
}
|
||||
|
||||
/// 清除所有认证数据
|
||||
Future<void> _clearAuth() async {
|
||||
_currentToken = null;
|
||||
_currentUser = null;
|
||||
await _secureStorage.delete(key: _keyAccessToken);
|
||||
await _secureStorage.delete(key: _keyRefreshToken);
|
||||
await _secureStorage.delete(key: _keyExpiresAt);
|
||||
await _secureStorage.delete(key: _keyUserJson);
|
||||
await _tokenStore.delete(_keyAccessToken);
|
||||
await _tokenStore.delete(_keyRefreshToken);
|
||||
await _tokenStore.delete(_keyExpiresAt);
|
||||
await _tokenStore.delete(_keyUserJson);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,362 +1,7 @@
|
||||
// Isar 本地日记仓库 — 本地优先数据存储
|
||||
// Isar 本地日记仓库 — 条件导出
|
||||
//
|
||||
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||||
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||||
//
|
||||
// 转换层:
|
||||
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||
// 根据平台选择实现:
|
||||
// - 原生平台 → isar_journal_repository_native.dart(Isar 本地数据库)
|
||||
// - Web 平台 → isar_journal_repository_web.dart(空 stub,应使用 RemoteJournalRepository)
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/collections/journal_entry_collection.dart';
|
||||
import '../local/collections/journal_element_collection.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
Isar get _isar => IsarDatabase.instance!;
|
||||
|
||||
// ============================================================
|
||||
// 日记 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
String? classId,
|
||||
}) async {
|
||||
var query = _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false);
|
||||
|
||||
// 日期范围过滤
|
||||
if (dateFrom != null) {
|
||||
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
|
||||
}
|
||||
if (dateTo != null) {
|
||||
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
// 心情过滤
|
||||
if (mood != null) {
|
||||
query = query.and().moodEqualTo(mood);
|
||||
}
|
||||
|
||||
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||||
if (tag != null) {
|
||||
query = query.and().tagsJsonContains(tag);
|
||||
}
|
||||
|
||||
// 班级过滤
|
||||
if (classId != null) {
|
||||
query = query.and().classIdEqualTo(classId);
|
||||
}
|
||||
|
||||
// 按日期降序排列
|
||||
var results = await query
|
||||
.sortByDateEpochDesc()
|
||||
.findAll();
|
||||
|
||||
// 分页
|
||||
if (page != null && pageSize != null) {
|
||||
final start = (page - 1) * pageSize;
|
||||
if (start >= results.length) return [];
|
||||
final end = (start + pageSize).clamp(0, results.length);
|
||||
results = results.sublist(start, end);
|
||||
}
|
||||
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getJournalCount() async {
|
||||
return _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false)
|
||||
.count();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry?> getJournal(String id) async {
|
||||
final col = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
if (col == null) return null;
|
||||
return _fromCollection(col);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
final col = _toEntryCollection(entry);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(entry.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('日记不存在: ${entry.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != entry.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = entry.copyWith(
|
||||
version: entry.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toEntryCollection(updated);
|
||||
col.isarId = existing.isarId; // 保留 Isar 主键
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除日记
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// 软删除关联元素
|
||||
final elements = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findAll();
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(existing);
|
||||
for (final el in elements) {
|
||||
el.isDeleted = true;
|
||||
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
await _isar.journalElementCollections.put(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元素 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) async {
|
||||
final results = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(journalId)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.sortByZIndex()
|
||||
.findAll();
|
||||
return results.map(_fromElementCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> addElement(JournalElement element) async {
|
||||
final col = _toElementCollection(element);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> updateElement(JournalElement element) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(element.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('元素不存在: ${element.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != element.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = element.copyWith(
|
||||
version: element.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toElementCollection(updated);
|
||||
col.isarId = existing.isarId;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(elementId)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(existing);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalEntry ↔ JournalEntryCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalEntry → JournalEntryCollection
|
||||
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
|
||||
return JournalEntryCollection()
|
||||
..id = entry.id
|
||||
..authorId = entry.authorId
|
||||
..classId = entry.classId
|
||||
..title = entry.title
|
||||
..dateEpoch = entry.date.millisecondsSinceEpoch
|
||||
..mood = entry.mood.value
|
||||
..weather = entry.weather.value
|
||||
..tagsJson = jsonEncode(entry.tags)
|
||||
..isPrivate = entry.isPrivate
|
||||
..sharedToClass = entry.sharedToClass
|
||||
..assignedTopicId = entry.assignedTopicId
|
||||
..contentExcerpt = entry.contentExcerpt
|
||||
..version = entry.version
|
||||
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalEntryCollection → JournalEntry
|
||||
JournalEntry _fromCollection(JournalEntryCollection col) {
|
||||
return JournalEntry(
|
||||
id: col.id,
|
||||
authorId: col.authorId,
|
||||
classId: col.classId,
|
||||
title: col.title,
|
||||
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
|
||||
mood: Mood.values.firstWhere(
|
||||
(m) => m.value == col.mood,
|
||||
orElse: () => Mood.calm,
|
||||
),
|
||||
weather: Weather.values.firstWhere(
|
||||
(w) => w.value == col.weather,
|
||||
orElse: () => Weather.sunny,
|
||||
),
|
||||
tags: List<String>.from(
|
||||
jsonDecode(col.tagsJson) as List? ?? [],
|
||||
),
|
||||
isPrivate: col.isPrivate,
|
||||
sharedToClass: col.sharedToClass,
|
||||
assignedTopicId: col.assignedTopicId,
|
||||
contentExcerpt: col.contentExcerpt,
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalElement ↔ JournalElementCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalElement → JournalElementCollection
|
||||
JournalElementCollection _toElementCollection(JournalElement element) {
|
||||
return JournalElementCollection()
|
||||
..id = element.id
|
||||
..journalId = element.journalId
|
||||
..elementType = element.elementType.value
|
||||
..positionX = element.positionX
|
||||
..positionY = element.positionY
|
||||
..width = element.width
|
||||
..height = element.height
|
||||
..rotation = element.rotation
|
||||
..zIndex = element.zIndex
|
||||
..contentJson = jsonEncode(element.content)
|
||||
..version = element.version
|
||||
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalElementCollection → JournalElement
|
||||
JournalElement _fromElementCollection(JournalElementCollection col) {
|
||||
return JournalElement(
|
||||
id: col.id,
|
||||
journalId: col.journalId,
|
||||
elementType: ElementType.values.firstWhere(
|
||||
(e) => e.value == col.elementType,
|
||||
orElse: () => ElementType.text,
|
||||
),
|
||||
positionX: col.positionX,
|
||||
positionY: col.positionY,
|
||||
width: col.width,
|
||||
height: col.height,
|
||||
rotation: col.rotation,
|
||||
zIndex: col.zIndex,
|
||||
content: Map<String, dynamic>.from(
|
||||
jsonDecode(col.contentJson) as Map? ?? {},
|
||||
),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
}
|
||||
export 'isar_journal_repository_web.dart' if (dart.library.io) 'isar_journal_repository_native.dart';
|
||||
|
||||
370
app/lib/data/repositories/isar_journal_repository_native.dart
Normal file
@@ -0,0 +1,370 @@
|
||||
// Isar 本地日记仓库 — 本地优先数据存储
|
||||
//
|
||||
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||||
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||||
//
|
||||
// 转换层:
|
||||
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database_native.dart';
|
||||
import '../local/collections/journal_entry_collection.dart';
|
||||
import '../local/collections/journal_element_collection.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
Isar get _isar => IsarDatabase.instance;
|
||||
|
||||
final StreamController<void> _changeController = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _changeController.stream;
|
||||
|
||||
// ============================================================
|
||||
// 日记 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
String? classId,
|
||||
}) async {
|
||||
var query = _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false);
|
||||
|
||||
// 日期范围过滤
|
||||
if (dateFrom != null) {
|
||||
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
|
||||
}
|
||||
if (dateTo != null) {
|
||||
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
// 心情过滤
|
||||
if (mood != null) {
|
||||
query = query.and().moodEqualTo(mood);
|
||||
}
|
||||
|
||||
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||||
if (tag != null) {
|
||||
query = query.and().tagsJsonContains(tag);
|
||||
}
|
||||
|
||||
// 班级过滤
|
||||
if (classId != null) {
|
||||
query = query.and().classIdEqualTo(classId);
|
||||
}
|
||||
|
||||
// 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist)
|
||||
if (page != null && pageSize != null) {
|
||||
final offset = (page - 1) * pageSize;
|
||||
final results = await query
|
||||
.sortByDateEpochDesc()
|
||||
.offset(offset)
|
||||
.limit(pageSize)
|
||||
.findAll();
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
final results = await query.sortByDateEpochDesc().findAll();
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getJournalCount() async {
|
||||
return _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.isDeletedEqualTo(false)
|
||||
.count();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry?> getJournal(String id) async {
|
||||
final col = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
if (col == null) return null;
|
||||
return _fromCollection(col);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
final col = _toEntryCollection(entry);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
_changeController.add(null);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(entry.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('日记不存在: ${entry.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != entry.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = entry.copyWith(
|
||||
version: entry.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toEntryCollection(updated);
|
||||
col.isarId = existing.isarId; // 保留 Isar 主键
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
|
||||
_changeController.add(null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) async {
|
||||
final existing = await _isar.journalEntryCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(id)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除日记
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// 软删除关联元素
|
||||
final elements = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findAll();
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(existing);
|
||||
for (final el in elements) {
|
||||
el.isDeleted = true;
|
||||
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
await _isar.journalElementCollections.put(el);
|
||||
}
|
||||
});
|
||||
_changeController.add(null);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元素 CRUD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) async {
|
||||
final results = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(journalId)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.sortByZIndex()
|
||||
.findAll();
|
||||
return results.map(_fromElementCollection).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> addElement(JournalElement element) async {
|
||||
final col = _toElementCollection(element);
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<JournalElement> updateElement(JournalElement element) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(element.id)
|
||||
.and()
|
||||
.isDeletedEqualTo(false)
|
||||
.findFirst();
|
||||
|
||||
if (existing == null) {
|
||||
throw StateError('元素不存在: ${element.id}');
|
||||
}
|
||||
|
||||
// 乐观锁冲突检测
|
||||
if (existing.version != element.version) {
|
||||
throw StateError(
|
||||
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = element.copyWith(
|
||||
version: element.version + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final col = _toElementCollection(updated);
|
||||
col.isarId = existing.isarId;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(col);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) async {
|
||||
final existing = await _isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.idEqualTo(elementId)
|
||||
.findFirst();
|
||||
if (existing == null) return;
|
||||
|
||||
// 软删除
|
||||
existing.isDeleted = true;
|
||||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalElementCollections.put(existing);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalEntry ↔ JournalEntryCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalEntry → JournalEntryCollection
|
||||
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
|
||||
return JournalEntryCollection()
|
||||
..id = entry.id
|
||||
..authorId = entry.authorId
|
||||
..classId = entry.classId
|
||||
..title = entry.title
|
||||
..dateEpoch = entry.date.millisecondsSinceEpoch
|
||||
..mood = entry.mood.value
|
||||
..weather = entry.weather.value
|
||||
..tagsJson = jsonEncode(entry.tags)
|
||||
..isPrivate = entry.isPrivate
|
||||
..sharedToClass = entry.sharedToClass
|
||||
..assignedTopicId = entry.assignedTopicId
|
||||
..contentExcerpt = entry.contentExcerpt
|
||||
..version = entry.version
|
||||
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalEntryCollection → JournalEntry
|
||||
JournalEntry _fromCollection(JournalEntryCollection col) {
|
||||
return JournalEntry(
|
||||
id: col.id,
|
||||
authorId: col.authorId,
|
||||
classId: col.classId,
|
||||
title: col.title,
|
||||
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
|
||||
mood: Mood.values.firstWhere(
|
||||
(m) => m.value == col.mood,
|
||||
orElse: () => Mood.calm,
|
||||
),
|
||||
weather: Weather.values.firstWhere(
|
||||
(w) => w.value == col.weather,
|
||||
orElse: () => Weather.sunny,
|
||||
),
|
||||
tags: List<String>.from(
|
||||
jsonDecode(col.tagsJson) as List? ?? [],
|
||||
),
|
||||
isPrivate: col.isPrivate,
|
||||
sharedToClass: col.sharedToClass,
|
||||
assignedTopicId: col.assignedTopicId,
|
||||
contentExcerpt: col.contentExcerpt,
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数:JournalElement ↔ JournalElementCollection
|
||||
// ============================================================
|
||||
|
||||
/// JournalElement → JournalElementCollection
|
||||
JournalElementCollection _toElementCollection(JournalElement element) {
|
||||
return JournalElementCollection()
|
||||
..id = element.id
|
||||
..journalId = element.journalId
|
||||
..elementType = element.elementType.value
|
||||
..positionX = element.positionX
|
||||
..positionY = element.positionY
|
||||
..width = element.width
|
||||
..height = element.height
|
||||
..rotation = element.rotation
|
||||
..zIndex = element.zIndex
|
||||
..contentJson = jsonEncode(element.content)
|
||||
..version = element.version
|
||||
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
|
||||
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
|
||||
..isDeleted = false;
|
||||
}
|
||||
|
||||
/// JournalElementCollection → JournalElement
|
||||
JournalElement _fromElementCollection(JournalElementCollection col) {
|
||||
return JournalElement(
|
||||
id: col.id,
|
||||
journalId: col.journalId,
|
||||
elementType: ElementType.values.firstWhere(
|
||||
(e) => e.value == col.elementType,
|
||||
orElse: () => ElementType.text,
|
||||
),
|
||||
positionX: col.positionX,
|
||||
positionY: col.positionY,
|
||||
width: col.width,
|
||||
height: col.height,
|
||||
rotation: col.rotation,
|
||||
zIndex: col.zIndex,
|
||||
content: Map<String, dynamic>.from(
|
||||
jsonDecode(col.contentJson) as Map? ?? {},
|
||||
),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/lib/data/repositories/isar_journal_repository_web.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
// Isar 本地日记仓库 — Web 平台 stub(不可用)
|
||||
//
|
||||
// Isar 3.x 不支持 Web,此文件提供空实现。
|
||||
// Web 平台应使用 RemoteJournalRepository。
|
||||
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// 空的变更通知流 — Web 平台 stub
|
||||
const _emptyStream = Stream<void>.empty();
|
||||
|
||||
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError)
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
String? classId,
|
||||
}) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<int> getJournalCount() =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalEntry?> getJournal(String id) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalEntry> updateJournal(JournalEntry entry) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalElement> addElement(JournalElement element) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<JournalElement> updateElement(JournalElement element) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _emptyStream;
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
// - SyncEngine 负责协调本地和远程仓库之间的数据同步
|
||||
// - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
|
||||
@@ -52,6 +54,9 @@ abstract class JournalRepository {
|
||||
|
||||
/// 从日记中移除元素
|
||||
Future<void> removeElement(String elementId);
|
||||
|
||||
/// 日记变更通知流 — create/update/delete 时发出信号
|
||||
Stream<void> get onJournalChanged;
|
||||
}
|
||||
|
||||
/// 内存实现 — 用于开发阶段快速迭代和单元测试
|
||||
@@ -61,6 +66,10 @@ abstract class JournalRepository {
|
||||
class InMemoryJournalRepository implements JournalRepository {
|
||||
final Map<String, JournalEntry> _journals = {};
|
||||
final Map<String, JournalElement> _elements = {};
|
||||
final StreamController<void> _changeController = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _changeController.stream;
|
||||
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
@@ -122,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
_journals[entry.id] = entry;
|
||||
_changeController.add(null);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -145,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
_journals[entry.id] = updated;
|
||||
_changeController.add(null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -154,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
_journals.remove(id);
|
||||
// 同时移除关联元素
|
||||
_elements.removeWhere((_, e) => e.journalId == id);
|
||||
_changeController.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// 远程日记仓库 — 通过 API 客户端连接后端
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../models/journal_element.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
import '../remote/api_client.dart';
|
||||
@@ -11,6 +13,10 @@ import 'journal_repository.dart';
|
||||
class RemoteJournalRepository implements JournalRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
|
||||
final StreamController<void> _changeController =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
RemoteJournalRepository({required ApiClient api}) : _api = api;
|
||||
|
||||
@override
|
||||
@@ -39,7 +45,9 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
|
||||
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final items = body['data'] as List? ?? [];
|
||||
// 后端信封格式: { success, data: { data: [...], total, page, ... }, message }
|
||||
final envelope = body['data'] as Map<String, dynamic>? ?? {};
|
||||
final items = envelope['data'] as List? ?? [];
|
||||
return items
|
||||
.map((json) => JournalEntry.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -52,7 +60,9 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
'page_size': 1,
|
||||
});
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
return (body['total'] as int?) ?? 0;
|
||||
// 后端信封格式: { success, data: { data: [...], total, ... }, message }
|
||||
final envelope = body['data'] as Map<String, dynamic>? ?? {};
|
||||
return (envelope['total'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,9 +79,14 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
final response = await _api.post('/diary/journals', data: entry.toJson());
|
||||
// 后端 CreateJournalReq.date 是 NaiveDate(只有日期),需转换格式
|
||||
final json = entry.toJson();
|
||||
json['date'] = entry.date.toIso8601String().substring(0, 10);
|
||||
final response = await _api.post('/diary/journals', data: json);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||
final created = JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||
_changeController.add(null); // 通知 UI 刷新列表
|
||||
return created;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
},
|
||||
);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
_changeController.add(null); // 通知 UI 刷新列表
|
||||
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) async {
|
||||
await _api.delete('/diary/journals/$id');
|
||||
_changeController.add(null); // 通知 UI 刷新列表
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -131,6 +148,10 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
Future<void> removeElement(String elementId) async {
|
||||
await _api.delete('/diary/elements/$elementId');
|
||||
}
|
||||
|
||||
/// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _changeController.stream;
|
||||
}
|
||||
|
||||
/// API 异常封装 — 后端返回非 2xx 状态码时抛出
|
||||
|
||||
184
app/lib/data/services/content_filter_service.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
// 内容安全过滤服务 — 本地敏感词检测
|
||||
//
|
||||
// 提供 checkText() 纯函数,用于在分享日记前检查文本内容是否包含敏感词。
|
||||
// 检测策略:精确匹配 + 谐音/形近/数字变体匹配。
|
||||
// 返回匹配列表,空列表表示内容安全。不自动屏蔽,由 UI 层决定提示方式。
|
||||
|
||||
import 'sensitive_words.dart';
|
||||
|
||||
/// 敏感词匹配结果
|
||||
class SensitiveWordMatch {
|
||||
/// 匹配到的敏感词原文
|
||||
final String word;
|
||||
|
||||
/// 所属分类
|
||||
final SensitiveCategory category;
|
||||
|
||||
/// 在预处理后文本中的起始位置
|
||||
final int position;
|
||||
|
||||
const SensitiveWordMatch({
|
||||
required this.word,
|
||||
required this.category,
|
||||
required this.position,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'SensitiveWordMatch("$word", ${category.label}, @$position)';
|
||||
}
|
||||
|
||||
/// 内容安全过滤服务
|
||||
///
|
||||
/// 纯静态方法,无状态,可安全在任何地方调用。
|
||||
/// 性能:~200 条词 × contains() 检查,<1ms 完成。
|
||||
class ContentFilterService {
|
||||
ContentFilterService._();
|
||||
|
||||
/// 检查文本内容,返回所有匹配到的敏感词。
|
||||
///
|
||||
/// 对输入文本进行预处理(去空格/特殊符号/零宽字符/小写化),
|
||||
/// 然后遍历全量词库做精确匹配和谐音变体匹配。
|
||||
/// 返回空列表表示内容安全。
|
||||
static List<SensitiveWordMatch> checkText(String text) {
|
||||
if (text.isEmpty) return const [];
|
||||
|
||||
final normalized = _normalize(text);
|
||||
if (normalized.isEmpty) return const [];
|
||||
|
||||
final matches = <SensitiveWordMatch>[];
|
||||
final seen = <String>{}; // 去重:同一词不重复报告
|
||||
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
final category = entry.key;
|
||||
|
||||
for (final word in entry.value) {
|
||||
// 精确匹配
|
||||
final pos = normalized.indexOf(word);
|
||||
if (pos >= 0 && seen.add(word)) {
|
||||
matches.add(SensitiveWordMatch(
|
||||
word: word,
|
||||
category: category,
|
||||
position: pos,
|
||||
));
|
||||
}
|
||||
|
||||
// 谐音/变体匹配 — 将词中每个有变体映射的字替换为变体,检查是否命中
|
||||
if (_matchesWithVariants(normalized, word)) {
|
||||
if (seen.add('variant:$word')) {
|
||||
matches.add(SensitiveWordMatch(
|
||||
word: word,
|
||||
category: category,
|
||||
position: -1, // 变体匹配无法精确定位
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 整词变体匹配 — kHomophoneVariants 中的多字 key(如 "卧槽")
|
||||
for (final variantEntry in kHomophoneVariants.entries) {
|
||||
final originalKey = variantEntry.key;
|
||||
if (originalKey.length <= 1) continue; // 单字已在上面处理
|
||||
|
||||
// 找到这个变体 key 对应的分类
|
||||
SensitiveCategory? foundCategory;
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
if (entry.value.contains(originalKey)) {
|
||||
foundCategory = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundCategory == null) continue;
|
||||
|
||||
for (final variant in variantEntry.value) {
|
||||
if (variant.isEmpty) continue;
|
||||
final vPos = normalized.indexOf(variant.toLowerCase());
|
||||
if (vPos >= 0) {
|
||||
final key = 'wvariant:$originalKey:$variant';
|
||||
if (seen.add(key)) {
|
||||
matches.add(SensitiveWordMatch(
|
||||
word: originalKey,
|
||||
category: foundCategory,
|
||||
position: vPos,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// 检查文本是否包含敏感词(快捷方法)
|
||||
static bool hasSensitiveContent(String text) => checkText(text).isNotEmpty;
|
||||
|
||||
/// 获取匹配到的分类标签集合(用于 UI 展示)
|
||||
static Set<String> getMatchedCategories(List<SensitiveWordMatch> matches) {
|
||||
return matches.map((m) => m.category.label).toSet();
|
||||
}
|
||||
|
||||
/// 变体匹配:检查文本中是否出现了词的谐音/形近/数字变体版本
|
||||
///
|
||||
/// 将敏感词中每个有变体映射的字符逐一替换为变体,检查替换后的
|
||||
/// 字符串是否出现在文本中。例如 "去死" → 检查 "去4" 是否在文本中。
|
||||
static bool _matchesWithVariants(String normalizedText, String word) {
|
||||
final chars = word.split('');
|
||||
final variantChars = <List<String>>[];
|
||||
|
||||
for (final char in chars) {
|
||||
final variants = kHomophoneVariants[char];
|
||||
if (variants != null && variants.isNotEmpty) {
|
||||
// 原字符 + 所有变体
|
||||
variantChars.add([char, ...variants]);
|
||||
} else {
|
||||
variantChars.add([char]);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成所有变体组合并检查
|
||||
return _checkCombinations(normalizedText, variantChars, 0, '');
|
||||
}
|
||||
|
||||
/// 递归生成变体组合并检查文本
|
||||
static bool _checkCombinations(
|
||||
String text,
|
||||
List<List<String>> variantChars,
|
||||
int index,
|
||||
String current,
|
||||
) {
|
||||
if (index == variantChars.length) {
|
||||
return text.contains(current);
|
||||
}
|
||||
for (final char in variantChars[index]) {
|
||||
if (_checkCombinations(text, variantChars, index + 1, current + char)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 文本预处理:去除干扰字符,统一为小写
|
||||
///
|
||||
/// 1. 去除零宽字符(U+200B~U+200F, U+FEFF)
|
||||
/// 2. 去除空格、制表符、换行
|
||||
/// 3. 去除常见特殊符号(用于绕过的 @#$%^&* 等)
|
||||
/// 4. 转小写(对英文词有效)
|
||||
static String _normalize(String text) {
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in text.runes) {
|
||||
// 跳过零宽字符
|
||||
if (rune >= 0x200B && rune <= 0x200F) continue;
|
||||
if (rune == 0xFEFF) continue;
|
||||
// 跳过空白
|
||||
if (rune == 0x20 || rune == 0x09 || rune == 0x0A || rune == 0x0D) continue;
|
||||
// 跳过常见绕过符号
|
||||
if (rune == 0x2E || rune == 0x2C || rune == 0x2D || rune == 0x5F) continue; // . , - _
|
||||
if (rune == 0x21 || rune == 0x40 || rune == 0x23 || rune == 0x24) continue; // ! @ # $
|
||||
if (rune == 0x25 || rune == 0x5E || rune == 0x26 || rune == 0x2A) continue; // % ^ & *
|
||||
if (rune == 0x7E || rune == 0x60) continue; // ~ `
|
||||
|
||||
buffer.writeCharCode(rune);
|
||||
}
|
||||
return buffer.toString().toLowerCase();
|
||||
}
|
||||
}
|
||||
141
app/lib/data/services/sensitive_words.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
// 敏感词库 — 本地静态词库常量,面向小学生场景
|
||||
//
|
||||
// 分类:暴力、色情、欺凌、毒品、赌博、政治、诈骗、粗口
|
||||
// 每个分类包含基础词 + 谐音/形近/数字变体
|
||||
// 词库为 const 编译期常量,零运行时开销
|
||||
//
|
||||
// 注意:本词库仅为 Phase 1 基础覆盖,Phase 2 将接入服务端 AI + 可更新词库。
|
||||
|
||||
/// 敏感词分类
|
||||
enum SensitiveCategory {
|
||||
violence('暴力'),
|
||||
sexual('色情'),
|
||||
bullying('欺凌'),
|
||||
drugs('毒品'),
|
||||
gambling('赌博'),
|
||||
politics('政治敏感'),
|
||||
fraud('诈骗'),
|
||||
profanity('粗口');
|
||||
|
||||
const SensitiveCategory(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 各分类敏感词
|
||||
/// ============================================================
|
||||
|
||||
/// 暴力类
|
||||
const _violenceWords = [
|
||||
// 直接暴力
|
||||
'杀人', '砍人', '捅人', '打死', '打死你', '弄死', '弄死你',
|
||||
'揍你', '揍死', '打死他', '砍死', '捅死',
|
||||
'杀了他', '打死他', '砍了他', '捅了他',
|
||||
'去死', '你去死', '怎么不去死',
|
||||
'割腕', '割脖子', '跳楼', '上吊',
|
||||
// 武器
|
||||
'炸弹', '手枪', '步枪', '子弹', '刀杀',
|
||||
// 自残/伤害暗示
|
||||
'自杀', '自残', '不想活',
|
||||
];
|
||||
|
||||
/// 色情类
|
||||
const _sexualWords = [
|
||||
'色情', '裸体', '裸照', '黄色', '黄片',
|
||||
'做爱', '性行为', '性交', '强奸', '强暴',
|
||||
'猥亵', '性骚扰', '偷拍',
|
||||
'发情', '骚货', '贱人',
|
||||
];
|
||||
|
||||
/// 欺凌类
|
||||
const _bullyingWords = [
|
||||
'废物', '垃圾', '蠢货', '白痴', '弱智',
|
||||
'傻子', '笨蛋', '猪头', '丑八怪',
|
||||
'滚开', '滚蛋', '闭嘴', '别烦我',
|
||||
'讨厌鬼', '没人要', '没朋友',
|
||||
'不和你玩', '不要和你玩',
|
||||
'大家不要理', '孤立',
|
||||
'偷东西', '小偷',
|
||||
];
|
||||
|
||||
/// 毒品类
|
||||
const _drugsWords = [
|
||||
'毒品', '吸毒', '贩毒', '大麻', '海洛因',
|
||||
'冰毒', '摇头丸', '可卡因', '吗啡',
|
||||
'鸦片', 'K粉', '安非他命',
|
||||
'上瘾', '毒瘾',
|
||||
];
|
||||
|
||||
/// 赌博类
|
||||
const _gamblingWords = [
|
||||
'赌博', '赌钱', '下注', '押注', '赌场',
|
||||
'买彩票', '时时彩', '六合彩',
|
||||
'百家乐', '老虎机', '扑克赌',
|
||||
'赌债', '借钱赌',
|
||||
];
|
||||
|
||||
/// 政治敏感类
|
||||
const _politicsWords = [
|
||||
'反动', '颠覆', '分裂', '暴动', '造反',
|
||||
'推翻', '政变', '游行示威',
|
||||
];
|
||||
|
||||
/// 诈骗类
|
||||
const _fraudWords = [
|
||||
'诈骗', '骗钱', '骗密码', '骗账号',
|
||||
'中奖了', '恭喜中奖', '免费领取',
|
||||
'点击链接领奖', '转账给我',
|
||||
'刷单', '兼职刷单', '高薪兼职',
|
||||
'传销', '拉人头',
|
||||
];
|
||||
|
||||
/// 粗口类
|
||||
const _profanityWords = [
|
||||
'操你', '妈的', '他妈', '去你的', '狗屎',
|
||||
'滚', '屁', '放屁', '扯淡', '王八蛋',
|
||||
'混蛋', '靠', '我去', '卧槽',
|
||||
'我靠', '我擦',
|
||||
];
|
||||
|
||||
/// 全量词库:分类 → 词列表
|
||||
const Map<SensitiveCategory, List<String>> kSensitiveWords = {
|
||||
SensitiveCategory.violence: _violenceWords,
|
||||
SensitiveCategory.sexual: _sexualWords,
|
||||
SensitiveCategory.bullying: _bullyingWords,
|
||||
SensitiveCategory.drugs: _drugsWords,
|
||||
SensitiveCategory.gambling: _gamblingWords,
|
||||
SensitiveCategory.politics: _politicsWords,
|
||||
SensitiveCategory.fraud: _fraudWords,
|
||||
SensitiveCategory.profanity: _profanityWords,
|
||||
};
|
||||
|
||||
/// ============================================================
|
||||
/// 谐音/形近/数字变体映射
|
||||
/// ============================================================
|
||||
|
||||
/// 原词 → 变体列表
|
||||
///
|
||||
/// 变体检测在预处理后的文本上运行,可以捕获常见的绕过手法:
|
||||
/// - 数字谐音: "死" → "4"
|
||||
/// - 形近替换: "傻" → "纱"
|
||||
/// - 拼音缩写: "牛逼" → "nb"
|
||||
const Map<String, List<String>> kHomophoneVariants = {
|
||||
// 暴力相关
|
||||
'死': ['4', '④', '亖', '☠'],
|
||||
'杀': ['莎', '纱', '沙'],
|
||||
'砍': ['砍人'],
|
||||
'捅': ['捅人'],
|
||||
// 欺凌相关
|
||||
'傻': ['纱', '沙', '啥'],
|
||||
'笨': [], // 无实际变体
|
||||
'蠢': ['春'],
|
||||
'废物': ['费物', '废无'],
|
||||
'垃圾': ['拉吉', '垃 圾'],
|
||||
// 粗口相关
|
||||
'操': ['草', '艹', '槽'],
|
||||
'卧槽': ['我槽', '我草', 'wc', 'WC', 'Wc'],
|
||||
'我靠': ['我 k', '我K'],
|
||||
// 欺凌
|
||||
'滚': ['衮'],
|
||||
'屁': ['辟'],
|
||||
};
|
||||
@@ -1,364 +1,7 @@
|
||||
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
|
||||
// 同步引擎 — 条件导出
|
||||
//
|
||||
// 设计思路:
|
||||
// - 所有本地修改先入队 [PendingOperation]
|
||||
// - 网络恢复时自动批量同步
|
||||
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
||||
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
||||
// - 队列持久化到 Isar,应用退出后不丢失
|
||||
//
|
||||
// Phase 1 策略:本地优先
|
||||
// - 离线时正常使用,操作入队等待
|
||||
// - 联网后自动推送待同步操作
|
||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||
// 根据平台选择实现:
|
||||
// - 原生平台 → sync_engine_native.dart(Isar 持久化队列)
|
||||
// - Web 平台 → sync_engine_web.dart(纯内存队列)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/collections/pending_operation_collection.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 同步操作类型
|
||||
enum SyncOperationType {
|
||||
create('POST'),
|
||||
update('PUT'),
|
||||
delete('DELETE');
|
||||
|
||||
const SyncOperationType(this.httpMethod);
|
||||
final String httpMethod;
|
||||
}
|
||||
|
||||
/// 同步状态
|
||||
enum SyncStatus {
|
||||
idle, // 空闲,无待同步操作
|
||||
syncing, // 正在同步
|
||||
paused, // 暂停(网络不可用)
|
||||
error, // 出错,需要重试
|
||||
}
|
||||
|
||||
/// 待同步操作 — 记录一次本地修改
|
||||
class PendingOperation {
|
||||
final String id;
|
||||
final SyncOperationType type;
|
||||
final String endpoint;
|
||||
final Map<String, dynamic> data;
|
||||
final int version;
|
||||
final DateTime createdAt;
|
||||
final int retryCount;
|
||||
|
||||
/// 最大重试次数
|
||||
static const int maxRetryCount = 5;
|
||||
|
||||
const PendingOperation({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.endpoint,
|
||||
required this.data,
|
||||
required this.version,
|
||||
required this.createdAt,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
|
||||
PendingOperation copyWith({
|
||||
String? id,
|
||||
SyncOperationType? type,
|
||||
String? endpoint,
|
||||
Map<String, dynamic>? data,
|
||||
int? version,
|
||||
DateTime? createdAt,
|
||||
int? retryCount,
|
||||
}) =>
|
||||
PendingOperation(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
endpoint: endpoint ?? this.endpoint,
|
||||
data: data ?? this.data,
|
||||
version: version ?? this.version,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
);
|
||||
|
||||
/// 是否已超过最大重试次数
|
||||
bool get isExhausted => retryCount >= maxRetryCount;
|
||||
}
|
||||
|
||||
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final engine = SyncEngine(apiClient: apiClient);
|
||||
///
|
||||
/// // 启动时恢复持久化队列
|
||||
/// await engine.restorePendingQueue();
|
||||
///
|
||||
/// // 本地修改后入队
|
||||
/// engine.enqueue(PendingOperation(
|
||||
/// id: 'op-1',
|
||||
/// type: SyncOperationType.create,
|
||||
/// endpoint: '/diary/entries',
|
||||
/// data: entry.toJson(),
|
||||
/// version: 1,
|
||||
/// createdAt: DateTime.now(),
|
||||
/// ));
|
||||
///
|
||||
/// // 网络恢复时触发同步
|
||||
/// await engine.trySync();
|
||||
///
|
||||
/// // 应用退出时持久化
|
||||
/// await engine.persistPendingQueue();
|
||||
/// ```
|
||||
class SyncEngine {
|
||||
final ApiClient _apiClient;
|
||||
final Queue<PendingOperation> _pendingQueue = Queue();
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
|
||||
SyncStatus _status = SyncStatus.idle;
|
||||
String? _lastError;
|
||||
|
||||
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 当前同步状态
|
||||
SyncStatus get status => _status;
|
||||
|
||||
/// 最近一次错误信息
|
||||
String? get lastError => _lastError;
|
||||
|
||||
/// 待同步操作数量
|
||||
int get pendingCount => _pendingQueue.length;
|
||||
|
||||
/// 是否有操作正在同步
|
||||
bool get isSyncing => _status == SyncStatus.syncing;
|
||||
|
||||
/// 添加待同步操作到队列尾部
|
||||
void enqueue(PendingOperation operation) {
|
||||
_pendingQueue.add(operation);
|
||||
if (_status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量添加待同步操作
|
||||
void enqueueAll(List<PendingOperation> operations) {
|
||||
for (final op in operations) {
|
||||
_pendingQueue.add(op);
|
||||
}
|
||||
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查网络状态并尝试同步全部待处理操作
|
||||
///
|
||||
/// 同步策略:
|
||||
/// 1. 检查网络是否可用
|
||||
/// 2. 按先进先出顺序处理队列
|
||||
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
|
||||
/// 4. 超过重试次数的操作标记为冲突,移出队列
|
||||
/// 5. 网络中断时暂停同步,保留剩余操作
|
||||
Future<void> trySync() async {
|
||||
if (_status == SyncStatus.syncing) return; // 防止重入
|
||||
if (_pendingQueue.isEmpty) {
|
||||
_status = SyncStatus.idle;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查网络
|
||||
final connectivity = Connectivity();
|
||||
final result = await connectivity.checkConnectivity();
|
||||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (!isOnline) {
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '网络不可用';
|
||||
return;
|
||||
}
|
||||
|
||||
// WiFi 优先策略:仅在 WiFi 下自动同步(Phase 1 简化)
|
||||
// TODO: 添加用户设置允许蜂窝数据同步
|
||||
|
||||
_status = SyncStatus.syncing;
|
||||
_lastError = null;
|
||||
|
||||
while (_pendingQueue.isNotEmpty) {
|
||||
final operation = _pendingQueue.removeFirst();
|
||||
|
||||
try {
|
||||
await _executeOperation(operation);
|
||||
} on OfflineException {
|
||||
// 网络中断,操作放回队列头部
|
||||
_pendingQueue.addFirst(operation);
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '同步中断:网络不可用';
|
||||
return;
|
||||
} catch (e) {
|
||||
// 操作失败,增加重试计数
|
||||
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||||
|
||||
if (retried.isExhausted) {
|
||||
// 超过最大重试次数,标记为冲突(Phase 1 简化:丢弃)
|
||||
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
|
||||
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 放回队列头部,下次重试
|
||||
_pendingQueue.addFirst(retried);
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '同步失败: $e';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部同步完成,更新持久化
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
await persistPendingQueue();
|
||||
}
|
||||
|
||||
/// 执行单个同步操作
|
||||
Future<void> _executeOperation(PendingOperation operation) async {
|
||||
switch (operation.type) {
|
||||
case SyncOperationType.create:
|
||||
await _apiClient.post(operation.endpoint, data: operation.data);
|
||||
case SyncOperationType.update:
|
||||
await _apiClient.put(operation.endpoint, data: operation.data);
|
||||
case SyncOperationType.delete:
|
||||
await _apiClient.delete(operation.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
|
||||
void clear() {
|
||||
_pendingQueue.clear();
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
}
|
||||
|
||||
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
||||
///
|
||||
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
||||
/// 下次启动时通过 [restorePendingQueue] 恢复。
|
||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||
|
||||
// ============================================================
|
||||
// Isar 持久化
|
||||
// ============================================================
|
||||
|
||||
/// 将当前内存队列持久化到 Isar
|
||||
///
|
||||
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
|
||||
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||
Future<void> persistPendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final ops = snapshot;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
// 清空旧数据
|
||||
await isar.pendingOperationCollections.clear();
|
||||
|
||||
// 写入当前队列
|
||||
for (final op in ops) {
|
||||
final col = _operationToCollection(op);
|
||||
await isar.pendingOperationCollections.put(col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 Isar 恢复持久化队列到内存
|
||||
///
|
||||
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
|
||||
/// Web 平台上 Isar 不可用,跳过恢复。
|
||||
Future<void> restorePendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final persisted = await isar.pendingOperationCollections
|
||||
.where()
|
||||
.anyIsarId()
|
||||
.findAll();
|
||||
|
||||
for (final col in persisted) {
|
||||
final op = _collectionToOperation(col);
|
||||
_pendingQueue.add(op);
|
||||
}
|
||||
|
||||
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动网络监听 — 网络恢复时自动触发同步
|
||||
///
|
||||
/// 在 app.dart 中创建 SyncEngine 后调用一次。
|
||||
/// 调用 [dispose] 停止监听。
|
||||
void startAutoSync() {
|
||||
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
|
||||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
|
||||
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
|
||||
trySync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止网络监听并清理资源
|
||||
void dispose() {
|
||||
_connectivitySub?.cancel();
|
||||
_connectivitySub = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数
|
||||
// ============================================================
|
||||
|
||||
/// PendingOperation → PendingOperationCollection
|
||||
PendingOperationCollection _operationToCollection(PendingOperation op) {
|
||||
return PendingOperationCollection()
|
||||
..id = op.id
|
||||
..operationType = op.type.httpMethod
|
||||
..endpoint = op.endpoint
|
||||
..dataJson = _encodeJson(op.data)
|
||||
..version = op.version
|
||||
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
|
||||
..retryCount = op.retryCount;
|
||||
}
|
||||
|
||||
/// PendingOperationCollection → PendingOperation
|
||||
PendingOperation _collectionToOperation(PendingOperationCollection col) {
|
||||
return PendingOperation(
|
||||
id: col.id,
|
||||
type: SyncOperationType.values.firstWhere(
|
||||
(t) => t.httpMethod == col.operationType,
|
||||
orElse: () => SyncOperationType.create,
|
||||
),
|
||||
endpoint: col.endpoint,
|
||||
data: _decodeJson(col.dataJson),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
retryCount: col.retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 安全编码 JSON
|
||||
String _encodeJson(Map<String, dynamic> data) {
|
||||
try {
|
||||
return jsonEncode(data);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解码 JSON
|
||||
Map<String, dynamic> _decodeJson(String json) {
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
export 'sync_engine_web.dart' if (dart.library.io) 'sync_engine_native.dart';
|
||||
|
||||
504
app/lib/data/services/sync_engine_native.dart
Normal file
@@ -0,0 +1,504 @@
|
||||
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
|
||||
//
|
||||
// 设计思路:
|
||||
// - 所有本地修改先入队 [PendingOperation]
|
||||
// - 网络恢复时自动批量同步
|
||||
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
||||
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
||||
// - 队列持久化到 Isar,应用退出后不丢失
|
||||
//
|
||||
// Phase 1 策略:本地优先
|
||||
// - 离线时正常使用,操作入队等待
|
||||
// - 联网后自动推送待同步操作
|
||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database_native.dart';
|
||||
import '../local/collections/pending_operation_collection.dart';
|
||||
import '../models/sync_models.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 同步操作类型
|
||||
enum SyncOperationType {
|
||||
create('POST'),
|
||||
update('PUT'),
|
||||
delete('DELETE');
|
||||
|
||||
const SyncOperationType(this.httpMethod);
|
||||
final String httpMethod;
|
||||
}
|
||||
|
||||
/// 同步状态
|
||||
enum SyncStatus {
|
||||
idle, // 空闲,无待同步操作
|
||||
syncing, // 正在同步
|
||||
paused, // 暂停(网络不可用)
|
||||
error, // 出错,需要重试
|
||||
}
|
||||
|
||||
/// 待同步操作 — 记录一次本地修改
|
||||
class PendingOperation {
|
||||
final String id;
|
||||
final SyncOperationType type;
|
||||
final String endpoint;
|
||||
final Map<String, dynamic> data;
|
||||
final int version;
|
||||
final DateTime createdAt;
|
||||
final int retryCount;
|
||||
|
||||
/// 最大重试次数
|
||||
static const int maxRetryCount = 5;
|
||||
|
||||
const PendingOperation({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.endpoint,
|
||||
required this.data,
|
||||
required this.version,
|
||||
required this.createdAt,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
|
||||
PendingOperation copyWith({
|
||||
String? id,
|
||||
SyncOperationType? type,
|
||||
String? endpoint,
|
||||
Map<String, dynamic>? data,
|
||||
int? version,
|
||||
DateTime? createdAt,
|
||||
int? retryCount,
|
||||
}) =>
|
||||
PendingOperation(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
endpoint: endpoint ?? this.endpoint,
|
||||
data: data ?? this.data,
|
||||
version: version ?? this.version,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
);
|
||||
|
||||
/// 是否已超过最大重试次数
|
||||
bool get isExhausted => retryCount >= maxRetryCount;
|
||||
}
|
||||
|
||||
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final engine = SyncEngine(apiClient: apiClient);
|
||||
///
|
||||
/// // 启动时恢复持久化队列
|
||||
/// await engine.restorePendingQueue();
|
||||
///
|
||||
/// // 本地修改后入队
|
||||
/// engine.enqueue(PendingOperation(
|
||||
/// id: 'op-1',
|
||||
/// type: SyncOperationType.create,
|
||||
/// endpoint: '/diary/entries',
|
||||
/// data: entry.toJson(),
|
||||
/// version: 1,
|
||||
/// createdAt: DateTime.now(),
|
||||
/// ));
|
||||
///
|
||||
/// // 网络恢复时触发同步
|
||||
/// await engine.trySync();
|
||||
///
|
||||
/// // 应用退出时持久化
|
||||
/// await engine.persistPendingQueue();
|
||||
/// ```
|
||||
class SyncEngine {
|
||||
final ApiClient _apiClient;
|
||||
final Queue<PendingOperation> _pendingQueue = Queue();
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
|
||||
SyncStatus _status = SyncStatus.idle;
|
||||
String? _lastError;
|
||||
|
||||
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 当前同步状态
|
||||
SyncStatus get status => _status;
|
||||
|
||||
/// 最近一次错误信息
|
||||
String? get lastError => _lastError;
|
||||
|
||||
/// 待同步操作数量
|
||||
int get pendingCount => _pendingQueue.length;
|
||||
|
||||
/// 是否有操作正在同步
|
||||
bool get isSyncing => _status == SyncStatus.syncing;
|
||||
|
||||
/// 添加待同步操作到队列尾部
|
||||
///
|
||||
/// 合并策略(8b-N01):同一资源(endpoint 相同)的连续操作只保留最新一条。
|
||||
/// create+update → create(使用最新数据)
|
||||
/// update+update → update(使用最新数据)
|
||||
/// update+delete → delete(资源最终被删除)
|
||||
/// create+delete → 取消(资源从未存在)
|
||||
///
|
||||
/// 私密日记(is_private=true)不入队 — 仅保存在本地,不上传后端。
|
||||
void enqueue(PendingOperation operation) {
|
||||
// 防御性检查:私密日记不入队
|
||||
final isPrivate = operation.data['is_private'] as bool? ?? false;
|
||||
if (isPrivate) {
|
||||
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找队列中同一资源的最后一个操作
|
||||
PendingOperation? existing;
|
||||
for (final op in _pendingQueue) {
|
||||
if (op.endpoint == operation.endpoint) {
|
||||
existing = op;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
final merged = _mergeOperations(existing, operation);
|
||||
_pendingQueue.remove(existing);
|
||||
if (merged != null) {
|
||||
_pendingQueue.add(merged);
|
||||
}
|
||||
// merged == null → create+delete 取消,不添加
|
||||
} else {
|
||||
_pendingQueue.add(operation);
|
||||
}
|
||||
|
||||
if (_status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量添加待同步操作(每个操作独立走合并逻辑)
|
||||
void enqueueAll(List<PendingOperation> operations) {
|
||||
for (final op in operations) {
|
||||
enqueue(op);
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并同一资源的两个操作
|
||||
///
|
||||
/// 返回合并后的操作,或 null 表示应取消(create+delete)。
|
||||
PendingOperation? _mergeOperations(
|
||||
PendingOperation existing,
|
||||
PendingOperation incoming,
|
||||
) {
|
||||
// create + delete → 取消(资源从未同步到服务端)
|
||||
if (existing.type == SyncOperationType.create &&
|
||||
incoming.type == SyncOperationType.delete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// create + update → create(使用最新数据)
|
||||
if (existing.type == SyncOperationType.create &&
|
||||
incoming.type == SyncOperationType.update) {
|
||||
return existing.copyWith(data: incoming.data, version: incoming.version);
|
||||
}
|
||||
|
||||
// update + update → update(使用最新数据)
|
||||
if (existing.type == SyncOperationType.update &&
|
||||
incoming.type == SyncOperationType.update) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// update + delete → delete
|
||||
if (existing.type == SyncOperationType.update &&
|
||||
incoming.type == SyncOperationType.delete) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// 其他组合(delete+create, create+create 等)不合并
|
||||
return incoming;
|
||||
}
|
||||
|
||||
/// 检查网络状态并尝试同步全部待处理操作
|
||||
///
|
||||
/// 同步策略:
|
||||
/// 1. 检查网络是否可用
|
||||
/// 2. 按先进先出顺序处理队列
|
||||
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
|
||||
/// 4. 超过重试次数的操作标记为冲突,移出队列
|
||||
/// 5. 网络中断时暂停同步,保留剩余操作
|
||||
Future<void> trySync() async {
|
||||
if (_status == SyncStatus.syncing) return; // 防止重入
|
||||
if (_pendingQueue.isEmpty) {
|
||||
_status = SyncStatus.idle;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查网络
|
||||
final connectivity = Connectivity();
|
||||
final result = await connectivity.checkConnectivity();
|
||||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (!isOnline) {
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '网络不可用';
|
||||
return;
|
||||
}
|
||||
|
||||
// WiFi 优先策略:仅在 WiFi 下自动同步(Phase 1 简化)
|
||||
// TODO: 添加用户设置允许蜂窝数据同步
|
||||
|
||||
_status = SyncStatus.syncing;
|
||||
_lastError = null;
|
||||
|
||||
while (_pendingQueue.isNotEmpty) {
|
||||
final operation = _pendingQueue.removeFirst();
|
||||
|
||||
try {
|
||||
await _executeOperation(operation);
|
||||
} on OfflineException {
|
||||
// 网络中断,操作放回队列头部
|
||||
_pendingQueue.addFirst(operation);
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '同步中断:网络不可用';
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('SyncEngine.trySync 操作失败: $e');
|
||||
// 操作失败,增加重试计数
|
||||
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||||
|
||||
if (retried.isExhausted) {
|
||||
// 超过最大重试次数,标记为冲突(Phase 1 简化:丢弃)
|
||||
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
|
||||
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 放回队列头部,下次重试
|
||||
_pendingQueue.addFirst(retried);
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '同步失败: $e';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部同步完成,更新持久化
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
await persistPendingQueue();
|
||||
}
|
||||
|
||||
/// 批量同步 — 使用 POST /diary/sync 端点一次性提交所有变更
|
||||
///
|
||||
/// 将队列中的 PendingOperation 转换为 SyncChange 列表,
|
||||
/// 调用 Rust sync_handler 批量处理,获取服务端变更和冲突。
|
||||
/// 成功后清空队列;失败时保留队列供重试。
|
||||
Future<SyncResp?> tryBatchSync({DateTime? lastSyncTime}) async {
|
||||
if (_status == SyncStatus.syncing) return null;
|
||||
if (_pendingQueue.isEmpty) {
|
||||
_status = SyncStatus.idle;
|
||||
return null;
|
||||
}
|
||||
|
||||
_status = SyncStatus.syncing;
|
||||
_lastError = null;
|
||||
|
||||
try {
|
||||
// 转换: PendingOperation → SyncChange
|
||||
final changes = _pendingQueue.map(_operationToSyncChange).toList();
|
||||
|
||||
final req = SyncReq(
|
||||
lastSyncTime: lastSyncTime,
|
||||
changes: changes,
|
||||
);
|
||||
|
||||
final resp = await _apiClient.sync(req);
|
||||
|
||||
// 处理冲突 — 将冲突的操作保留在队列中
|
||||
if (resp.conflicts.isNotEmpty) {
|
||||
final conflictIds = resp.conflicts.map((c) => c.journalId).toSet();
|
||||
// 移除已成功同步的非冲突操作,保留冲突操作
|
||||
_pendingQueue.removeWhere(
|
||||
(op) => !conflictIds.contains(op.id),
|
||||
);
|
||||
_lastError = '${resp.conflicts.length} 个操作存在版本冲突';
|
||||
} else {
|
||||
// 全部成功,清空队列
|
||||
_pendingQueue.clear();
|
||||
}
|
||||
|
||||
_status = _pendingQueue.isEmpty ? SyncStatus.idle : SyncStatus.paused;
|
||||
await persistPendingQueue();
|
||||
|
||||
return resp;
|
||||
} on OfflineException {
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '同步中断:网络不可用';
|
||||
return null;
|
||||
} catch (e) {
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '批量同步失败: $e';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// PendingOperation → SyncChange 转换
|
||||
SyncChange _operationToSyncChange(PendingOperation op) {
|
||||
switch (op.type) {
|
||||
case SyncOperationType.create:
|
||||
return SyncChangeCreateJournal(data: op.data);
|
||||
case SyncOperationType.update:
|
||||
return SyncChangeUpdateJournal(
|
||||
id: op.id,
|
||||
version: op.version,
|
||||
data: op.data,
|
||||
);
|
||||
case SyncOperationType.delete:
|
||||
return SyncChangeDeleteJournal(
|
||||
id: op.id,
|
||||
version: op.version,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行单个同步操作
|
||||
Future<void> _executeOperation(PendingOperation operation) async {
|
||||
switch (operation.type) {
|
||||
case SyncOperationType.create:
|
||||
await _apiClient.post(operation.endpoint, data: operation.data);
|
||||
case SyncOperationType.update:
|
||||
await _apiClient.put(operation.endpoint, data: operation.data);
|
||||
case SyncOperationType.delete:
|
||||
await _apiClient.delete(operation.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
|
||||
void clear() {
|
||||
_pendingQueue.clear();
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
}
|
||||
|
||||
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
||||
///
|
||||
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
||||
/// 下次启动时通过 [restorePendingQueue] 恢复。
|
||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||
|
||||
// ============================================================
|
||||
// Isar 持久化
|
||||
// ============================================================
|
||||
|
||||
/// 将当前内存队列持久化到 Isar
|
||||
///
|
||||
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
|
||||
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||
Future<void> persistPendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance;
|
||||
final ops = snapshot;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
// 清空旧数据
|
||||
await isar.pendingOperationCollections.clear();
|
||||
|
||||
// 写入当前队列
|
||||
for (final op in ops) {
|
||||
final col = _operationToCollection(op);
|
||||
await isar.pendingOperationCollections.put(col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 Isar 恢复持久化队列到内存
|
||||
///
|
||||
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
|
||||
/// Web 平台上 Isar 不可用,跳过恢复。
|
||||
Future<void> restorePendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance;
|
||||
final persisted = await isar.pendingOperationCollections
|
||||
.where()
|
||||
.anyIsarId()
|
||||
.findAll();
|
||||
|
||||
for (final col in persisted) {
|
||||
final op = _collectionToOperation(col);
|
||||
_pendingQueue.add(op);
|
||||
}
|
||||
|
||||
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动网络监听 — 网络恢复时自动触发同步
|
||||
///
|
||||
/// 在 app.dart 中创建 SyncEngine 后调用一次。
|
||||
/// 调用 [dispose] 停止监听。
|
||||
void startAutoSync() {
|
||||
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
|
||||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
|
||||
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
|
||||
trySync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止网络监听并清理资源
|
||||
void dispose() {
|
||||
_connectivitySub?.cancel();
|
||||
_connectivitySub = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 转换函数
|
||||
// ============================================================
|
||||
|
||||
/// PendingOperation → PendingOperationCollection
|
||||
PendingOperationCollection _operationToCollection(PendingOperation op) {
|
||||
return PendingOperationCollection()
|
||||
..id = op.id
|
||||
..operationType = op.type.httpMethod
|
||||
..endpoint = op.endpoint
|
||||
..dataJson = _encodeJson(op.data)
|
||||
..version = op.version
|
||||
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
|
||||
..retryCount = op.retryCount;
|
||||
}
|
||||
|
||||
/// PendingOperationCollection → PendingOperation
|
||||
PendingOperation _collectionToOperation(PendingOperationCollection col) {
|
||||
return PendingOperation(
|
||||
id: col.id,
|
||||
type: SyncOperationType.values.firstWhere(
|
||||
(t) => t.httpMethod == col.operationType,
|
||||
orElse: () => SyncOperationType.create,
|
||||
),
|
||||
endpoint: col.endpoint,
|
||||
data: _decodeJson(col.dataJson),
|
||||
version: col.version,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||
retryCount: col.retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 安全编码 JSON
|
||||
String _encodeJson(Map<String, dynamic> data) {
|
||||
try {
|
||||
return jsonEncode(data);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解码 JSON
|
||||
Map<String, dynamic> _decodeJson(String json) {
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
221
app/lib/data/services/sync_engine_web.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
// 同步引擎 — Web 平台实现(无 Isar 持久化)
|
||||
//
|
||||
// Web 平台上 Isar 不可用,操作队列仅保存在内存中。
|
||||
// 核心同步逻辑与原生版一致,仅持久化部分为空实现。
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 同步操作类型
|
||||
enum SyncOperationType {
|
||||
create('POST'),
|
||||
update('PUT'),
|
||||
delete('DELETE');
|
||||
|
||||
const SyncOperationType(this.httpMethod);
|
||||
final String httpMethod;
|
||||
}
|
||||
|
||||
/// 同步状态
|
||||
enum SyncStatus {
|
||||
idle,
|
||||
syncing,
|
||||
paused,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 待同步操作
|
||||
class PendingOperation {
|
||||
final String id;
|
||||
final SyncOperationType type;
|
||||
final String endpoint;
|
||||
final Map<String, dynamic> data;
|
||||
final int version;
|
||||
final DateTime createdAt;
|
||||
final int retryCount;
|
||||
|
||||
static const int maxRetryCount = 5;
|
||||
|
||||
const PendingOperation({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.endpoint,
|
||||
required this.data,
|
||||
required this.version,
|
||||
required this.createdAt,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
|
||||
PendingOperation copyWith({
|
||||
String? id,
|
||||
SyncOperationType? type,
|
||||
String? endpoint,
|
||||
Map<String, dynamic>? data,
|
||||
int? version,
|
||||
DateTime? createdAt,
|
||||
int? retryCount,
|
||||
}) =>
|
||||
PendingOperation(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
endpoint: endpoint ?? this.endpoint,
|
||||
data: data ?? this.data,
|
||||
version: version ?? this.version,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
);
|
||||
|
||||
bool get isExhausted => retryCount >= maxRetryCount;
|
||||
}
|
||||
|
||||
/// 同步引擎 — Web 版(内存队列,无持久化)
|
||||
class SyncEngine {
|
||||
final ApiClient _apiClient;
|
||||
final Queue<PendingOperation> _pendingQueue = Queue();
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
|
||||
SyncStatus _status = SyncStatus.idle;
|
||||
String? _lastError;
|
||||
|
||||
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
SyncStatus get status => _status;
|
||||
String? get lastError => _lastError;
|
||||
int get pendingCount => _pendingQueue.length;
|
||||
bool get isSyncing => _status == SyncStatus.syncing;
|
||||
|
||||
/// 入队待同步操作 — 私密日记(is_private=true)不入队
|
||||
void enqueue(PendingOperation operation) {
|
||||
// 防御性检查:私密日记仅保存在本地,不上传后端
|
||||
final isPrivate = operation.data['is_private'] as bool? ?? false;
|
||||
if (isPrivate) {
|
||||
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingQueue.add(operation);
|
||||
if (_status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
void enqueueAll(List<PendingOperation> operations) {
|
||||
for (final op in operations) {
|
||||
_pendingQueue.add(op);
|
||||
}
|
||||
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> trySync() async {
|
||||
if (_status == SyncStatus.syncing) return;
|
||||
if (_pendingQueue.isEmpty) {
|
||||
_status = SyncStatus.idle;
|
||||
return;
|
||||
}
|
||||
|
||||
final connectivity = Connectivity();
|
||||
final result = await connectivity.checkConnectivity();
|
||||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (!isOnline) {
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '网络不可用';
|
||||
return;
|
||||
}
|
||||
|
||||
_status = SyncStatus.syncing;
|
||||
_lastError = null;
|
||||
|
||||
while (_pendingQueue.isNotEmpty) {
|
||||
final operation = _pendingQueue.removeFirst();
|
||||
|
||||
try {
|
||||
await _executeOperation(operation);
|
||||
} on OfflineException {
|
||||
_pendingQueue.addFirst(operation);
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '同步中断:网络不可用';
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('SyncEngine.trySync 操作失败: $e');
|
||||
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||||
|
||||
if (retried.isExhausted) {
|
||||
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||
continue;
|
||||
}
|
||||
|
||||
_pendingQueue.addFirst(retried);
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '同步失败: $e';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
}
|
||||
|
||||
Future<void> _executeOperation(PendingOperation operation) async {
|
||||
switch (operation.type) {
|
||||
case SyncOperationType.create:
|
||||
await _apiClient.post(operation.endpoint, data: operation.data);
|
||||
case SyncOperationType.update:
|
||||
await _apiClient.put(operation.endpoint, data: operation.data);
|
||||
case SyncOperationType.delete:
|
||||
await _apiClient.delete(operation.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_pendingQueue.clear();
|
||||
_status = SyncStatus.idle;
|
||||
_lastError = null;
|
||||
}
|
||||
|
||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||
|
||||
/// Web 平台:持久化为空操作(队列仅保存在内存中)
|
||||
Future<void> persistPendingQueue() async {}
|
||||
|
||||
/// Web 平台:恢复队列为空操作(无持久化数据)
|
||||
Future<void> restorePendingQueue() async {}
|
||||
|
||||
void startAutoSync() {
|
||||
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
|
||||
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
|
||||
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
|
||||
trySync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_connectivitySub?.cancel();
|
||||
_connectivitySub = null;
|
||||
}
|
||||
|
||||
String _encodeJson(Map<String, dynamic> data) {
|
||||
try {
|
||||
return jsonEncode(data);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeJson(String json) {
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 成就 BLoC — 通过 API 加载成就列表
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
@@ -98,6 +99,7 @@ class AchievementBloc extends ChangeNotifier {
|
||||
|
||||
_state = _state.copyWith(isLoading: false, achievements: achievements);
|
||||
} catch (e) {
|
||||
debugPrint('AchievementBloc._fetchAchievements 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载成就列表失败',
|
||||
|
||||
@@ -34,6 +34,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
on<LoginRequested>(_onLoginRequested);
|
||||
on<RegisterRequested>(_onRegisterRequested);
|
||||
on<RoleSelected>(_onRoleSelected);
|
||||
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
|
||||
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
|
||||
on<LogoutRequested>(_onLogoutRequested);
|
||||
on<TokenRefreshed>(_onTokenRefreshed);
|
||||
@@ -124,16 +125,38 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
// 学生角色需要先经过家长同意确认(PIPL 第28条)
|
||||
final needsParentalConsent = event.role == UserRoleType.student;
|
||||
|
||||
// 根据角色决定下一步
|
||||
final needsClassCode =
|
||||
event.role == UserRoleType.student || event.role == UserRoleType.parent;
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsRoleSelection: false,
|
||||
needsClassCode: needsClassCode,
|
||||
needsParentalConsent: needsParentalConsent,
|
||||
needsClassCode: needsClassCode && !needsParentalConsent,
|
||||
selectedRole: event.role,
|
||||
));
|
||||
|
||||
_logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode');
|
||||
_logger.i('角色选择: ${event.role.name}, 需要家长同意: $needsParentalConsent');
|
||||
}
|
||||
|
||||
/// 家长/监护人同意信息收集(PIPL 合规)
|
||||
Future<void> _onParentalConsentAccepted(
|
||||
ParentalConsentAccepted event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
_logger.i('家长同意已确认: ${event.consentAt}');
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsParentalConsent: false,
|
||||
needsClassCode: true,
|
||||
parentalConsentAt: event.consentAt,
|
||||
));
|
||||
}
|
||||
|
||||
/// 班级码加入
|
||||
|
||||
@@ -43,6 +43,12 @@ final class RoleSelected extends AuthEvent {
|
||||
const RoleSelected(this.role);
|
||||
}
|
||||
|
||||
/// 家长/监护人同意 PIPL 信息收集(审计 S-03)
|
||||
final class ParentalConsentAccepted extends AuthEvent {
|
||||
final DateTime consentAt;
|
||||
const ParentalConsentAccepted(this.consentAt);
|
||||
}
|
||||
|
||||
/// 班级码加入(学生/家长加入班级)
|
||||
final class ClassCodeSubmitted extends AuthEvent {
|
||||
final String classCode;
|
||||
|
||||
@@ -37,6 +37,9 @@ final class Authenticated extends AuthState {
|
||||
/// 是否需要角色选择(新注册用户还没有角色)
|
||||
final bool needsRoleSelection;
|
||||
|
||||
/// 是否需要家长/监护人同意(PIPL 第28条 — 学生角色)
|
||||
final bool needsParentalConsent;
|
||||
|
||||
/// 是否需要班级码加入(学生/家长角色)
|
||||
final bool needsClassCode;
|
||||
|
||||
@@ -46,27 +49,43 @@ final class Authenticated extends AuthState {
|
||||
/// 班级码验证错误信息
|
||||
final String? classCodeError;
|
||||
|
||||
/// 已选择的角色(角色选择后暂存)
|
||||
final UserRoleType? selectedRole;
|
||||
|
||||
/// 家长同意时间戳
|
||||
final DateTime? parentalConsentAt;
|
||||
|
||||
const Authenticated({
|
||||
required this.user,
|
||||
this.needsRoleSelection = false,
|
||||
this.needsParentalConsent = false,
|
||||
this.needsClassCode = false,
|
||||
this.isLoading = false,
|
||||
this.classCodeError,
|
||||
this.selectedRole,
|
||||
this.parentalConsentAt,
|
||||
});
|
||||
|
||||
Authenticated copyWith({
|
||||
User? user,
|
||||
bool? needsRoleSelection,
|
||||
bool? needsParentalConsent,
|
||||
bool? needsClassCode,
|
||||
bool? isLoading,
|
||||
String? classCodeError,
|
||||
UserRoleType? selectedRole,
|
||||
DateTime? parentalConsentAt,
|
||||
}) =>
|
||||
Authenticated(
|
||||
user: user ?? this.user,
|
||||
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
|
||||
needsParentalConsent:
|
||||
needsParentalConsent ?? this.needsParentalConsent,
|
||||
needsClassCode: needsClassCode ?? this.needsClassCode,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
classCodeError: classCodeError,
|
||||
selectedRole: selectedRole ?? this.selectedRole,
|
||||
parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 装饰圆圈
|
||||
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
|
||||
@@ -180,6 +181,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
|
||||
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo — 自定义笔记本图标
|
||||
Container(
|
||||
|
||||
254
app/lib/features/auth/views/parental_consent_page.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
// 家长同意确认页面 — PIPL 第28条合规
|
||||
//
|
||||
// 未满 14 岁用户选择"学生"角色后,必须经过家长/监护人确认。
|
||||
// 页面展示隐私政策要点,要求家长勾选同意并确认。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// 家长同意确认页面
|
||||
class ParentalConsentPage extends StatefulWidget {
|
||||
const ParentalConsentPage({super.key});
|
||||
|
||||
@override
|
||||
State<ParentalConsentPage> createState() => _ParentalConsentPageState();
|
||||
}
|
||||
|
||||
class _ParentalConsentPageState extends State<ParentalConsentPage> {
|
||||
bool _consentGiven = false;
|
||||
bool _privacyPolicyAccepted = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canProceed = _consentGiven && _privacyPolicyAccepted;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
title: const Text('家长/监护人确认'),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Icon(
|
||||
Icons.shield_rounded,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Text(
|
||||
'儿童个人信息保护',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(
|
||||
'根据《中华人民共和国个人信息保护法》第28条,'
|
||||
'未满14周岁未成年人的个人信息处理需要取得父母或监护人的同意。',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
// 信息收集说明卡片
|
||||
_buildInfoCard(
|
||||
context,
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: '我们会收集哪些信息',
|
||||
items: const [
|
||||
'昵称和年级(不收集真实姓名和身份证号)',
|
||||
'日记内容和手写笔画',
|
||||
'心情标签和照片',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
|
||||
// 用途说明卡片
|
||||
_buildInfoCard(
|
||||
context,
|
||||
icon: Icons.security_rounded,
|
||||
title: '信息如何保护',
|
||||
items: const [
|
||||
'所有数据加密存储和传输',
|
||||
'仅用于日记记录和班级互动',
|
||||
'不会用于商业广告或分享给第三方',
|
||||
'您可以随时查阅、更正或删除孩子数据',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
// 同意复选框
|
||||
_buildCheckbox(
|
||||
value: _privacyPolicyAccepted,
|
||||
onChanged: (v) =>
|
||||
setState(() => _privacyPolicyAccepted = v ?? false),
|
||||
text: '我已阅读并同意《暖记隐私政策》和《儿童个人信息保护规则》',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing4),
|
||||
|
||||
_buildCheckbox(
|
||||
value: _consentGiven,
|
||||
onChanged: (v) => setState(() => _consentGiven = v ?? false),
|
||||
text: '我是该用户的家长/监护人,同意暖记收集和处理上述信息',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing32),
|
||||
|
||||
// 确认按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: canProceed ? _onConfirm : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
),
|
||||
child: const Text('确认同意,继续'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
|
||||
// 拒绝按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
),
|
||||
child: const Text('不同意,返回'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<String> items,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
...items.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: DesignTokens.spacing4,
|
||||
left: DesignTokens.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckbox({
|
||||
required bool value,
|
||||
required ValueChanged<bool?> onChanged,
|
||||
required String text,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: () => onChanged(!value),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing4,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing4),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 确认同意 — 发出事件继续注册流程
|
||||
void _onConfirm() {
|
||||
final consentAt = DateTime.now();
|
||||
context.read<AuthBloc>().add(ParentalConsentAccepted(consentAt));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
@@ -130,12 +131,21 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
|
||||
if (state is CalendarLoaded) {
|
||||
final current = state as CalendarLoaded;
|
||||
// 根据当前选中日期查找日记,避免进入页面时空白
|
||||
final dayKey = DateTime(
|
||||
current.selectedDay.year,
|
||||
current.selectedDay.month,
|
||||
current.selectedDay.day,
|
||||
);
|
||||
final selectedJournals = byDate[dayKey] ?? [];
|
||||
emit(current.copyWith(
|
||||
journalsByDate: byDate,
|
||||
selectedDayJournals: selectedJournals,
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CalendarBloc._onMonthChanged 失败: $e');
|
||||
if (state is CalendarLoaded) {
|
||||
emit((state as CalendarLoaded).copyWith(isLoading: false));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 星期标题行 =====
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,7 +297,7 @@ class _DiaryWallCard extends StatelessWidget {
|
||||
radius: 16,
|
||||
backgroundColor: AppColors.rose.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
'同',
|
||||
journal.title.isNotEmpty ? journal.title[0] : '日',
|
||||
style: theme.textTheme.labelMedium?.copyWith(color: AppColors.rose),
|
||||
),
|
||||
),
|
||||
@@ -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: '暂无主题布置',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
78
app/lib/features/discover/bloc/discover_bloc.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
// 发现页 BLoC — 管理发现页数据加载状态
|
||||
//
|
||||
// 职责:调用 /diary/discover API,解析响应,管理加载/成功/失败状态。
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../models/discover_models.dart';
|
||||
|
||||
part 'discover_event.dart';
|
||||
part 'discover_state.dart';
|
||||
|
||||
class DiscoverBloc extends Bloc<DiscoverEvent, DiscoverState> {
|
||||
final ApiClient _api;
|
||||
|
||||
DiscoverBloc({required ApiClient api})
|
||||
: _api = api,
|
||||
super(const DiscoverInitial()) {
|
||||
on<DiscoverLoadData>(_onLoadData);
|
||||
on<DiscoverRefresh>(_onRefresh);
|
||||
}
|
||||
|
||||
/// 首次加载 — 显示 loading 状态
|
||||
Future<void> _onLoadData(
|
||||
DiscoverLoadData event,
|
||||
Emitter<DiscoverState> emit,
|
||||
) async {
|
||||
emit(const DiscoverLoading());
|
||||
await _fetchData(emit);
|
||||
}
|
||||
|
||||
/// 刷新 — 不显示 loading,静默更新
|
||||
Future<void> _onRefresh(
|
||||
DiscoverRefresh event,
|
||||
Emitter<DiscoverState> emit,
|
||||
) async {
|
||||
await _fetchData(emit);
|
||||
}
|
||||
|
||||
/// 通用数据获取逻辑
|
||||
Future<void> _fetchData(Emitter<DiscoverState> emit) async {
|
||||
try {
|
||||
final response = await _api.get('/diary/discover');
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
|
||||
// 后端信封格式: { success, data: { ... }, message }
|
||||
final dataJson = body['data'] as Map<String, dynamic>? ?? {};
|
||||
final discoverData = DiscoverData.fromJson(dataJson);
|
||||
|
||||
emit(DiscoverLoaded(discoverData));
|
||||
} on OfflineException {
|
||||
// 离线时,如果有已加载的数据,保留它
|
||||
if (state is DiscoverLoaded) return;
|
||||
emit(const DiscoverError('网络不可用,请检查网络连接'));
|
||||
} catch (e) {
|
||||
if (state is DiscoverLoaded) return;
|
||||
emit(DiscoverError('加载失败:${_friendlyError(e)}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 将异常转换为用户友好的错误消息
|
||||
String _friendlyError(Object error) {
|
||||
final msg = error.toString();
|
||||
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||
return '无法连接服务器';
|
||||
}
|
||||
if (msg.contains('401')) {
|
||||
return '登录已过期,请重新登录';
|
||||
}
|
||||
if (msg.contains('403')) {
|
||||
return '没有访问权限';
|
||||
}
|
||||
if (msg.contains('500')) {
|
||||
return '服务器错误,请稍后重试';
|
||||
}
|
||||
return '请稍后重试';
|
||||
}
|
||||
}
|
||||
16
app/lib/features/discover/bloc/discover_event.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
part of 'discover_bloc.dart';
|
||||
|
||||
/// 发现页事件
|
||||
sealed class DiscoverEvent {
|
||||
const DiscoverEvent();
|
||||
}
|
||||
|
||||
/// 加载发现页数据(首次进入或重新进入页面)
|
||||
final class DiscoverLoadData extends DiscoverEvent {
|
||||
const DiscoverLoadData();
|
||||
}
|
||||
|
||||
/// 下拉刷新(不显示全屏 loading,避免闪烁)
|
||||
final class DiscoverRefresh extends DiscoverEvent {
|
||||
const DiscoverRefresh();
|
||||
}
|
||||
30
app/lib/features/discover/bloc/discover_state.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
part of 'discover_bloc.dart';
|
||||
|
||||
/// 发现页状态
|
||||
sealed class DiscoverState {
|
||||
const DiscoverState();
|
||||
}
|
||||
|
||||
/// 初始状态
|
||||
final class DiscoverInitial extends DiscoverState {
|
||||
const DiscoverInitial();
|
||||
}
|
||||
|
||||
/// 加载中
|
||||
final class DiscoverLoading extends DiscoverState {
|
||||
const DiscoverLoading();
|
||||
}
|
||||
|
||||
/// 加载成功
|
||||
final class DiscoverLoaded extends DiscoverState {
|
||||
final DiscoverData data;
|
||||
|
||||
const DiscoverLoaded(this.data);
|
||||
}
|
||||
|
||||
/// 加载失败
|
||||
final class DiscoverError extends DiscoverState {
|
||||
final String message;
|
||||
|
||||
const DiscoverError(this.message);
|
||||
}
|
||||
160
app/lib/features/discover/models/discover_models.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
// 发现页数据模型 — 手写不可变类(避免 build_runner 依赖)
|
||||
|
||||
/// 发现页聚合响应 — 一次 API 返回全部板块数据
|
||||
class DiscoverData {
|
||||
final InspirationItem? dailyInspiration;
|
||||
final List<TagCount> hotTopics;
|
||||
final List<DiscoverTemplateItem> featuredTemplates;
|
||||
final List<ExpertDiaryItem> expertDiaries;
|
||||
|
||||
const DiscoverData({
|
||||
this.dailyInspiration,
|
||||
this.hotTopics = const [],
|
||||
this.featuredTemplates = const [],
|
||||
this.expertDiaries = const [],
|
||||
});
|
||||
|
||||
factory DiscoverData.fromJson(Map<String, dynamic> json) => DiscoverData(
|
||||
dailyInspiration: json['daily_inspiration'] != null
|
||||
? InspirationItem.fromJson(
|
||||
json['daily_inspiration'] as Map<String, dynamic>)
|
||||
: null,
|
||||
hotTopics: (json['hot_topics'] as List? ?? [])
|
||||
.map((e) => TagCount.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
featuredTemplates: (json['featured_templates'] as List? ?? [])
|
||||
.map(
|
||||
(e) => DiscoverTemplateItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
expertDiaries: (json['expert_diaries'] as List? ?? [])
|
||||
.map((e) => ExpertDiaryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
/// 心情 → emoji 映射
|
||||
static String moodToEmoji(String mood) => switch (mood) {
|
||||
'happy' => '😊',
|
||||
'calm' => '😌',
|
||||
'sad' => '😢',
|
||||
'angry' => '😤',
|
||||
'thinking' => '🤔',
|
||||
_ => '📝',
|
||||
};
|
||||
}
|
||||
|
||||
/// 每日推荐条目
|
||||
class InspirationItem {
|
||||
final String journalId;
|
||||
final String title;
|
||||
final String authorName;
|
||||
final String mood;
|
||||
final DateTime date;
|
||||
|
||||
const InspirationItem({
|
||||
required this.journalId,
|
||||
required this.title,
|
||||
required this.authorName,
|
||||
required this.mood,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
factory InspirationItem.fromJson(Map<String, dynamic> json) =>
|
||||
InspirationItem(
|
||||
journalId: json['journal_id'] as String,
|
||||
title: json['title'] as String,
|
||||
authorName: json['author_name'] as String,
|
||||
mood: json['mood'] as String,
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// 热门话题
|
||||
class TagCount {
|
||||
final String tag;
|
||||
final int count;
|
||||
|
||||
const TagCount({required this.tag, required this.count});
|
||||
|
||||
factory TagCount.fromJson(Map<String, dynamic> json) => TagCount(
|
||||
tag: json['tag'] as String,
|
||||
count: json['count'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// 精选模板条目(轻量版,不含 layout_data)
|
||||
class DiscoverTemplateItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? previewUrl;
|
||||
final String? category;
|
||||
final bool isFree;
|
||||
|
||||
const DiscoverTemplateItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.previewUrl,
|
||||
this.category,
|
||||
this.isFree = true,
|
||||
});
|
||||
|
||||
factory DiscoverTemplateItem.fromJson(Map<String, dynamic> json) =>
|
||||
DiscoverTemplateItem(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
previewUrl: json['preview_url'] as String?,
|
||||
category: json['category'] as String?,
|
||||
isFree: json['is_free'] as bool? ?? true,
|
||||
);
|
||||
|
||||
/// 分类 → emoji 映射
|
||||
String get emoji => switch (category) {
|
||||
'日常' => '📖',
|
||||
'旅行' => '✈️',
|
||||
'校园' => '🎓',
|
||||
'节日' => '🎄',
|
||||
'创意' => '✨',
|
||||
'心情' => '🌿',
|
||||
_ => '📝',
|
||||
};
|
||||
|
||||
/// 使用人数展示文本
|
||||
String get usageText => isFree ? '免费模板' : '精品模板';
|
||||
}
|
||||
|
||||
/// 达人日记条目
|
||||
class ExpertDiaryItem {
|
||||
final String journalId;
|
||||
final String title;
|
||||
final String authorId;
|
||||
final String authorName;
|
||||
final String authorEmoji;
|
||||
final String contentPreview;
|
||||
final int likeCount;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ExpertDiaryItem({
|
||||
required this.journalId,
|
||||
required this.title,
|
||||
required this.authorId,
|
||||
required this.authorName,
|
||||
required this.authorEmoji,
|
||||
required this.contentPreview,
|
||||
required this.likeCount,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ExpertDiaryItem.fromJson(Map<String, dynamic> json) =>
|
||||
ExpertDiaryItem(
|
||||
journalId: json['journal_id'] as String,
|
||||
title: json['title'] as String,
|
||||
authorId: json['author_id'] as String,
|
||||
authorName: json['author_name'] as String,
|
||||
authorEmoji: json['author_emoji'] as String,
|
||||
contentPreview: json['content_preview'] as String? ?? '',
|
||||
likeCount: json['like_count'] as int? ?? 0,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
|
||||
/// 点赞数展示文本
|
||||
String get likeText => '$likeCount 赞';
|
||||
}
|
||||
@@ -8,8 +8,10 @@
|
||||
// 5. 达人日记 expert-diaries (纵向列表)
|
||||
//
|
||||
// 注意:本页是发现/灵感浏览,区别于 /search(主动搜索)
|
||||
// 数据来源:GET /diary/discover → DiscoverBloc
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
@@ -17,51 +19,156 @@ 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';
|
||||
|
||||
class DiscoverPage extends StatelessWidget {
|
||||
const DiscoverPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: bg,
|
||||
backgroundColor: _bgColor(context),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_SearchBar(onTap: () => context.push('/search')),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
const _InspirationCard(
|
||||
title: '今日推荐:图书馆的午后时光',
|
||||
author: '小暖 · 5月31日',
|
||||
emoji: '📚',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '热门话题'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _HotTopicsChips(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '精选模板'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _FeaturedTemplatesGrid(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '达人日记'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _ExpertDiariesList(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
],
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<DiscoverBloc>().add(const DiscoverRefresh());
|
||||
// 等待状态变化完成
|
||||
await context.read<DiscoverBloc>().stream.firstWhere(
|
||||
(s) => s is DiscoverLoaded || s is DiscoverError,
|
||||
orElse: () => const DiscoverLoaded(DiscoverData()),
|
||||
);
|
||||
},
|
||||
child: BlocBuilder<DiscoverBloc, DiscoverState>(
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_SearchBar(onTap: () => context.push('/search')),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
_buildContent(context, state),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据状态构建主要内容
|
||||
Widget _buildContent(BuildContext context, DiscoverState state) {
|
||||
return switch (state) {
|
||||
DiscoverInitial() => _buildLoading(context),
|
||||
DiscoverLoading() => _buildLoading(context),
|
||||
DiscoverLoaded(:final data) => _buildLoaded(context, data),
|
||||
DiscoverError(:final message) => _buildError(context, message),
|
||||
};
|
||||
}
|
||||
|
||||
/// 加载中状态 — 骨架占位
|
||||
Widget _buildLoading(BuildContext context) {
|
||||
return const Column(
|
||||
children: [
|
||||
_LoadingSkeleton(height: 140),
|
||||
SizedBox(height: DesignTokens.spacing24),
|
||||
_LoadingSkeleton(height: 44),
|
||||
SizedBox(height: DesignTokens.spacing24),
|
||||
_LoadingSkeleton(height: 200),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载成功 — 渲染真实数据
|
||||
Widget _buildLoaded(BuildContext context, DiscoverData data) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 每日推荐
|
||||
_InspirationCard(item: data.dailyInspiration),
|
||||
// 热门话题
|
||||
if (data.hotTopics.isNotEmpty) ...[
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
const _SectionTitle(title: '热门话题'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_HotTopicsChips(topics: data.hotTopics),
|
||||
],
|
||||
// 精选模板
|
||||
if (data.featuredTemplates.isNotEmpty) ...[
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
const _SectionTitle(title: '精选模板'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_FeaturedTemplatesGrid(templates: data.featuredTemplates),
|
||||
],
|
||||
// 达人日记
|
||||
if (data.expertDiaries.isNotEmpty) ...[
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
const _SectionTitle(title: '达人日记'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_ExpertDiariesList(diaries: data.expertDiaries),
|
||||
],
|
||||
// 全部为空时的占位提示
|
||||
if (data.dailyInspiration == null &&
|
||||
data.hotTopics.isEmpty &&
|
||||
data.featuredTemplates.isEmpty &&
|
||||
data.expertDiaries.isEmpty)
|
||||
_buildEmptyHint(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 错误状态
|
||||
Widget _buildError(BuildContext context, String message) {
|
||||
return ErrorStateWidget(
|
||||
message: message,
|
||||
onRetry: () =>
|
||||
context.read<DiscoverBloc>().add(const DiscoverLoadData()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 空数据提示
|
||||
Widget _buildEmptyHint(BuildContext context) {
|
||||
return const EmptyStateWidget(
|
||||
icon: Icons.explore_rounded,
|
||||
title: '还没有发现内容',
|
||||
subtitle: '试试写一篇日记分享给大家吧',
|
||||
);
|
||||
}
|
||||
|
||||
Color _bgColor(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return theme.brightness == Brightness.dark
|
||||
? AppColors.bgDark
|
||||
: AppColors.bgLight;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载骨架占位
|
||||
class _LoadingSkeleton extends StatelessWidget {
|
||||
const _LoadingSkeleton({required this.height});
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 1. 搜索框(点击跳转 /search)
|
||||
@@ -77,7 +184,8 @@ class _SearchBar extends StatelessWidget {
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
@@ -85,7 +193,8 @@ class _SearchBar extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||
Icon(Icons.search_rounded,
|
||||
size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Text(
|
||||
'搜索日记、模板、话题...',
|
||||
@@ -103,18 +212,49 @@ class _SearchBar extends StatelessWidget {
|
||||
|
||||
/// 2. 每日推荐卡片(渐变背景)
|
||||
class _InspirationCard extends StatelessWidget {
|
||||
const _InspirationCard({
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.emoji,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String author;
|
||||
final String emoji;
|
||||
const _InspirationCard({required this.item});
|
||||
final InspirationItem? item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (item == null) {
|
||||
// 无推荐日记时的占位卡片
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.tertiary],
|
||||
),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('今日推荐',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
letterSpacing: 0.5,
|
||||
)),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const Text('今天还没有推荐日记',
|
||||
style: TextStyle(fontSize: 16, color: Colors.white)),
|
||||
const SizedBox(height: 4),
|
||||
Text('写下你的日记,可能出现在这里哦 ✨',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.7))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final emoji = DiscoverData.moodToEmoji(item!.mood);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing20),
|
||||
@@ -191,17 +331,19 @@ class _InspirationCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
item!.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.25,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
author,
|
||||
'${item!.authorName} · ${_formatDate(item!.date)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
@@ -218,6 +360,10 @@ class _InspirationCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.month}月${date.day}日';
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
@@ -241,12 +387,8 @@ class _SectionTitle extends StatelessWidget {
|
||||
|
||||
/// 3. 热门话题(横向滚动 chips)
|
||||
class _HotTopicsChips extends StatelessWidget {
|
||||
const _HotTopicsChips();
|
||||
|
||||
static const _topics = [
|
||||
'#期末备考', '#读书笔记', '#旅行手账', '#美食日记',
|
||||
'#校园生活', '#自我成长', '#心情日记', '#手写摘抄',
|
||||
];
|
||||
const _HotTopicsChips({required this.topics});
|
||||
final List<TagCount> topics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -255,24 +397,31 @@ class _HotTopicsChips extends StatelessWidget {
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _topics.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.spacing8),
|
||||
itemCount: topics.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
itemBuilder: (context, index) {
|
||||
final isHot = index < 3;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
|
||||
color:
|
||||
isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: isHot ? null : Border.all(color: theme.colorScheme.outlineVariant),
|
||||
border: isHot
|
||||
? null
|
||||
: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_topics[index],
|
||||
'#${topics[index].tag}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isHot ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
|
||||
color: isHot
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -284,14 +433,8 @@ class _HotTopicsChips extends StatelessWidget {
|
||||
|
||||
/// 4. 精选模板(2 列网格)
|
||||
class _FeaturedTemplatesGrid extends StatelessWidget {
|
||||
const _FeaturedTemplatesGrid();
|
||||
|
||||
static const _templates = [
|
||||
('📖', '每日心情日记', '2.3k 人使用', AppColors.secondarySoftLight),
|
||||
('🎓', '期末复习计划', '1.8k 人使用', AppColors.tertiarySoftLight),
|
||||
('🌿', '植物观察日记', '956 人使用', AppColors.roseSoftLight),
|
||||
('✈️', '旅行手账本', '742 人使用', AppColors.secondarySoftLight),
|
||||
];
|
||||
const _FeaturedTemplatesGrid({required this.templates});
|
||||
final List<DiscoverTemplateItem> templates;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -304,13 +447,28 @@ class _FeaturedTemplatesGrid extends StatelessWidget {
|
||||
crossAxisSpacing: DesignTokens.spacing12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _templates.length,
|
||||
itemCount: templates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = _templates[index];
|
||||
return _TemplateCard(emoji: t.$1, name: t.$2, usage: t.$3, bg: t.$4);
|
||||
final t = templates[index];
|
||||
return _TemplateCard(
|
||||
emoji: t.emoji,
|
||||
name: t.name,
|
||||
usage: t.usageText,
|
||||
bg: _categoryColor(t.category),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Color _categoryColor(String? category) {
|
||||
return switch (category) {
|
||||
'日常' => AppColors.secondarySoftLight,
|
||||
'校园' => AppColors.tertiarySoftLight,
|
||||
'心情' => AppColors.roseSoftLight,
|
||||
'旅行' => AppColors.secondarySoftLight,
|
||||
_ => AppColors.secondarySoftLight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateCard extends StatelessWidget {
|
||||
@@ -368,7 +526,9 @@ class _TemplateCard extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
usage,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -380,19 +540,14 @@ class _TemplateCard extends StatelessWidget {
|
||||
|
||||
/// 5. 达人日记(纵向列表)
|
||||
class _ExpertDiariesList extends StatelessWidget {
|
||||
const _ExpertDiariesList();
|
||||
|
||||
static const _experts = [
|
||||
('🌸', '小桃子', '春日漫步手账', '记录春天的每一朵花开...', '342 赞'),
|
||||
('☕', '咖啡少年', '咖啡馆日记', '今天尝试了一家新店...', '218 赞'),
|
||||
('📝', '学习达人', '考研倒计时30天', '坚持就是胜利...', '556 赞'),
|
||||
];
|
||||
const _ExpertDiariesList({required this.diaries});
|
||||
final List<ExpertDiaryItem> diaries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: _experts.map((e) {
|
||||
children: diaries.map((diary) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: DesignTokens.spacing12),
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
@@ -412,7 +567,8 @@ class _ExpertDiariesList extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(e.$1, style: const TextStyle(fontSize: 20)),
|
||||
child: Text(diary.authorEmoji,
|
||||
style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(
|
||||
@@ -422,7 +578,7 @@ class _ExpertDiariesList extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
e.$2,
|
||||
diary.authorName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -432,12 +588,13 @@ class _ExpertDiariesList extends StatelessWidget {
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Text(
|
||||
'·',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.$3,
|
||||
diary.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -452,7 +609,9 @@ class _ExpertDiariesList extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
e.$4,
|
||||
diary.contentPreview.isNotEmpty
|
||||
? diary.contentPreview
|
||||
: '...',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -468,11 +627,14 @@ class _ExpertDiariesList extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.favorite_rounded, size: 14, color: AppColors.rose),
|
||||
Icon(Icons.favorite_rounded,
|
||||
size: 14, color: AppColors.rose),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
e.$5,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
diary.likeText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -99,6 +99,16 @@ class ElementSelected extends EditorEvent {
|
||||
ElementSelected(this.elementId);
|
||||
}
|
||||
|
||||
/// 图层顺序调整方向
|
||||
enum LayerChange { bringToFront, sendToBack }
|
||||
|
||||
/// 调整元素图层顺序
|
||||
class ElementLayerChanged extends EditorEvent {
|
||||
final String elementId;
|
||||
final LayerChange change;
|
||||
ElementLayerChanged({required this.elementId, required this.change});
|
||||
}
|
||||
|
||||
// --- 工具栏事件 ---
|
||||
|
||||
/// 切换活动工具
|
||||
@@ -107,6 +117,12 @@ class ToolChanged extends EditorEvent {
|
||||
ToolChanged(this.tool);
|
||||
}
|
||||
|
||||
/// 再次点击已激活的工具 — 重新弹出设置面板
|
||||
class ToolReactivated extends EditorEvent {
|
||||
final EditorTool tool;
|
||||
ToolReactivated(this.tool);
|
||||
}
|
||||
|
||||
/// 加载已有元素
|
||||
class ElementsLoaded extends EditorEvent {
|
||||
final List<JournalElement> elements;
|
||||
@@ -163,6 +179,28 @@ class TextFormatChanged extends EditorEvent {
|
||||
});
|
||||
}
|
||||
|
||||
/// 加载已有日记数据(从 JournalRepository 读取后原子注入)
|
||||
///
|
||||
/// 与 StrokesLoaded/ElementsLoaded/TagsLoaded 等细粒度事件不同,
|
||||
/// LoadJournal 一次性还原所有日记状态,不触发 auto-save (isDirty=false)。
|
||||
class LoadJournal extends EditorEvent {
|
||||
final String title;
|
||||
final Mood mood;
|
||||
final List<String> tags;
|
||||
final List<Stroke> strokes;
|
||||
final List<JournalElement> elements;
|
||||
final DateTime? lastSavedAt;
|
||||
|
||||
LoadJournal({
|
||||
required this.title,
|
||||
required this.mood,
|
||||
required this.tags,
|
||||
required this.strokes,
|
||||
required this.elements,
|
||||
this.lastSavedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态
|
||||
// ============================================================
|
||||
@@ -205,6 +243,9 @@ class EditorState {
|
||||
final bool isDirty;
|
||||
final DateTime? lastSavedAt;
|
||||
|
||||
// 工具重新激活时间戳(用于驱动面板重新弹出)
|
||||
final int toolReactivatedAt;
|
||||
|
||||
const EditorState({
|
||||
this.strokes = const [],
|
||||
this.redoStack = const [],
|
||||
@@ -221,6 +262,7 @@ class EditorState {
|
||||
this.title = '',
|
||||
this.isDirty = false,
|
||||
this.lastSavedAt,
|
||||
this.toolReactivatedAt = 0,
|
||||
});
|
||||
|
||||
EditorState copyWith({
|
||||
@@ -239,6 +281,7 @@ class EditorState {
|
||||
String? title,
|
||||
bool? isDirty,
|
||||
DateTime? lastSavedAt,
|
||||
int? toolReactivatedAt,
|
||||
}) =>
|
||||
EditorState(
|
||||
strokes: strokes ?? this.strokes,
|
||||
@@ -257,6 +300,7 @@ class EditorState {
|
||||
title: title ?? this.title,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
|
||||
toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt,
|
||||
);
|
||||
|
||||
/// 是否处于手写模式
|
||||
@@ -301,10 +345,15 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
on<ElementResized>(_onElementResized);
|
||||
on<ElementRotated>(_onElementRotated);
|
||||
on<ElementSelected>(_onElementSelected);
|
||||
on<ElementLayerChanged>(_onElementLayerChanged);
|
||||
on<ElementsLoaded>(_onElementsLoaded);
|
||||
|
||||
// 日记加载事件
|
||||
on<LoadJournal>(_onLoadJournal);
|
||||
|
||||
// 工具栏事件
|
||||
on<ToolChanged>(_onToolChanged);
|
||||
on<ToolReactivated>(_onToolReactivated);
|
||||
|
||||
// 标签/心情/标题事件
|
||||
on<TagAdded>(_onTagAdded);
|
||||
@@ -454,10 +503,59 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
));
|
||||
}
|
||||
|
||||
/// 调整元素图层顺序 — 置顶或置底
|
||||
void _onElementLayerChanged(
|
||||
ElementLayerChanged event,
|
||||
Emitter<EditorState> emit,
|
||||
) {
|
||||
final elements = List<JournalElement>.from(state.elements);
|
||||
final index = elements.indexWhere((e) => e.id == event.elementId);
|
||||
if (index == -1) return;
|
||||
|
||||
switch (event.change) {
|
||||
case LayerChange.bringToFront:
|
||||
// 设为最大 zIndex + 1
|
||||
final maxZ = elements.fold<int>(
|
||||
0,
|
||||
(max, e) => e.zIndex > max ? e.zIndex : max,
|
||||
);
|
||||
elements[index] = elements[index].copyWith(zIndex: maxZ + 1);
|
||||
case LayerChange.sendToBack:
|
||||
// 设为最小 zIndex - 1
|
||||
final minZ = elements.fold<int>(
|
||||
0,
|
||||
(min, e) => e.zIndex < min ? e.zIndex : min,
|
||||
);
|
||||
elements[index] = elements[index].copyWith(zIndex: minZ - 1);
|
||||
}
|
||||
|
||||
emit(state.copyWith(elements: elements, isDirty: true));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(elements: event.elements));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 日记加载事件处理
|
||||
// ============================================================
|
||||
|
||||
/// 加载已有日记 — 原子操作,一次性还原所有状态
|
||||
///
|
||||
/// 不触发 auto-save(isDirty=false),因为这是加载而非用户编辑。
|
||||
void _onLoadJournal(LoadJournal event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(
|
||||
title: event.title,
|
||||
selectedMood: event.mood,
|
||||
tags: event.tags,
|
||||
strokes: event.strokes,
|
||||
elements: event.elements,
|
||||
lastSavedAt: event.lastSavedAt,
|
||||
isDirty: false,
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具栏事件处理
|
||||
// ============================================================
|
||||
@@ -470,6 +568,13 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
));
|
||||
}
|
||||
|
||||
void _onToolReactivated(ToolReactivated event, Emitter<EditorState> emit) {
|
||||
// 不改变 activeTool,仅递增时间戳驱动 UI 层重新弹出面板
|
||||
emit(state.copyWith(
|
||||
toolReactivatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 标签/心情/标题事件处理
|
||||
// ============================================================
|
||||
|
||||
@@ -19,9 +19,11 @@ import '../../../data/models/journal_element.dart';
|
||||
import '../../../data/models/journal_entry.dart' show JournalEntry, Mood;
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../../../data/repositories/class_repository.dart';
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../../../data/services/sync_engine.dart';
|
||||
import '../../auth/bloc/auth_bloc.dart';
|
||||
import '../bloc/editor_bloc.dart';
|
||||
import '../widgets/comment_list_sheet.dart';
|
||||
import '../widgets/handwriting_canvas.dart';
|
||||
import '../widgets/stroke_model.dart';
|
||||
import '../widgets/draggable_element.dart';
|
||||
@@ -35,12 +37,26 @@ import '../widgets/brush_panel.dart';
|
||||
import '../widgets/dot_grid_painter.dart';
|
||||
|
||||
/// 手账编辑器页面
|
||||
class EditorPage extends StatelessWidget {
|
||||
class EditorPage extends StatefulWidget {
|
||||
final String? journalId;
|
||||
final String? templateId;
|
||||
|
||||
const EditorPage({super.key, this.journalId, this.templateId});
|
||||
|
||||
@override
|
||||
State<EditorPage> createState() => _EditorPageState();
|
||||
}
|
||||
|
||||
class _EditorPageState extends State<EditorPage> {
|
||||
/// 跟踪已保存的日记 ID — 新建日记首次保存后赋值
|
||||
String? _savedJournalId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_savedJournalId = widget.journalId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 从 Provider 树获取 JournalRepository(IsarJournalRepository)
|
||||
@@ -48,10 +64,6 @@ class EditorPage extends StatelessWidget {
|
||||
// 从 Provider 树获取 SyncEngine(同步到后端)
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
|
||||
// 可变闭包变量:跟踪已保存的日记 ID
|
||||
// 新建日记首次保存后赋值,后续自动更新使用此 ID
|
||||
String? savedJournalId = journalId;
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => EditorBloc(
|
||||
onSave: (state) async {
|
||||
@@ -64,7 +76,7 @@ class EditorPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
await _persistState(
|
||||
repo, state, (id) => savedJournalId = id, savedJournalId,
|
||||
repo, state, (id) => _savedJournalId = id, _savedJournalId,
|
||||
syncEngine: syncEngine,
|
||||
authorId: authorId,
|
||||
);
|
||||
@@ -74,12 +86,12 @@ class EditorPage extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
child: _EditorView(
|
||||
journalId: journalId,
|
||||
templateId: templateId,
|
||||
savedJournalId: savedJournalId,
|
||||
journalId: widget.journalId,
|
||||
templateId: widget.templateId,
|
||||
savedJournalId: _savedJournalId,
|
||||
repo: repo,
|
||||
onSaveComplete: () {
|
||||
_showShareSheetAndNavigate(context, repo, savedJournalId);
|
||||
_showShareSheetAndNavigate(context, repo, _savedJournalId);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -109,43 +121,59 @@ class EditorPage extends StatelessWidget {
|
||||
title: '${now.month}月${now.day}日的日记',
|
||||
date: now,
|
||||
);
|
||||
await repo.createJournal(entry);
|
||||
setId(entry.id);
|
||||
|
||||
// 保存笔画
|
||||
// 保存到仓库(Web=远程API,原生=Isar本地)
|
||||
// 远程仓库返回服务端生成的 ID,必须使用返回值
|
||||
final saved = await repo.createJournal(entry);
|
||||
final journalId = saved.id;
|
||||
setId(journalId);
|
||||
|
||||
// 保存笔画 — 使用 saved.id(与仓库一致)
|
||||
if (state.strokes.isNotEmpty) {
|
||||
await _saveStrokesAsElement(repo, entry.id, state.strokes);
|
||||
await _saveStrokesAsElement(repo, journalId, state.strokes);
|
||||
}
|
||||
|
||||
// 保存其他元素
|
||||
for (final element in state.elements) {
|
||||
await repo.addElement(element.copyWith(journalId: entry.id));
|
||||
await repo.addElement(element.copyWith(journalId: journalId));
|
||||
}
|
||||
|
||||
// 入队 SyncEngine 等待同步到后端
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: entry.id,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: entry.toJson(),
|
||||
version: entry.version,
|
||||
createdAt: now,
|
||||
));
|
||||
// 仅非私密日记入队 SyncEngine 等待同步到后端
|
||||
// 私密日记(is_private=true)仅保存在本地,不上传
|
||||
if (!saved.isPrivate) {
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: journalId,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: saved.toJson(),
|
||||
version: saved.version,
|
||||
createdAt: now,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// --- 更新已有日记 ---
|
||||
final existing = await repo.getJournal(savedJournalId);
|
||||
if (existing != null) {
|
||||
await repo.updateJournal(existing);
|
||||
// 将编辑器当前状态合并到已有日记中
|
||||
final updated = existing.copyWith(
|
||||
title: state.title.isNotEmpty ? state.title : existing.title,
|
||||
mood: state.selectedMood,
|
||||
tags: state.tags.isNotEmpty ? state.tags : existing.tags,
|
||||
updatedAt: now,
|
||||
);
|
||||
await repo.updateJournal(updated);
|
||||
|
||||
// 入队 SyncEngine 等待同步到后端
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: existing.id,
|
||||
type: SyncOperationType.update,
|
||||
endpoint: '/diary/journals/${existing.id}',
|
||||
data: existing.toJson(),
|
||||
version: existing.version,
|
||||
createdAt: now,
|
||||
));
|
||||
// 仅非私密日记入队 SyncEngine
|
||||
if (!updated.isPrivate) {
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.update,
|
||||
endpoint: '/diary/journals/${updated.id}',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: now,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新笔画
|
||||
@@ -196,6 +224,11 @@ class EditorPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 显示分享面板并在用户选择后导航
|
||||
///
|
||||
/// 分享行为:
|
||||
/// - 分享到班级/所有人 → is_private=false + shared_to_class=对应值
|
||||
/// - 仅自己可见 → is_private=true,不上传到后端
|
||||
/// - 首次将私密日记变为公开时,入队 SyncEngine create 操作上传
|
||||
static Future<void> _showShareSheetAndNavigate(
|
||||
BuildContext context,
|
||||
JournalRepository repo,
|
||||
@@ -227,14 +260,41 @@ class EditorPage extends StatelessWidget {
|
||||
classId: userClassId,
|
||||
className: userClassName,
|
||||
onDecision: (shareToClass) async {
|
||||
// 更新日记的 sharedToClass 状态
|
||||
if (savedJournalId != null) {
|
||||
try {
|
||||
final entry = await repo.getJournal(savedJournalId);
|
||||
if (entry != null) {
|
||||
await repo.updateJournal(
|
||||
entry.copyWith(sharedToClass: shareToClass),
|
||||
final wasPrivate = entry.isPrivate;
|
||||
// 分享到班级/所有人 → 取消私密标记
|
||||
final updated = entry.copyWith(
|
||||
isPrivate: false,
|
||||
sharedToClass: shareToClass,
|
||||
);
|
||||
await repo.updateJournal(updated);
|
||||
|
||||
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
|
||||
if (wasPrivate && !updated.isPrivate) {
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
} else if (!updated.isPrivate) {
|
||||
// 已公开日记的分享状态更新
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.update,
|
||||
endpoint: '/diary/journals/${updated.id}',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('更新分享状态失败: $e');
|
||||
@@ -274,56 +334,71 @@ class _EditorView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EditorViewState extends State<_EditorView> {
|
||||
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
|
||||
bool _isViewMode = false;
|
||||
|
||||
/// 保存中状态 — 用于显示"保存中..."指示器
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 当 journalId 非空时,从 Isar 加载已有日记数据
|
||||
// 当 journalId 非空时,进入查看模式
|
||||
_isViewMode = widget.journalId != null;
|
||||
if (widget.journalId != null) {
|
||||
_loadExistingJournal(widget.journalId!);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Isar 加载已有日记的笔画、元素、标签、心情、标题
|
||||
/// 从查看模式切换到编辑模式
|
||||
void _enterEditMode() {
|
||||
setState(() => _isViewMode = false);
|
||||
// 切换到画笔工具,进入编辑状态
|
||||
context.read<EditorBloc>().add(ToolChanged(EditorTool.brush));
|
||||
}
|
||||
|
||||
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
|
||||
Future<void> _loadExistingJournal(String id) async {
|
||||
try {
|
||||
// 加载日记元数据
|
||||
final entry = await widget.repo.getJournal(id);
|
||||
if (entry == null || !mounted) return;
|
||||
|
||||
final bloc = context.read<EditorBloc>();
|
||||
|
||||
// 加载标题和心情
|
||||
bloc.add(TitleChanged(entry.title));
|
||||
bloc.add(MoodChanged(entry.mood));
|
||||
|
||||
// 加载标签
|
||||
if (entry.tags.isNotEmpty) {
|
||||
bloc.add(TagsLoaded(entry.tags));
|
||||
}
|
||||
|
||||
// 加载元素(含笔画)
|
||||
final elements = await widget.repo.getElements(id);
|
||||
if (!mounted) return;
|
||||
|
||||
for (final element in elements) {
|
||||
if (element.elementType == ElementType.handwritingRef) {
|
||||
// 从 handwriting_ref 元素中恢复笔画
|
||||
final strokesData = element.content['strokes'];
|
||||
if (strokesData is List) {
|
||||
final strokes = strokesData
|
||||
.map((s) => Stroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
bloc.add(StrokesLoaded(strokes));
|
||||
}
|
||||
// 从 handwriting_ref 元素中反序列化笔画
|
||||
List<Stroke> strokes = [];
|
||||
final strokesElement = elements
|
||||
.where((e) => e.elementType == ElementType.handwritingRef)
|
||||
.firstOrNull;
|
||||
if (strokesElement != null) {
|
||||
final strokesData = strokesElement.content['strokes'];
|
||||
if (strokesData is List) {
|
||||
strokes = strokesData
|
||||
.map((s) => Stroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载非笔画元素(贴纸/文字/图片)
|
||||
final nonStrokeElements = elements
|
||||
// 过滤掉 handwriting_ref 元素(笔画单独管理)
|
||||
final otherElements = elements
|
||||
.where((e) => e.elementType != ElementType.handwritingRef)
|
||||
.toList();
|
||||
if (nonStrokeElements.isNotEmpty) {
|
||||
bloc.add(ElementsLoaded(nonStrokeElements));
|
||||
|
||||
// 原子加载 — 一次 dispatch 还原所有状态
|
||||
context.read<EditorBloc>().add(LoadJournal(
|
||||
title: entry.title,
|
||||
mood: entry.mood,
|
||||
tags: entry.tags,
|
||||
strokes: strokes,
|
||||
elements: otherElements,
|
||||
lastSavedAt: entry.updatedAt,
|
||||
));
|
||||
|
||||
// 查看模式下使用 select 工具,避免自动弹出画笔面板
|
||||
if (_isViewMode) {
|
||||
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载日记数据失败: $e');
|
||||
@@ -349,26 +424,31 @@ class _EditorViewState extends State<_EditorView> {
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return _EditorStack(state: state, journalId: widget.journalId);
|
||||
return _EditorStack(
|
||||
state: state,
|
||||
journalId: widget.journalId,
|
||||
isViewMode: _isViewMode,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 底部工具栏(自带底部安全区)
|
||||
BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return EditorToolbar(
|
||||
state: state,
|
||||
onEvent: (event) => context.read<EditorBloc>().add(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 底部工具栏 — 仅编辑模式显示
|
||||
if (!_isViewMode)
|
||||
BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return EditorToolbar(
|
||||
state: state,
|
||||
onEvent: (event) => context.read<EditorBloc>().add(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
|
||||
/// 顶部操作栏 — 查看模式: 返回/日期/评语/编辑按钮;编辑模式: 返回/日期/撤销重做/标签/完成
|
||||
Widget _buildTopBar(BuildContext context, EditorState state) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
@@ -411,51 +491,83 @@ class _EditorViewState extends State<_EditorView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 撤销
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Undo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 重做
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Redo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 自动保存状态
|
||||
_buildAutosaveIndicator(state),
|
||||
// 标签按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sell_rounded, size: 18),
|
||||
onPressed: () => _showTagPanel(context, state),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 完成/保存按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _handleSave(context, state),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minimumSize: const Size(0, 32),
|
||||
if (_isViewMode) ...[
|
||||
// 查看模式:评语按钮 + 编辑按钮
|
||||
if (widget.journalId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
|
||||
onPressed: () => _showComments(context),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _enterEditMode,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
child: const Text('完成', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// 编辑模式:撤销/重做/标签/评语/完成
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Undo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Redo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
_buildAutosaveIndicator(state),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sell_rounded, size: 18),
|
||||
onPressed: () => _showTagPanel(context, state),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
if (widget.journalId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
|
||||
onPressed: () => _showComments(context),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _handleSave(context, state),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('完成', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 日期 + 心情条 (40px)
|
||||
_buildDateMoodStrip(context, state),
|
||||
// 日期 + 心情条 (40px) — 仅编辑模式显示
|
||||
if (!_isViewMode) _buildDateMoodStrip(context, state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回处理
|
||||
/// 返回处理 — 有未保存修改时弹出确认
|
||||
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 {
|
||||
@@ -463,9 +575,55 @@ 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 显示评论列表
|
||||
void _showComments(BuildContext context) {
|
||||
final journalId = widget.journalId;
|
||||
if (journalId == null) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => CommentListSheet(
|
||||
journalId: journalId,
|
||||
apiClient: context.read<ApiClient>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化日期显示
|
||||
@@ -476,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),
|
||||
@@ -486,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// 已保存 — 绿色点
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
@@ -595,8 +773,13 @@ class _EditorViewState extends State<_EditorView> {
|
||||
class _EditorStack extends StatefulWidget {
|
||||
final EditorState state;
|
||||
final String? journalId;
|
||||
final bool isViewMode;
|
||||
|
||||
const _EditorStack({required this.state, this.journalId});
|
||||
const _EditorStack({
|
||||
required this.state,
|
||||
this.journalId,
|
||||
this.isViewMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EditorStack> createState() => _EditorStackState();
|
||||
@@ -604,6 +787,7 @@ class _EditorStack extends StatefulWidget {
|
||||
|
||||
class _EditorStackState extends State<_EditorStack> {
|
||||
EditorTool? _lastTool;
|
||||
int _lastReactivatedAt = 0;
|
||||
late final TextEditingController _titleController;
|
||||
|
||||
@override
|
||||
@@ -621,6 +805,13 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
@override
|
||||
void didUpdateWidget(covariant _EditorStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 同步标题输入框(LoadJournal 更新 state.title 时 controller 需要跟随)
|
||||
if (widget.state.title != oldWidget.state.title &&
|
||||
widget.state.title != _titleController.text) {
|
||||
_titleController.text = widget.state.title;
|
||||
}
|
||||
|
||||
final currentTool = widget.state.activeTool;
|
||||
|
||||
// 防止重复弹窗:只在工具切换时触发
|
||||
@@ -647,6 +838,26 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
});
|
||||
}
|
||||
_lastTool = currentTool;
|
||||
|
||||
// 工具重新激活(再次点击已选中的工具)→ 重新弹出面板
|
||||
final reactivatedAt = widget.state.toolReactivatedAt;
|
||||
if (reactivatedAt != _lastReactivatedAt) {
|
||||
_lastReactivatedAt = reactivatedAt;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
switch (currentTool) {
|
||||
case EditorTool.brush:
|
||||
_showBrushPanel();
|
||||
case EditorTool.sticker:
|
||||
_showStickerPicker();
|
||||
case EditorTool.more:
|
||||
_showMoreSheet();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示贴纸选择底部面板
|
||||
@@ -793,6 +1004,7 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _titleController,
|
||||
enabled: !widget.isViewMode,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Quicksand',
|
||||
fontSize: 18,
|
||||
@@ -836,8 +1048,8 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
if (state.elements.isNotEmpty)
|
||||
_buildElementLayer(context, state),
|
||||
|
||||
// 文字输入覆盖层(文字工具激活时显示)
|
||||
if (state.activeTool == EditorTool.text)
|
||||
// 文字输入覆盖层(文字工具激活时显示)— 仅编辑模式
|
||||
if (!widget.isViewMode && state.activeTool == EditorTool.text)
|
||||
TextInputOverlay(
|
||||
onConfirmed: (text, fontSize, fontColor) {
|
||||
final center = Offset(
|
||||
@@ -860,8 +1072,8 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
},
|
||||
),
|
||||
|
||||
// 图片选择覆盖层(图片工具激活时显示)
|
||||
if (state.activeTool == EditorTool.photo)
|
||||
// 图片选择覆盖层(图片工具激活时显示)— 仅编辑模式
|
||||
if (!widget.isViewMode && state.activeTool == EditorTool.photo)
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -881,8 +1093,11 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
),
|
||||
),
|
||||
|
||||
// 空状态提示
|
||||
if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select)
|
||||
// 空状态提示 — 仅编辑模式显示
|
||||
if (!widget.isViewMode &&
|
||||
state.strokes.isEmpty &&
|
||||
state.elements.isEmpty &&
|
||||
state.activeTool == EditorTool.select)
|
||||
_buildEmptyHint(context),
|
||||
],
|
||||
);
|
||||
@@ -934,9 +1149,27 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
positionY: y,
|
||||
));
|
||||
},
|
||||
onResized: (id, w, h) {
|
||||
context.read<EditorBloc>().add(ElementResized(
|
||||
elementId: id,
|
||||
width: w,
|
||||
height: h,
|
||||
));
|
||||
},
|
||||
onRotated: (id, r) {
|
||||
context.read<EditorBloc>().add(ElementRotated(
|
||||
elementId: id,
|
||||
rotation: r,
|
||||
));
|
||||
},
|
||||
onDeleted: (id) {
|
||||
context.read<EditorBloc>().add(ElementRemoved(id));
|
||||
},
|
||||
onLayerChanged: (id, change) {
|
||||
context.read<EditorBloc>().add(
|
||||
ElementLayerChanged(elementId: id, change: change),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
@@ -1008,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ class ActiveStrokePainter extends CustomPainter {
|
||||
|
||||
final path = buildStrokePath(outlinePoints);
|
||||
|
||||
// 橡皮擦实时反馈:绘制半透明灰色,让用户看到擦除范围
|
||||
// 实际擦除在笔画完成后的合成图中通过 BlendMode.dstOut 执行
|
||||
if (brushType == BrushType.eraser) {
|
||||
canvas.drawPath(path, Paint()
|
||||
..color = const Color(0x40808080) // 25% 灰色
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造临时 Stroke 用于获取 Paint
|
||||
final stroke = Stroke(
|
||||
id: '__active__',
|
||||
|
||||
262
app/lib/features/editor/widgets/comment_list_sheet.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
// 评论列表底部面板 — FutureBuilder 拉取老师评语
|
||||
//
|
||||
// 独立 widget,不纳入 EditorBloc。
|
||||
// 打开日记时从后端拉取评论列表展示。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../../class_/bloc/class_bloc.dart' show Comment;
|
||||
|
||||
/// 评论列表底部面板
|
||||
class CommentListSheet extends StatelessWidget {
|
||||
final String journalId;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const CommentListSheet({
|
||||
super.key,
|
||||
required this.journalId,
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.4,
|
||||
minChildSize: 0.2,
|
||||
maxChildSize: 0.7,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 拖拽指示条
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'老师评语',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
_CommentCountBadge(
|
||||
journalId: journalId,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 评论列表
|
||||
Expanded(
|
||||
child: _CommentListFuture(
|
||||
journalId: journalId,
|
||||
apiClient: apiClient,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论数量 Badge
|
||||
class _CommentCountBadge extends StatelessWidget {
|
||||
final String journalId;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const _CommentCountBadge({
|
||||
required this.journalId,
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<dynamic>>(
|
||||
future: _fetchComments(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
final count = snapshot.data!.length;
|
||||
if (count == 0) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _fetchComments() async {
|
||||
try {
|
||||
final response = await apiClient.get('/diary/journals/$journalId/comments');
|
||||
return response.data as List<dynamic>;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论列表 — FutureBuilder 拉取
|
||||
class _CommentListFuture extends StatelessWidget {
|
||||
final String journalId;
|
||||
final ApiClient apiClient;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _CommentListFuture({
|
||||
required this.journalId,
|
||||
required this.apiClient,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Comment>>(
|
||||
future: _fetchComments(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('加载评语失败', style: TextStyle(color: Colors.grey[500])),
|
||||
);
|
||||
}
|
||||
final comments = snapshot.data ?? [];
|
||||
if (comments.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 36, color: Colors.grey[300]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'还没有评语哦',
|
||||
style: TextStyle(color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _CommentTile(comment: comments[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Comment>> _fetchComments() async {
|
||||
try {
|
||||
final response = await apiClient.get('/diary/journals/$journalId/comments');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((json) => Comment(
|
||||
id: json['id'] as String,
|
||||
journalId: json['journal_id'] as String,
|
||||
authorId: json['author_id'] as String,
|
||||
content: json['content'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('加载评论失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条评论卡片
|
||||
class _CommentTile extends StatelessWidget {
|
||||
final Comment comment;
|
||||
|
||||
const _CommentTile({required this.comment});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 老师标签 + 时间
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE07A5F).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'老师',
|
||||
style: TextStyle(fontSize: 11, color: Color(0xFFE07A5F)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(comment.createdAt),
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 评语内容
|
||||
Text(
|
||||
comment.content,
|
||||
style: const TextStyle(fontSize: 14, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/models/journal_element.dart';
|
||||
import '../bloc/editor_bloc.dart' show LayerChange;
|
||||
|
||||
/// 可拖拽日记元素组件
|
||||
class DraggableElement extends StatefulWidget {
|
||||
@@ -19,7 +20,10 @@ class DraggableElement extends StatefulWidget {
|
||||
final bool isSelected;
|
||||
final ValueChanged<String> onTap;
|
||||
final void Function(String id, double x, double y) onMoved;
|
||||
final void Function(String id, double w, double h)? onResized;
|
||||
final void Function(String id, double rotation)? onRotated;
|
||||
final ValueChanged<String> onDeleted;
|
||||
final void Function(String id, LayerChange change)? onLayerChanged;
|
||||
|
||||
const DraggableElement({
|
||||
super.key,
|
||||
@@ -27,7 +31,10 @@ class DraggableElement extends StatefulWidget {
|
||||
this.isSelected = false,
|
||||
required this.onTap,
|
||||
required this.onMoved,
|
||||
this.onResized,
|
||||
this.onRotated,
|
||||
required this.onDeleted,
|
||||
this.onLayerChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -41,6 +48,11 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
late double _height;
|
||||
late double _rotation;
|
||||
|
||||
// Scale 手势状态
|
||||
double _baseWidth = 0;
|
||||
double _baseHeight = 0;
|
||||
double _baseRotation = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -76,15 +88,35 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
child: Transform.rotate(
|
||||
angle: _rotation,
|
||||
child: GestureDetector(
|
||||
// 拖拽移动
|
||||
onPanUpdate: (details) {
|
||||
// 缩放开始 — 记录基准值
|
||||
onScaleStart: (details) {
|
||||
_baseWidth = _width;
|
||||
_baseHeight = _height;
|
||||
_baseRotation = _rotation;
|
||||
},
|
||||
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
|
||||
onScaleUpdate: (details) {
|
||||
setState(() {
|
||||
_x += details.delta.dx;
|
||||
_y += details.delta.dy;
|
||||
// 拖拽(单指和双指都支持)
|
||||
_x += details.focalPointDelta.dx;
|
||||
_y += details.focalPointDelta.dy;
|
||||
|
||||
// 双指缩放 + 旋转
|
||||
if (details.pointerCount >= 2) {
|
||||
final newW = (_baseWidth * details.scale).clamp(40.0, 400.0);
|
||||
final newH = (_baseHeight * details.scale).clamp(40.0, 400.0);
|
||||
_width = newW;
|
||||
_height = newH;
|
||||
_rotation = _baseRotation + details.rotation;
|
||||
}
|
||||
});
|
||||
widget.onMoved(widget.element.id, _x, _y);
|
||||
if (details.pointerCount >= 2) {
|
||||
widget.onResized?.call(widget.element.id, _width, _height);
|
||||
widget.onRotated?.call(widget.element.id, _rotation);
|
||||
}
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
onScaleEnd: (_) {
|
||||
// 确保最终位置已通知
|
||||
widget.onMoved(widget.element.id, _x, _y);
|
||||
},
|
||||
@@ -113,26 +145,41 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
),
|
||||
),
|
||||
|
||||
// 选中时显示删除按钮
|
||||
// 选中时显示操作按钮:图层 + 删除
|
||||
if (widget.isSelected)
|
||||
Positioned(
|
||||
top: -12,
|
||||
right: -12,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onDeleted(widget.element.id),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 置顶
|
||||
_ActionButton(
|
||||
icon: Icons.flip_to_front_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onTap: () => widget.onLayerChanged?.call(
|
||||
widget.element.id,
|
||||
LayerChange.bringToFront,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 置底
|
||||
_ActionButton(
|
||||
icon: Icons.flip_to_back_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onTap: () => widget.onLayerChanged?.call(
|
||||
widget.element.id,
|
||||
LayerChange.sendToBack,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 删除
|
||||
_ActionButton(
|
||||
icon: Icons.close_rounded,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
onTap: () => widget.onDeleted(widget.element.id),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -250,3 +297,32 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中元素的操作按钮(图层/删除)
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 16, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ class EditorToolbar extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onEvent(ToolChanged(tool)),
|
||||
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,
|
||||
|
||||
@@ -134,10 +134,15 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
|
||||
}
|
||||
|
||||
/// 在 build 完成后同步缓存(避免在 build 中触发异步操作)
|
||||
///
|
||||
/// syncStrokes 重建合成图后必须调用 setState,
|
||||
/// 否则 CachedStrokesPainter 不知道缓存已更新,不会触发重绘。
|
||||
void _syncCacheAfterBuild() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_cache.syncStrokes(widget.strokes);
|
||||
_cache.syncStrokes(widget.strokes).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,7 +240,10 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
|
||||
widget.onStrokeCompleted?.call(stroke);
|
||||
|
||||
// 光栅化新笔画到缓存(异步,不阻塞 UI)
|
||||
_cache.addStroke(stroke);
|
||||
// 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘
|
||||
_cache.addStroke(stroke).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
/// 指针取消(如来电打断):丢弃当前笔画。
|
||||
|
||||
@@ -4,20 +4,27 @@
|
||||
// - 温暖友好的文案(面向小学生)
|
||||
// - 分享到班级(有班级时显示)/ 仅自己可见
|
||||
// - 无班级时提示加入班级后可分享
|
||||
// - 分享前自动进行内容安全检查(敏感词过滤)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/services/content_filter_service.dart';
|
||||
|
||||
/// 编辑器完成后的分享选择面板
|
||||
class ShareBottomSheet extends StatelessWidget {
|
||||
final String? classId;
|
||||
final String className;
|
||||
final void Function(bool shareToClass) onDecision;
|
||||
|
||||
/// 用于内容安全检查的文本内容(标题 + 文本元素)
|
||||
final String contentText;
|
||||
|
||||
const ShareBottomSheet({
|
||||
super.key,
|
||||
required this.classId,
|
||||
required this.className,
|
||||
required this.onDecision,
|
||||
this.contentText = '',
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -65,10 +72,7 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
onDecision(true);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onPressed: () => _handleShare(context, shareToClass: true),
|
||||
icon: const Icon(Icons.group),
|
||||
label: Text('分享到 $className'),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -86,10 +90,7 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onDecision(false);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onPressed: () => _handleShare(context, shareToClass: false),
|
||||
icon: const Icon(Icons.lock_outline),
|
||||
label: const Text('仅自己可见'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -116,4 +117,80 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理分享/保存决定
|
||||
void _handleShare(BuildContext context, {required bool shareToClass}) {
|
||||
// 仅在分享到班级时进行内容安全检查
|
||||
if (shareToClass && contentText.isNotEmpty) {
|
||||
final matches = ContentFilterService.checkText(contentText);
|
||||
if (matches.isNotEmpty) {
|
||||
_showContentWarning(context, matches);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 安全或仅自己可见 → 直接执行
|
||||
onDecision(shareToClass);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// 显示内容安全警告对话框
|
||||
void _showContentWarning(
|
||||
BuildContext context,
|
||||
List<SensitiveWordMatch> matches,
|
||||
) {
|
||||
final categories = ContentFilterService.getMatchedCategories(matches);
|
||||
final words = matches.map((m) => ' "${m.word}"').toSet().toList();
|
||||
final wordList = words.take(5).join('、');
|
||||
final categoryList = categories.join('、');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('内容提醒'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('日记中可能包含不太合适分享的内容:'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
wordList,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'涉及:$categoryList',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'建议修改后再分享,或者先保存为仅自己可见。',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('返回修改'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext); // 关闭对话框
|
||||
onDecision(true); // 仍然分享
|
||||
Navigator.pop(context); // 关闭 BottomSheet
|
||||
},
|
||||
child: const Text('仍然分享'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 贴纸选择底部面板
|
||||
//
|
||||
// Phase 1 使用内置 emoji 贴纸(6 类 60 个),后续替换为贴纸包资源。
|
||||
// 分类:心情/动物/自然/食物/学校/装饰
|
||||
// Phase 1 使用内置 emoji 贴纸(6 类 60 个)。
|
||||
// 当贴纸包 API 有数据时自动追加到"更多贴纸"分类。
|
||||
// 后续 Phase 2 将完全迁移到贴纸包资源。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -14,8 +15,8 @@ class StickerPickerSheet extends StatelessWidget {
|
||||
required this.onStickerSelected,
|
||||
});
|
||||
|
||||
// Phase 1 内置贴纸集
|
||||
static const _stickerCategories = <String, List<String>>{
|
||||
// 内置基础贴纸集(Phase 1 保底,保证离线可用)
|
||||
static const _builtinStickers = <String, List<String>>{
|
||||
'心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'],
|
||||
'动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'],
|
||||
'自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '⭐', '🌙', '☀️', '❄️', '🍃'],
|
||||
@@ -24,6 +25,9 @@ class StickerPickerSheet extends StatelessWidget {
|
||||
'装饰': ['💕', '✨', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'],
|
||||
};
|
||||
|
||||
/// 合并后的贴纸分类(预留 API 扩展入口)
|
||||
Map<String, List<String>> get _stickerCategories => _builtinStickers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
|
||||
@@ -20,8 +20,10 @@ import 'stroke_renderer.dart';
|
||||
class _CacheEntry {
|
||||
final ui.Image image;
|
||||
final Stroke stroke;
|
||||
/// BBox 偏移量 — 光栅化时裁剪的起点,合成时用于定位
|
||||
final Offset offset;
|
||||
|
||||
const _CacheEntry({required this.image, required this.stroke});
|
||||
const _CacheEntry({required this.image, required this.stroke, required this.offset});
|
||||
}
|
||||
|
||||
// ===== 光栅化缓存 =====
|
||||
@@ -76,18 +78,22 @@ class StrokeRasterCache {
|
||||
|
||||
/// 添加一条已完成笔画到缓存
|
||||
///
|
||||
/// 光栅化该笔画为 ui.Image,然后增量合成到 compositeImage。
|
||||
/// 光栅化该笔画为 ui.Image(仅 BBox 区域),然后增量合成到 compositeImage。
|
||||
Future<void> addStroke(Stroke stroke) async {
|
||||
if (_canvasSize == Size.zero) return;
|
||||
|
||||
// 光栅化单笔画
|
||||
final image = await _rasterizeStroke(stroke);
|
||||
if (image == null) return;
|
||||
// 光栅化单笔画(BBox 裁剪)
|
||||
final result = await _rasterizeStroke(stroke);
|
||||
if (result == null) return;
|
||||
|
||||
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
|
||||
_cache[stroke.id] = _CacheEntry(
|
||||
image: result.image,
|
||||
stroke: stroke,
|
||||
offset: result.offset,
|
||||
);
|
||||
|
||||
// 增量合成:将新笔画绘制到现有 compositeImage 之上
|
||||
await _compositeIncremental(stroke, image);
|
||||
await _compositeIncremental(stroke, result.image, result.offset);
|
||||
}
|
||||
|
||||
/// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐)
|
||||
@@ -108,9 +114,13 @@ class StrokeRasterCache {
|
||||
final toAdd = currentIds.difference(cachedIds);
|
||||
for (final stroke in strokes) {
|
||||
if (toAdd.contains(stroke.id)) {
|
||||
final image = await _rasterizeStroke(stroke);
|
||||
if (image != null) {
|
||||
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
|
||||
final result = await _rasterizeStroke(stroke);
|
||||
if (result != null) {
|
||||
_cache[stroke.id] = _CacheEntry(
|
||||
image: result.image,
|
||||
stroke: stroke,
|
||||
offset: result.offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,8 +165,12 @@ class StrokeRasterCache {
|
||||
|
||||
// ===== 光栅化 =====
|
||||
|
||||
/// 将单条笔画光栅化为 ui.Image
|
||||
Future<ui.Image?> _rasterizeStroke(Stroke stroke) async {
|
||||
/// 将单条笔画光栅化为 ui.Image — 仅光栅化 BBox 区域(性能优化 8b-M02)
|
||||
///
|
||||
/// 计算笔画的包围盒 (bounding box),仅对该区域光栅化,
|
||||
/// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。
|
||||
/// 返回 null 表示笔画无有效点。
|
||||
Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async {
|
||||
final outlinePoints = pointsToOutline(
|
||||
stroke.points,
|
||||
stroke.brushType,
|
||||
@@ -165,16 +179,38 @@ class StrokeRasterCache {
|
||||
);
|
||||
if (outlinePoints.isEmpty) return null;
|
||||
|
||||
// 计算笔画包围盒
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||
for (final p in outlinePoints) {
|
||||
if (p.dx < minX) minX = p.dx;
|
||||
if (p.dy < minY) minY = p.dy;
|
||||
if (p.dx > maxX) maxX = p.dx;
|
||||
if (p.dy > maxY) maxY = p.dy;
|
||||
}
|
||||
|
||||
// 添加边距(抗锯齿 + 笔触溢出)
|
||||
const padding = 4.0;
|
||||
final bboxLeft = (minX - padding).clamp(0.0, _canvasSize.width);
|
||||
final bboxTop = (minY - padding).clamp(0.0, _canvasSize.height);
|
||||
final bboxRight = (maxX + padding).clamp(0.0, _canvasSize.width);
|
||||
final bboxBottom = (maxY + padding).clamp(0.0, _canvasSize.height);
|
||||
final bboxWidth = (bboxRight - bboxLeft).clamp(1.0, 4096.0);
|
||||
final bboxHeight = (bboxBottom - bboxTop).clamp(1.0, 4096.0);
|
||||
|
||||
final path = buildStrokePath(outlinePoints);
|
||||
final paint = createPaintForStroke(stroke);
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
// 平移坐标系,使 BBox 左上角对齐 (0, 0)
|
||||
canvas.translate(-bboxLeft, -bboxTop);
|
||||
|
||||
// 橡皮擦需要 saveLayer 保护,避免穿透
|
||||
if (stroke.brushType == BrushType.eraser) {
|
||||
canvas.saveLayer(
|
||||
Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height),
|
||||
Rect.fromLTWH(0, 0, bboxWidth, bboxHeight),
|
||||
Paint(),
|
||||
);
|
||||
}
|
||||
@@ -186,16 +222,20 @@ class StrokeRasterCache {
|
||||
}
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
return picture.toImage(
|
||||
_canvasSize.width.toInt().clamp(1, 4096),
|
||||
_canvasSize.height.toInt().clamp(1, 4096),
|
||||
final image = await picture.toImage(
|
||||
bboxWidth.toInt().clamp(1, 4096),
|
||||
bboxHeight.toInt().clamp(1, 4096),
|
||||
);
|
||||
|
||||
return (image: image, offset: Offset(bboxLeft, bboxTop));
|
||||
}
|
||||
|
||||
// ===== 合成 =====
|
||||
|
||||
/// 增量合成:将新笔画图像绘制到现有 compositeImage 上
|
||||
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage) async {
|
||||
///
|
||||
/// strokeImage 是 BBox 裁剪后的图像,offset 是其原始位置偏移。
|
||||
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage, Offset offset) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
@@ -210,15 +250,15 @@ class StrokeRasterCache {
|
||||
canvas.drawImage(_compositeImage!, Offset.zero, Paint());
|
||||
}
|
||||
|
||||
// 再绘制新笔画(橡皮擦用 dstOut)
|
||||
// 再绘制新笔画(橡皮擦用 dstOut),使用 BBox offset 定位
|
||||
if (stroke.brushType == BrushType.eraser) {
|
||||
canvas.drawImage(
|
||||
strokeImage,
|
||||
Offset.zero,
|
||||
offset,
|
||||
Paint()..blendMode = BlendMode.dstOut,
|
||||
);
|
||||
} else {
|
||||
canvas.drawImage(strokeImage, Offset.zero, Paint());
|
||||
canvas.drawImage(strokeImage, offset, Paint());
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
@@ -232,14 +272,10 @@ class StrokeRasterCache {
|
||||
_canvasSize.height.toInt().clamp(1, 4096),
|
||||
);
|
||||
|
||||
// 单笔画图像已合成,释放以节省 GPU 内存
|
||||
strokeImage.dispose();
|
||||
// 注意:从 _cache 中移除该条目的 image(保留 stroke 引用以备重建)
|
||||
// 不,缓存保留 image 引用以便撤销时重建。增量合成时释放 strokeImage
|
||||
// 但 _cache 仍持有引用,所以需要用另一个方式
|
||||
// 实际上增量合成后可以释放单笔画图像——合成图已包含其内容
|
||||
// 但撤销时需要重建,需要原始数据。保留 Stroke 数据,释放 image。
|
||||
// 如果后续撤销,syncStrokes 会重新光栅化。
|
||||
// 注意:不在此处 dispose strokeImage!
|
||||
// _cache 和此方法持有同一 image 引用,提前 dispose 会导致
|
||||
// syncStrokes/clear/dispose 时 use-after-dispose(审计 ID: 8b-R01)。
|
||||
// 单笔画 image 由缓存统一管理生命周期(移除/清除/销毁时释放)。
|
||||
|
||||
_layerVersion++;
|
||||
}
|
||||
@@ -265,8 +301,7 @@ class StrokeRasterCache {
|
||||
);
|
||||
|
||||
// 按笔画顺序重新绘制所有单笔画
|
||||
// 注意:增量合成时已释放了单笔画 image,这里需要重新光栅化
|
||||
// 所以全量重建时,直接用 stroke 数据重绘路径(不依赖缓存的 image)
|
||||
// 直接从 stroke 数据重绘路径,确保与增量合成结果一致
|
||||
for (final entry in _cache.values) {
|
||||
final stroke = entry.stroke;
|
||||
final outlinePoints = pointsToOutline(
|
||||
|
||||
@@ -39,19 +39,21 @@ class _BrushConfig {
|
||||
|
||||
/// 各画笔的渲染参数。
|
||||
const Map<BrushType, _BrushConfig> _brushConfigs = {
|
||||
/// 钢笔:中等粗细,强压感变化,模拟毛笔效果
|
||||
/// 钢笔:粗壮平滑,模拟签字笔效果
|
||||
BrushType.pen: _BrushConfig(
|
||||
size: 8,
|
||||
thinning: 0.7,
|
||||
smoothing: 0.5,
|
||||
size: 10,
|
||||
thinning: 0.65,
|
||||
smoothing: 0.65,
|
||||
streamline: 0.6,
|
||||
simulatePressure: true,
|
||||
),
|
||||
|
||||
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
|
||||
/// 铅笔:纤细有质感,保留书写抖动
|
||||
BrushType.pencil: _BrushConfig(
|
||||
size: 4,
|
||||
thinning: 0.3,
|
||||
smoothing: 0.7,
|
||||
size: 3,
|
||||
thinning: 0.4,
|
||||
smoothing: 0.35,
|
||||
streamline: 0.3,
|
||||
simulatePressure: true,
|
||||
),
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// 标签面板 -- 底部抽屉
|
||||
// 支持添加/移除自定义标签 + 推荐标签快捷选择
|
||||
// 推荐标签从用户历史标签动态推导,无数据时使用默认推荐
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
|
||||
/// 标签面板 -- 底部抽屉
|
||||
class TagPanel extends StatefulWidget {
|
||||
@@ -26,15 +29,37 @@ class _TagPanelState extends State<TagPanel> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
static const _suggestedTags = [
|
||||
'日常', '学习', '读书', '心情', '学校', '旅行',
|
||||
'美食', '运动', '音乐', '梦想',
|
||||
];
|
||||
/// 推荐标签 — 动态推导
|
||||
List<String> _suggestedTags = ['日常', '学习', '读书', '心情', '学校', '旅行', '美食', '运动'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.requestFocus();
|
||||
_deriveSuggestedTags();
|
||||
}
|
||||
|
||||
/// 从用户历史日记标签推导推荐标签
|
||||
Future<void> _deriveSuggestedTags() async {
|
||||
try {
|
||||
final repo = context.read<JournalRepository>();
|
||||
final journals = await repo.getJournals();
|
||||
final tagFreq = <String, int>{};
|
||||
for (final j in journals) {
|
||||
for (final tag in j.tags) {
|
||||
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
final sorted = tagFreq.keys.toList()
|
||||
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
|
||||
if (sorted.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
_suggestedTags = sorted.take(10).toList();
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 保持默认值
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// 首页 BLoC — 加载最近日记和心情概览
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
@@ -77,12 +80,24 @@ final class HomeError extends HomeState {
|
||||
|
||||
class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
final JournalRepository _journalRepo;
|
||||
StreamSubscription<void>? _changeSubscription;
|
||||
|
||||
HomeBloc({required JournalRepository journalRepository})
|
||||
: _journalRepo = journalRepository,
|
||||
super(const HomeInitial()) {
|
||||
on<HomeLoadData>(_onLoadData);
|
||||
on<HomeRefresh>(_onRefresh);
|
||||
|
||||
// 监听日记变更,自动刷新首页数据
|
||||
_changeSubscription = _journalRepo.onJournalChanged.listen((_) {
|
||||
add(const HomeRefresh());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_changeSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _onLoadData(
|
||||
@@ -115,9 +130,14 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
// 推算连续天数
|
||||
final streakDays = _calculateStreak(journals);
|
||||
|
||||
// 本月日记数(spec §3.4 quick-stats)
|
||||
final monthCount = journals.where((j) =>
|
||||
j.date.year == today.year && j.date.month == today.month).length;
|
||||
// 本月日记数 — 使用日期范围查询,不受分页限制(修复 8b-D03)
|
||||
final monthStart = DateTime(today.year, today.month, 1);
|
||||
final monthEnd = DateTime(today.year, today.month + 1, 1);
|
||||
final monthJournals = await _journalRepo.getJournals(
|
||||
dateFrom: monthStart,
|
||||
dateTo: monthEnd,
|
||||
);
|
||||
final monthCount = monthJournals.length;
|
||||
|
||||
// 总日记数 — 使用仓库计数方法(不受分页限制)
|
||||
final totalCount = await _journalRepo.getJournalCount();
|
||||
@@ -141,6 +161,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
todayWeather: todayWeather,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('HomeBloc._onLoadData 失败: $e');
|
||||
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import '../../../core/theme/app_shadows.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../../../data/repositories/class_repository.dart';
|
||||
import '../../../data/services/sync_engine.dart';
|
||||
import '../../auth/bloc/auth_bloc.dart';
|
||||
import '../../editor/widgets/share_bottom_sheet.dart';
|
||||
import '../bloc/home_bloc.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
@@ -87,7 +91,10 @@ class _HomeView extends StatelessWidget {
|
||||
children: [
|
||||
_GreetingHeader(
|
||||
greeting: greeting,
|
||||
username: '小暖',
|
||||
username: context.select<AuthBloc, String>((bloc) {
|
||||
final s = bloc.state;
|
||||
return s is Authenticated ? s.user.displayLabel : '同学';
|
||||
}),
|
||||
dateText: dateText,
|
||||
onSearchTap: () => context.push('/search'),
|
||||
),
|
||||
@@ -659,9 +666,22 @@ class _JournalCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${journal.date.month}月${journal.date.day}日',
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${journal.date.month}月${journal.date.day}日',
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// 可见性标签
|
||||
_VisibilityBadge(
|
||||
isPrivate: journal.isPrivate,
|
||||
sharedToClass: journal.sharedToClass,
|
||||
onTap: journal.isPrivate
|
||||
? () => _sharePrivateJournal(context, journal)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
@@ -702,6 +722,152 @@ class _JournalCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端
|
||||
Future<void> _sharePrivateJournal(BuildContext context, JournalEntry entry) async {
|
||||
String? userClassId;
|
||||
String userClassName = '我的班级';
|
||||
|
||||
try {
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
if (authState is Authenticated) {
|
||||
try {
|
||||
final classRepo = context.read<ClassRepository>();
|
||||
final classes = await classRepo.getMyClasses();
|
||||
if (classes.isNotEmpty) {
|
||||
userClassId = classes.first.id;
|
||||
userClassName = classes.first.name;
|
||||
}
|
||||
} catch (_) {
|
||||
// 没有班级信息,使用默认值
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) => ShareBottomSheet(
|
||||
classId: userClassId,
|
||||
className: userClassName,
|
||||
onDecision: (shareToClass) async {
|
||||
try {
|
||||
final repo = context.read<JournalRepository>();
|
||||
// 将私密日记变为公开
|
||||
final updated = entry.copyWith(
|
||||
isPrivate: false,
|
||||
sharedToClass: shareToClass,
|
||||
);
|
||||
await repo.updateJournal(updated);
|
||||
|
||||
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
|
||||
// 刷新首页列表
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
} catch (e) {
|
||||
debugPrint('分享日记失败: $e');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 可见性标签 — 显示日记的可见性状态
|
||||
///
|
||||
/// - 私密:🔒 仅自己可见(可点击分享)
|
||||
/// - 分享到班级:🏫 班级可见
|
||||
/// - 公开:🌐 所有人可见
|
||||
class _VisibilityBadge extends StatelessWidget {
|
||||
const _VisibilityBadge({
|
||||
required this.isPrivate,
|
||||
required this.sharedToClass,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final bool isPrivate;
|
||||
final bool sharedToClass;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isPrivate) {
|
||||
// 私密日记 — 显示锁定图标,可点击分享
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiarySoftLight,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.lock_outline, size: 12, color: Color(0xFFB8860B)),
|
||||
SizedBox(width: 3),
|
||||
Text(
|
||||
'仅自己可见',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFFB8860B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (sharedToClass) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary.withValues(alpha: 0.15),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.groups, size: 12, color: AppColors.secondary),
|
||||
SizedBox(width: 3),
|
||||
Text(
|
||||
'班级可见',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.public, size: 12, color: AppColors.accent),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'公开',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyJournalState extends StatelessWidget {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 心情 BLoC — 通过 API 加载心情统计数据
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
@@ -138,6 +139,7 @@ class MoodBloc extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('MoodBloc._loadStats 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载统计数据失败',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError
|
||||
// API: /diary/parent/* 端点
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/remote/api_client.dart';
|
||||
@@ -40,6 +41,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
.toList();
|
||||
emit(ParentChildrenLoaded(children));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onLoadChildren 失败: $e');
|
||||
emit(const ParentError('加载孩子列表失败'));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +59,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
// 绑定成功后重新加载列表
|
||||
add(const ParentLoadChildren());
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onBindChild 失败: $e');
|
||||
emit(const ParentError('绑定失败,请检查孩子 ID'));
|
||||
}
|
||||
}
|
||||
@@ -83,6 +86,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
journals: items.cast<Map<String, dynamic>>(),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onViewJournals 失败: $e');
|
||||
emit(const ParentError('加载日记失败'));
|
||||
}
|
||||
}
|
||||
@@ -103,6 +107,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
data: response.data as Map<String, dynamic>,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onExportData 失败: $e');
|
||||
emit(const ParentError('导出失败'));
|
||||
}
|
||||
}
|
||||
@@ -119,6 +124,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
});
|
||||
emit(ParentDataDeleted(event.childId));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onDeleteData 失败: $e');
|
||||
emit(const ParentError('删除失败'));
|
||||
}
|
||||
}
|
||||
@@ -134,6 +140,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
});
|
||||
add(const ParentLoadChildren());
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onUnbindChild 失败: $e');
|
||||
emit(const ParentError('解绑失败'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
// - 数据删除 → 确认对话框 → ParentDeleteData
|
||||
// 保留 PIPL 合规提示。
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -16,6 +18,7 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/utils/file_download.dart';
|
||||
import '../bloc/parent_bloc.dart';
|
||||
|
||||
/// 家长中心页面 — 家长查看孩子日记和统计数据
|
||||
@@ -929,7 +932,7 @@ class _JournalCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出数据视图 — 展示导出结果
|
||||
/// 导出数据视图 — 展示导出结果 + 下载按钮
|
||||
class _ExportDataView extends StatelessWidget {
|
||||
const _ExportDataView({
|
||||
required this.childId,
|
||||
@@ -1037,7 +1040,27 @@ class _ExportDataView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 提示
|
||||
// 下载按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _handleDownload(context),
|
||||
icon: const Icon(Icons.download_rounded, size: 20),
|
||||
label: const Text('下载 JSON 文件'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.secondary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// JSON 预览(折叠面板)
|
||||
_JsonPreviewCard(data: data),
|
||||
const SizedBox(height: 16),
|
||||
// PIPL 提示
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -1048,14 +1071,15 @@ class _ExportDataView extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
Icons.shield_outlined,
|
||||
size: 18,
|
||||
color: AppColors.tertiary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'数据已生成。Phase 1 展示 JSON 预览,后续版本将支持文件下载。',
|
||||
'根据《个人信息保护法》,您有权导出孩子的全部个人数据。'
|
||||
'导出数据仅供个人查阅,请妥善保管。',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.fg2Light,
|
||||
),
|
||||
@@ -1071,6 +1095,103 @@ class _ExportDataView extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 触发文件下载
|
||||
Future<void> _handleDownload(BuildContext context) async {
|
||||
final filename = '暖记_数据导出_${DateFormat('yyyy-MM-dd').format(DateTime.now())}.json';
|
||||
final success = await downloadJsonFile(data, filename);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? '文件已开始下载' : '下载失败,请重试'),
|
||||
backgroundColor: success ? AppColors.success : AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 预览折叠卡片
|
||||
class _JsonPreviewCard extends StatefulWidget {
|
||||
const _JsonPreviewCard({required this.data});
|
||||
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
@override
|
||||
State<_JsonPreviewCard> createState() => _JsonPreviewCardState();
|
||||
}
|
||||
|
||||
class _JsonPreviewCardState extends State<_JsonPreviewCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: const Radius.circular(12),
|
||||
bottom: _expanded ? Radius.zero : const Radius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.data_object,
|
||||
size: 20,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'JSON 数据预览',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const Divider(height: 1),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
const JsonEncoder.withIndent(' ').convert(widget.data),
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出信息行
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
|
||||
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/user.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import 'package:nuanji_app/features/achievement/bloc/achievement_bloc.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
/// 个人中心页面
|
||||
class ProfilePage extends StatelessWidget {
|
||||
@@ -60,7 +62,10 @@ class ProfilePage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('😊', style: TextStyle(fontSize: 36)),
|
||||
child: Text(
|
||||
displayName.isNotEmpty ? displayName[0] : '😊',
|
||||
style: const TextStyle(fontSize: 36, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 用户名
|
||||
@@ -91,7 +96,7 @@ class ProfilePage extends StatelessWidget {
|
||||
_LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ---- 成就徽章 ----
|
||||
// ---- 成就徽章(动态加载) ----
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith(
|
||||
@@ -101,21 +106,11 @@ class ProfilePage extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
_BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false),
|
||||
const SizedBox(width: 12),
|
||||
_BadgeItem(emoji: '🔥', name: '七日连续', bgColor: tertiarySoft, locked: false),
|
||||
const SizedBox(width: 12),
|
||||
_BadgeItem(emoji: '🎨', name: '装饰达人', bgColor: roseSoft, locked: false),
|
||||
const SizedBox(width: 12),
|
||||
_BadgeItem(emoji: '🌟', name: '人气之星', bgColor: secondarySoft, locked: true),
|
||||
const SizedBox(width: 12),
|
||||
_BadgeItem(emoji: '🏆', name: '写作高手', bgColor: accentSoft, locked: true),
|
||||
const SizedBox(width: 12),
|
||||
_BadgeItem(emoji: '💎', name: '全能王', bgColor: tertiarySoft, locked: true),
|
||||
],
|
||||
child: _AchievementBadges(
|
||||
accentSoft: accentSoft,
|
||||
tertiarySoft: tertiarySoft,
|
||||
roseSoft: roseSoft,
|
||||
secondarySoft: secondarySoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
@@ -436,3 +431,73 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成就徽章动态组件 — 从 AchievementBloc 加载真实数据
|
||||
class _AchievementBadges extends StatefulWidget {
|
||||
const _AchievementBadges({
|
||||
required this.accentSoft,
|
||||
required this.tertiarySoft,
|
||||
required this.roseSoft,
|
||||
required this.secondarySoft,
|
||||
});
|
||||
|
||||
final Color accentSoft;
|
||||
final Color tertiarySoft;
|
||||
final Color roseSoft;
|
||||
final Color secondarySoft;
|
||||
|
||||
@override
|
||||
State<_AchievementBadges> createState() => _AchievementBadgesState();
|
||||
}
|
||||
|
||||
class _AchievementBadgesState extends State<_AchievementBadges> {
|
||||
late final AchievementBloc _bloc;
|
||||
List<Achievement> _achievements = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = AchievementBloc(api: context.read<ApiClient>());
|
||||
_bloc.load();
|
||||
_bloc.addListener(_onUpdate);
|
||||
}
|
||||
|
||||
void _onUpdate() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_achievements = _bloc.state.achievements;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.removeListener(_onUpdate);
|
||||
_bloc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_achievements.isEmpty) {
|
||||
return const Center(child: Text('暂无成就', style: TextStyle(fontSize: 13)));
|
||||
}
|
||||
|
||||
final bgColors = [widget.accentSoft, widget.tertiarySoft, widget.roseSoft, widget.secondarySoft];
|
||||
|
||||
return ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _achievements.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final a = _achievements[index];
|
||||
return _BadgeItem(
|
||||
emoji: a.icon ?? '🏆',
|
||||
name: a.name,
|
||||
bgColor: bgColors[index % bgColors.length],
|
||||
locked: !a.isUnlocked,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
|
||||
// 支持关键词搜索、标签筛选、心情筛选、结果分类 tab。
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
@@ -50,6 +51,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
searchHistory: List.unmodifiable(_searchHistory),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('SearchBloc._onSearchByMood 失败: $e');
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
@@ -73,6 +75,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
searchHistory: List.unmodifiable(_searchHistory),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('SearchBloc._onSearchByTag 失败: $e');
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
searchHistory: List.unmodifiable(_searchHistory),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('SearchBloc._onSearchByKeyword 失败: $e');
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/utils/mood_utils.dart';
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../../templates/bloc/template_bloc.dart';
|
||||
import '../bloc/search_bloc.dart';
|
||||
|
||||
/// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类
|
||||
@@ -31,18 +34,42 @@ class _SearchPageState extends State<SearchPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _searchFocusNode = FocusNode();
|
||||
|
||||
// 热门搜索占位数据
|
||||
final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸'];
|
||||
// 热门搜索 — 从用户日记标签动态推导,无数据时使用默认推荐
|
||||
List<String> _hotSearches = ['日常', '学校', '旅行', '心情', '读书', '手账'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_deriveHotSearches();
|
||||
// 自动弹出键盘
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_searchFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
/// 从日记标签频率推导热门搜索
|
||||
Future<void> _deriveHotSearches() async {
|
||||
try {
|
||||
final repo = context.read<JournalRepository>();
|
||||
final journals = await repo.getJournals();
|
||||
final tagFreq = <String, int>{};
|
||||
for (final j in journals) {
|
||||
for (final tag in j.tags) {
|
||||
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
final sorted = tagFreq.keys.toList()
|
||||
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
|
||||
if (sorted.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
_hotSearches = sorted.take(8).toList();
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 保持默认值
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -490,79 +517,10 @@ class _SearchPageState extends State<SearchPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 6E: 模板结果(占位) =====
|
||||
// ===== 6E: 模板结果(动态加载) =====
|
||||
|
||||
Widget _buildTemplateResults(ThemeData theme, bool isDark) {
|
||||
// Phase 1 占位 — 模板功能未实现
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
itemCount: 4,
|
||||
itemBuilder: (context, index) {
|
||||
final gradients = [
|
||||
const [AppColors.accent, AppColors.tertiary],
|
||||
const [AppColors.secondary, AppColors.tertiary],
|
||||
const [AppColors.rose, AppColors.accent],
|
||||
const [AppColors.tertiary, AppColors.secondary],
|
||||
];
|
||||
final labels = ['每日心情', '旅行手账', '读书笔记', '日常记录'];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: gradients[index],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 装饰圆
|
||||
Positioned(
|
||||
right: -10,
|
||||
bottom: -10,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
labels[index],
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'即将上线',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return _TemplateSearchGrid(theme: theme, isDark: isDark);
|
||||
}
|
||||
|
||||
// ===== 6E: 标签结果 =====
|
||||
@@ -781,3 +739,124 @@ extension _PadAll on Widget {
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索页模板结果 — 从 TemplateBloc 动态加载
|
||||
class _TemplateSearchGrid extends StatefulWidget {
|
||||
const _TemplateSearchGrid({required this.theme, required this.isDark});
|
||||
final ThemeData theme;
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
State<_TemplateSearchGrid> createState() => _TemplateSearchGridState();
|
||||
}
|
||||
|
||||
class _TemplateSearchGridState extends State<_TemplateSearchGrid> {
|
||||
late final TemplateBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = TemplateBloc(api: context.read<ApiClient>());
|
||||
_bloc.load();
|
||||
_bloc.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _gradients = [
|
||||
[AppColors.accent, AppColors.tertiary],
|
||||
[AppColors.secondary, AppColors.tertiary],
|
||||
[AppColors.rose, AppColors.accent],
|
||||
[AppColors.tertiary, AppColors.secondary],
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final templates = _bloc.state.templates;
|
||||
|
||||
if (_bloc.state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (templates.isEmpty) {
|
||||
return Center(
|
||||
child: Text('暂无模板', style: widget.theme.textTheme.bodyMedium?.copyWith(
|
||||
color: widget.isDark ? AppColors.mutedDark : AppColors.mutedLight,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
itemCount: templates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = templates[index];
|
||||
final colors = _gradients[index % _gradients.length];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -10,
|
||||
bottom: -10,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
t.emoji,
|
||||
style: const TextStyle(fontSize: 28),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.name,
|
||||
style: widget.theme.textTheme.titleSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.isFree ? '免费模板' : '精品模板',
|
||||
style: widget.theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 贴纸 BLoC — 通过 API 加载贴纸包和贴纸数据
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
@@ -168,6 +169,7 @@ class StickerBloc extends ChangeNotifier {
|
||||
|
||||
_state = _state.copyWith(isLoading: false, packs: packs);
|
||||
} catch (e) {
|
||||
debugPrint('StickerBloc._fetchPacks 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载贴纸包失败',
|
||||
@@ -194,6 +196,7 @@ class StickerBloc extends ChangeNotifier {
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
debugPrint('StickerBloc.fetchStickersInPack 失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/// 贴纸库页面 — 分类浏览贴纸包
|
||||
@@ -19,10 +21,19 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
||||
late final StickerBloc _bloc;
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
/// 设计规格中的 8 个分类
|
||||
static const _specCategories = [
|
||||
'推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带',
|
||||
];
|
||||
/// 默认分类 — 从 API 数据动态补充
|
||||
static const _defaultCategories = ['推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带'];
|
||||
|
||||
List<String> get _categories {
|
||||
final apiCategories = _bloc.state.packs
|
||||
.map((p) => p.category)
|
||||
.whereType<String>()
|
||||
.toSet()
|
||||
.toList();
|
||||
if (apiCategories.isEmpty) return _defaultCategories;
|
||||
// 合并:推荐 + API 返回的分类
|
||||
return ['推荐', ...apiCategories];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: _specCategories.map((cat) {
|
||||
children: _categories.map((cat) {
|
||||
final isSelected = cat == state.selectedCategory ||
|
||||
(cat == '推荐' && state.selectedCategory == '全部');
|
||||
return Padding(
|
||||
@@ -148,19 +151,22 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ---- 精选贴纸包卡片 ----
|
||||
if (state.selectedCategory == '全部')
|
||||
// ---- 精选贴纸包卡片(动态数据) ----
|
||||
if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: const _FeaturedPackCard(),
|
||||
child: _FeaturedPackCard(pack: state.filteredPacks.first),
|
||||
),
|
||||
if (state.selectedCategory == '全部')
|
||||
if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty)
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ---- 贴纸包网格 ----
|
||||
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:
|
||||
@@ -188,9 +194,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签
|
||||
/// 精选贴纸包卡片 — 渐变背景 + 动态数据
|
||||
class _FeaturedPackCard extends StatelessWidget {
|
||||
const _FeaturedPackCard();
|
||||
const _FeaturedPackCard({required this.pack});
|
||||
final StickerPack pack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -198,7 +205,7 @@ class _FeaturedPackCard extends StatelessWidget {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('打开精选贴纸包: 治愈小动物')),
|
||||
SnackBar(content: Text('打开精选贴纸包: ${pack.name}')),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
@@ -214,7 +221,6 @@ class _FeaturedPackCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// emoji 图标区域
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
@@ -223,30 +229,38 @@ class _FeaturedPackCard extends StatelessWidget {
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('🧸', style: TextStyle(fontSize: 36)),
|
||||
child: Text(pack.displayCover, style: const TextStyle(fontSize: 36)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('治愈小动物', style: theme.textTheme.titleMedium?.copyWith(
|
||||
Text(pack.name, style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700, color: Colors.white,
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
Text('超可爱的手绘小动物贴纸', style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
)),
|
||||
Text(
|
||||
pack.description ?? '${pack.stickerCount} 张精选贴纸',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary,
|
||||
color: pack.isFree ? AppColors.secondary : AppColors.rose,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Text('限时免费', style: TextStyle(
|
||||
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
|
||||
)),
|
||||
child: Text(
|
||||
pack.isFree ? '免费' : '精品',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -67,11 +67,7 @@ class _TeacherView extends StatelessWidget {
|
||||
iconColor: AppColors.tertiary,
|
||||
title: '班级码管理',
|
||||
subtitle: '查看和重置班级码',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('班级码: a1b2c3')),
|
||||
);
|
||||
},
|
||||
onTap: () => _showClassCodes(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -159,6 +155,40 @@ class _TeacherView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showClassCodes(BuildContext context) {
|
||||
final classState = context.read<ClassBloc>().state;
|
||||
final classes = classState is ClassListLoaded ? classState.classes : <SchoolClass>[];
|
||||
|
||||
if (classes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请先创建班级')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('班级码管理'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: classes.map((c) => ListTile(
|
||||
leading: const Icon(Icons.qr_code, color: AppColors.tertiary),
|
||||
title: Text(c.name),
|
||||
subtitle: Text('班级码: ${c.classCode} · ${c.memberCount} 人'),
|
||||
dense: true,
|
||||
)).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAssignTopicDialog(BuildContext context) {
|
||||
final titleController = TextEditingController();
|
||||
final descController = TextEditingController();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 模板 BLoC — 通过 API 加载模板列表
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
@@ -125,6 +126,7 @@ class TemplateBloc extends ChangeNotifier {
|
||||
|
||||
_state = _state.copyWith(isLoading: false, templates: templates);
|
||||
} catch (e) {
|
||||
debugPrint('TemplateBloc._fetchTemplates 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载模板列表失败',
|
||||
|
||||
@@ -293,13 +293,22 @@ class _TemplateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 标签
|
||||
// 标签(从模板 category 动态生成)
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_TagPill(label: '学生专属', bgColor: secondarySoft, textColor: AppColors.secondary),
|
||||
_TagPill(label: '简约', bgColor: tertiarySoft, textColor: AppColors.tertiary),
|
||||
if (template.category != null && template.category!.isNotEmpty)
|
||||
_TagPill(
|
||||
label: template.category!,
|
||||
bgColor: secondarySoft,
|
||||
textColor: AppColors.secondary,
|
||||
),
|
||||
_TagPill(
|
||||
label: template.isFree ? '免费' : '精品',
|
||||
bgColor: template.isFree ? tertiarySoft : AppColors.roseSoftLight,
|
||||
textColor: template.isFree ? AppColors.tertiary : AppColors.rose,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
95
app/lib/widgets/empty_state_widget.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
72
app/lib/widgets/error_state_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
app/lib/widgets/offline_banner.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
156
app/lib/widgets/skeleton_loading.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -406,54 +406,6 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
||||
@@ -30,8 +30,11 @@ dependencies:
|
||||
# 连接检测
|
||||
connectivity_plus: ^6.1.0
|
||||
|
||||
# 安全存储(JWT 令牌持久化,PIPL 合规)
|
||||
flutter_secure_storage: ^9.2.0
|
||||
# 安全存储(JWT 令牌持久化)
|
||||
# 注意:flutter_secure_storage v9 的 web 插件使用 dart:html,
|
||||
# 不兼容 Flutter 3.44 的 Web 编译器。暂用 shared_preferences 替代。
|
||||
# TODO: flutter_secure_storage 升级到 v10+ 后恢复
|
||||
# flutter_secure_storage: ^9.2.0
|
||||
|
||||
# 手写引擎
|
||||
perfect_freehand: ^1.0.0
|
||||
|
||||
221
app/test/data/services/content_filter_service_test.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
// ContentFilterService 单元测试
|
||||
//
|
||||
// 覆盖:精确匹配、谐音变体匹配、文本预处理、各分类检测、边界条件
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/data/services/content_filter_service.dart';
|
||||
import 'package:nuanji_app/data/services/sensitive_words.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// 精确匹配 — 各分类
|
||||
// ============================================================
|
||||
group('精确匹配', () {
|
||||
test('暴力类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('我要打死你');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.violence), isTrue);
|
||||
expect(matches.any((m) => m.word == '打死你'), isTrue);
|
||||
});
|
||||
|
||||
test('色情类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('这个视频很色情');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.sexual), isTrue);
|
||||
});
|
||||
|
||||
test('欺凌类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('你是个废物');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.bullying), isTrue);
|
||||
expect(matches.any((m) => m.word == '废物'), isTrue);
|
||||
});
|
||||
|
||||
test('毒品类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('他在吸毒');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.drugs), isTrue);
|
||||
});
|
||||
|
||||
test('赌博类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('我们去赌钱吧');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.gambling), isTrue);
|
||||
});
|
||||
|
||||
test('粗口类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('卧槽太厉害了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.profanity), isTrue);
|
||||
});
|
||||
|
||||
test('诈骗类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('恭喜中奖了,点击链接领奖');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.fraud), isTrue);
|
||||
});
|
||||
|
||||
test('政治敏感类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('要造反了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.politics), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 安全内容
|
||||
// ============================================================
|
||||
group('安全内容', () {
|
||||
test('正常日记文本不触发', () {
|
||||
final text = '今天天气很好,我和小明一起去公园玩,非常开心。'
|
||||
'我们玩了滑梯、秋千,还吃了冰淇淋。';
|
||||
final matches = ContentFilterService.checkText(text);
|
||||
expect(matches, isEmpty);
|
||||
});
|
||||
|
||||
test('学习相关文本不触发', () {
|
||||
final text = '今天数学课学了乘法,我觉得很有趣。'
|
||||
'老师表扬了我,说我进步很大。';
|
||||
final matches = ContentFilterService.checkText(text);
|
||||
expect(matches, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 文本预处理 — 绕过手法
|
||||
// ============================================================
|
||||
group('文本预处理', () {
|
||||
test('空格分隔不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('我 要 打 死 你');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '打死你'), isTrue);
|
||||
});
|
||||
|
||||
test('特殊符号插入不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('废.物.垃.圾');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '废物'), isTrue);
|
||||
});
|
||||
|
||||
test('零宽字符不影响检测', () {
|
||||
// U+200B 零宽空格
|
||||
final matches = ContentFilterService.checkText('废物');
|
||||
expect(matches, isNotEmpty);
|
||||
});
|
||||
|
||||
test('下划线连字符不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('废_物');
|
||||
expect(matches, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 谐音/变体匹配
|
||||
// ============================================================
|
||||
group('谐音变体匹配', () {
|
||||
test('数字谐音 "4" 匹配含 "死" 的词', () {
|
||||
// "去死" 在词库中 → "去4" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('你怎么不去4');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '去死'), isTrue);
|
||||
});
|
||||
|
||||
test('形近字 "草" 匹配含 "操" 的词', () {
|
||||
// "操你" 在词库中 → "草你" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('我草你太牛了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '操你'), isTrue);
|
||||
});
|
||||
|
||||
test('变体 "wc" 匹配 "卧槽"', () {
|
||||
final matches = ContentFilterService.checkText('wc这个好厉害');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '卧槽'), isTrue);
|
||||
});
|
||||
|
||||
test('变体 "莎" 匹配含 "杀" 的词', () {
|
||||
// "杀人" 在词库中 → "莎人" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('我要莎人了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '杀人'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 边界条件
|
||||
// ============================================================
|
||||
group('边界条件', () {
|
||||
test('空字符串返回空列表', () {
|
||||
expect(ContentFilterService.checkText(''), isEmpty);
|
||||
});
|
||||
|
||||
test('纯空格返回空列表', () {
|
||||
expect(ContentFilterService.checkText(' '), isEmpty);
|
||||
});
|
||||
|
||||
test('纯符号返回空列表', () {
|
||||
expect(ContentFilterService.checkText('!@#\$%^&*'), isEmpty);
|
||||
});
|
||||
|
||||
test('超长文本不崩溃', () {
|
||||
final longText = '今天天气很好。' * 10000; // ~80,000 字符
|
||||
final matches = ContentFilterService.checkText(longText);
|
||||
expect(matches, isEmpty); // 正常内容
|
||||
});
|
||||
|
||||
test('多次出现同一词精确匹配只报告一次', () {
|
||||
final matches = ContentFilterService.checkText('白痴白痴');
|
||||
final exactMatches = matches.where((m) => m.word == '白痴' && m.position >= 0).toList();
|
||||
expect(exactMatches.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 辅助方法
|
||||
// ============================================================
|
||||
group('辅助方法', () {
|
||||
test('hasSensitiveContent 正确判断', () {
|
||||
expect(ContentFilterService.hasSensitiveContent('你好世界'), isFalse);
|
||||
expect(ContentFilterService.hasSensitiveContent('你是废物'), isTrue);
|
||||
});
|
||||
|
||||
test('getMatchedCategories 返回分类标签', () {
|
||||
final matches = ContentFilterService.checkText('废物你去死');
|
||||
final categories = ContentFilterService.getMatchedCategories(matches);
|
||||
expect(categories, isNotEmpty);
|
||||
// 至少包含欺凌和暴力
|
||||
expect(categories.any((c) => c == '欺凌'), isTrue);
|
||||
expect(categories.any((c) => c == '暴力'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 词库完整性
|
||||
// ============================================================
|
||||
group('词库完整性', () {
|
||||
test('8 个分类都有词', () {
|
||||
expect(kSensitiveWords.length, 8);
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
expect(entry.value, isNotEmpty, reason: '${entry.key.label} 分类不应为空');
|
||||
}
|
||||
});
|
||||
|
||||
test('总词量 >= 100', () {
|
||||
final total = kSensitiveWords.values.fold(0, (sum, list) => sum + list.length);
|
||||
expect(total, greaterThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
test('谐音变体映射的 key 都在词库中', () {
|
||||
final allWords = kSensitiveWords.values.expand((w) => w).toSet();
|
||||
for (final key in kHomophoneVariants.keys) {
|
||||
// 变体 key 应该在词库中存在(单字映射除外)
|
||||
// 有些变体 key 是单字如 "死",对应词库中的 "去死" 等
|
||||
expect(
|
||||
allWords.any((w) => w.contains(key)),
|
||||
isTrue,
|
||||
reason: '变体 key "$key" 不在词库中',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||