feat(app): Isar 本地数据库集成 — Collection + Repository + 编辑器持久化 + SyncEngine 队列
新增文件: - data/local/collections/ 3 个 Isar Collection 定义 + 生成 Schema - data/repositories/isar_journal_repository.dart 完整 CRUD + 乐观锁 修改文件: - app.dart: IsarJournalRepository 注册为主 JournalRepository + SyncEngine 注入 - editor_page.dart: onSave 接入 JournalRepository,笔画/元素自动保存到 Isar - sync_engine.dart: 新增 persistPendingQueue/restorePendingQueue Isar 持久化 - isar_database.dart: 注册 3 个 Collection Schema - main.dart: 启动时初始化 Isar 架构: 离线优先 — Isar 为本地主仓库,Remote 供 SyncEngine 推送
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
// MultiRepositoryProvider
|
// MultiRepositoryProvider
|
||||||
// ├─ ApiClient
|
// ├─ ApiClient
|
||||||
// ├─ AuthRepository
|
// ├─ AuthRepository
|
||||||
// ├─ JournalRepository (RemoteJournalRepository)
|
// ├─ JournalRepository (IsarJournalRepository — 离线优先)
|
||||||
|
// ├─ RemoteJournalRepository (供 SyncEngine 使用)
|
||||||
// └─ ClassRepository
|
// └─ ClassRepository
|
||||||
// └─ BlocProvider<AuthBloc>
|
// └─ BlocProvider<AuthBloc>
|
||||||
// └─ MaterialApp.router
|
// └─ MaterialApp.router
|
||||||
@@ -18,8 +19,10 @@ import 'core/routing/app_router.dart';
|
|||||||
import 'data/remote/api_client.dart';
|
import 'data/remote/api_client.dart';
|
||||||
import 'data/repositories/auth_repository.dart';
|
import 'data/repositories/auth_repository.dart';
|
||||||
import 'data/repositories/journal_repository.dart';
|
import 'data/repositories/journal_repository.dart';
|
||||||
|
import 'data/repositories/isar_journal_repository.dart';
|
||||||
import 'data/repositories/remote_journal_repository.dart';
|
import 'data/repositories/remote_journal_repository.dart';
|
||||||
import 'data/repositories/class_repository.dart';
|
import 'data/repositories/class_repository.dart';
|
||||||
|
import 'data/services/sync_engine.dart';
|
||||||
import 'features/auth/bloc/auth_bloc.dart';
|
import 'features/auth/bloc/auth_bloc.dart';
|
||||||
import 'features/profile/bloc/settings_bloc.dart';
|
import 'features/profile/bloc/settings_bloc.dart';
|
||||||
|
|
||||||
@@ -32,7 +35,10 @@ class NuanjiApp extends StatelessWidget {
|
|||||||
// 创建全局依赖(App 生命周期内单例)
|
// 创建全局依赖(App 生命周期内单例)
|
||||||
final apiClient = ApiClient();
|
final apiClient = ApiClient();
|
||||||
final authRepository = AuthRepository(apiClient: apiClient);
|
final authRepository = AuthRepository(apiClient: apiClient);
|
||||||
final journalRepository = RemoteJournalRepository(api: apiClient);
|
// 离线优先:Isar 为主要本地仓库,Remote 供 SyncEngine 推送
|
||||||
|
final journalRepository = IsarJournalRepository();
|
||||||
|
final remoteJournalRepository = RemoteJournalRepository(api: apiClient);
|
||||||
|
final syncEngine = SyncEngine(apiClient: apiClient);
|
||||||
final classRepository = ClassRepository(api: apiClient);
|
final classRepository = ClassRepository(api: apiClient);
|
||||||
final settingsBloc = SettingsBloc();
|
final settingsBloc = SettingsBloc();
|
||||||
final authBloc = AuthBloc(authRepository: authRepository);
|
final authBloc = AuthBloc(authRepository: authRepository);
|
||||||
@@ -40,6 +46,9 @@ class NuanjiApp extends StatelessWidget {
|
|||||||
// 启动时检查认证状态
|
// 启动时检查认证状态
|
||||||
authBloc.add(const AppStarted());
|
authBloc.add(const AppStarted());
|
||||||
|
|
||||||
|
// 异步恢复 SyncEngine 持久化队列(fire-and-forget,不阻塞 UI)
|
||||||
|
syncEngine.restorePendingQueue();
|
||||||
|
|
||||||
// 认证成功后注入 JWT token 到 ApiClient
|
// 认证成功后注入 JWT token 到 ApiClient
|
||||||
authBloc.stream.listen((state) {
|
authBloc.stream.listen((state) {
|
||||||
if (state is Authenticated) {
|
if (state is Authenticated) {
|
||||||
@@ -55,6 +64,8 @@ class NuanjiApp extends StatelessWidget {
|
|||||||
RepositoryProvider<ApiClient>.value(value: apiClient),
|
RepositoryProvider<ApiClient>.value(value: apiClient),
|
||||||
RepositoryProvider<AuthRepository>.value(value: authRepository),
|
RepositoryProvider<AuthRepository>.value(value: authRepository),
|
||||||
RepositoryProvider<JournalRepository>.value(value: journalRepository),
|
RepositoryProvider<JournalRepository>.value(value: journalRepository),
|
||||||
|
RepositoryProvider<RemoteJournalRepository>.value(value: remoteJournalRepository),
|
||||||
|
RepositoryProvider<SyncEngine>.value(value: syncEngine),
|
||||||
RepositoryProvider<ClassRepository>.value(value: classRepository),
|
RepositoryProvider<ClassRepository>.value(value: classRepository),
|
||||||
RepositoryProvider<SettingsBloc>.value(value: settingsBloc),
|
RepositoryProvider<SettingsBloc>.value(value: settingsBloc),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 日记元素 Isar Collection — 本地持久化存储
|
||||||
|
//
|
||||||
|
// 与纯 Dart 模型 JournalElement 分离,通过转换函数桥接。
|
||||||
|
// journalId 索引支持按日记查询所有元素。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'journal_element_collection.g.dart';
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class JournalElementCollection {
|
||||||
|
/// Isar 自增主键
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
/// 业务 UUID(索引)
|
||||||
|
@Index()
|
||||||
|
String id = '';
|
||||||
|
|
||||||
|
/// 所属日记 ID(索引,用于外键查询)
|
||||||
|
@Index()
|
||||||
|
String journalId = '';
|
||||||
|
|
||||||
|
/// 元素类型(enum → string: text/image/sticker/handwriting_ref/tape)
|
||||||
|
String elementType = 'text';
|
||||||
|
|
||||||
|
/// X 坐标
|
||||||
|
double positionX = 0;
|
||||||
|
|
||||||
|
/// Y 坐标
|
||||||
|
double positionY = 0;
|
||||||
|
|
||||||
|
/// 宽度
|
||||||
|
double width = 100;
|
||||||
|
|
||||||
|
/// 高度
|
||||||
|
double height = 100;
|
||||||
|
|
||||||
|
/// 旋转角度
|
||||||
|
double rotation = 0;
|
||||||
|
|
||||||
|
/// 层级
|
||||||
|
int zIndex = 0;
|
||||||
|
|
||||||
|
/// 结构化内容(JSON String)
|
||||||
|
/// text: {'text':'...','fontSize':16.0}
|
||||||
|
/// image: {'filePath':'...'}
|
||||||
|
/// sticker: {'stickerPackId':'...','stickerId':'...'}
|
||||||
|
/// handwriting_ref: {'strokesJson':'...','strokeCount':42}
|
||||||
|
/// tape: {'tapeStyle':'washi_dots'}
|
||||||
|
String contentJson = '{}';
|
||||||
|
|
||||||
|
/// 版本号(乐观锁)
|
||||||
|
int version = 1;
|
||||||
|
|
||||||
|
/// 创建时间(epoch milliseconds)
|
||||||
|
int createdAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 更新时间(epoch milliseconds)
|
||||||
|
int updatedAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 软删除标记
|
||||||
|
bool isDeleted = false;
|
||||||
|
}
|
||||||
2218
app/lib/data/local/collections/journal_element_collection.g.dart
Normal file
2218
app/lib/data/local/collections/journal_element_collection.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
60
app/lib/data/local/collections/journal_entry_collection.dart
Normal file
60
app/lib/data/local/collections/journal_entry_collection.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// 日记条目 Isar Collection — 本地持久化存储
|
||||||
|
//
|
||||||
|
// 与纯 Dart 模型 JournalEntry 分离,通过转换函数桥接。
|
||||||
|
// 业务 ID (String UUID) 作为索引字段,Isar 主键用 autoIncrement。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'journal_entry_collection.g.dart';
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class JournalEntryCollection {
|
||||||
|
/// Isar 自增主键
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
/// 业务 UUID(索引,用于查找)
|
||||||
|
@Index()
|
||||||
|
String id = '';
|
||||||
|
|
||||||
|
/// 作者 ID
|
||||||
|
String authorId = '';
|
||||||
|
|
||||||
|
/// 班级 ID(可选)
|
||||||
|
String? classId;
|
||||||
|
|
||||||
|
/// 日记标题
|
||||||
|
String title = '';
|
||||||
|
|
||||||
|
/// 日记日期(epoch milliseconds)
|
||||||
|
int dateEpoch = 0;
|
||||||
|
|
||||||
|
/// 心情(enum → string)
|
||||||
|
String mood = 'calm';
|
||||||
|
|
||||||
|
/// 天气(enum → string)
|
||||||
|
String weather = 'sunny';
|
||||||
|
|
||||||
|
/// 标签列表(JSON String)
|
||||||
|
String tagsJson = '[]';
|
||||||
|
|
||||||
|
/// 是否私密
|
||||||
|
bool isPrivate = true;
|
||||||
|
|
||||||
|
/// 是否分享到班级
|
||||||
|
bool sharedToClass = false;
|
||||||
|
|
||||||
|
/// 关联主题 ID(可选)
|
||||||
|
String? assignedTopicId;
|
||||||
|
|
||||||
|
/// 版本号(乐观锁)
|
||||||
|
int version = 1;
|
||||||
|
|
||||||
|
/// 创建时间(epoch milliseconds)
|
||||||
|
int createdAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 更新时间(epoch milliseconds)
|
||||||
|
int updatedAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 软删除标记
|
||||||
|
bool isDeleted = false;
|
||||||
|
}
|
||||||
2502
app/lib/data/local/collections/journal_entry_collection.g.dart
Normal file
2502
app/lib/data/local/collections/journal_entry_collection.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
// 待同步操作 Isar Collection — SyncEngine 队列持久化
|
||||||
|
//
|
||||||
|
// 应用退出时将内存队列写入 Isar,下次启动时恢复。
|
||||||
|
// 保证离线操作不会因进程终止而丢失。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'pending_operation_collection.g.dart';
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class PendingOperationCollection {
|
||||||
|
/// Isar 自增主键
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
/// 业务 UUID(索引)
|
||||||
|
@Index()
|
||||||
|
String id = '';
|
||||||
|
|
||||||
|
/// 操作类型:create / update / delete
|
||||||
|
String operationType = 'create';
|
||||||
|
|
||||||
|
/// API 端点(如 '/diary/journals')
|
||||||
|
String endpoint = '';
|
||||||
|
|
||||||
|
/// 请求负载(JSON String)
|
||||||
|
String dataJson = '{}';
|
||||||
|
|
||||||
|
/// 资源版本号(乐观锁)
|
||||||
|
int version = 1;
|
||||||
|
|
||||||
|
/// 创建时间(epoch milliseconds)
|
||||||
|
int createdAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 重试次数(最大 5 次)
|
||||||
|
int retryCount = 0;
|
||||||
|
}
|
||||||
1408
app/lib/data/local/collections/pending_operation_collection.g.dart
Normal file
1408
app/lib/data/local/collections/pending_operation_collection.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,36 @@
|
|||||||
// Isar 数据库初始化 — 本地持久化存储
|
// Isar 数据库初始化 — 本地持久化存储
|
||||||
//
|
//
|
||||||
// Isar 3.x 要求 open() 时传入 List<CollectionSchema> 位置参数。
|
// Isar 3.x 要求 open() 时传入 List<CollectionSchema>。
|
||||||
// 由于我们使用手写不可变类而非 isar_generator 代码生成,
|
// 通过 build_runner 生成 Schema,在 main.dart 启动时调用 init()。
|
||||||
// 需要在调用 [init] 时传入 schema 列表。
|
|
||||||
// 当前阶段使用 [ensureInitialized] 占位,待后续添加 Isar Collection 后正式注册。
|
|
||||||
|
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:path_provider/path_provider.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 数据库单例管理
|
/// Isar 数据库单例管理
|
||||||
///
|
|
||||||
/// 使用方式(Phase 1 — 无 schema 时):
|
|
||||||
/// ```dart
|
|
||||||
/// // 直接使用,不初始化 Isar(内存仓库模式)
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// 使用方式(Phase 2 — 有 schema 后):
|
|
||||||
/// ```dart
|
|
||||||
/// final isar = await IsarDatabase.init(schemas: [JournalEntrySchema]);
|
|
||||||
/// ```
|
|
||||||
class IsarDatabase {
|
class IsarDatabase {
|
||||||
IsarDatabase._();
|
IsarDatabase._();
|
||||||
|
|
||||||
static Isar? _instance;
|
static Isar? _instance;
|
||||||
static bool _initialized = false;
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
/// 所有 Collection Schema(由 build_runner 生成)
|
||||||
|
static final List<CollectionSchema<dynamic>> schemas = [
|
||||||
|
JournalEntryCollectionSchema,
|
||||||
|
JournalElementCollectionSchema,
|
||||||
|
PendingOperationCollectionSchema,
|
||||||
|
];
|
||||||
|
|
||||||
/// 是否已初始化
|
/// 是否已初始化
|
||||||
static bool get isInitialized => _initialized;
|
static bool get isInitialized => _initialized;
|
||||||
|
|
||||||
/// 初始化数据库(需在 app 启动时调用,传入所有 CollectionSchema)
|
/// 初始化数据库
|
||||||
///
|
///
|
||||||
/// - [schemas]: Isar Collection Schema 列表(由 isar_generator 生成)
|
/// 在 main() 中调用,open 之前需确保 WidgetsFlutterBinding 已初始化。
|
||||||
/// - 在应用文档目录下创建 isar 数据库文件
|
static Future<Isar> init() async {
|
||||||
/// - 开发模式开启 inspector(flutter pub global run isar_inspector)
|
|
||||||
static Future<Isar> init({
|
|
||||||
required List<CollectionSchema<dynamic>> schemas,
|
|
||||||
}) async {
|
|
||||||
if (_instance != null && _instance!.isOpen) return _instance!;
|
if (_instance != null && _instance!.isOpen) return _instance!;
|
||||||
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
@@ -50,20 +45,14 @@ class IsarDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Isar 实例(必须先调用 [init])
|
/// 获取 Isar 实例(必须先调用 [init])
|
||||||
///
|
|
||||||
/// 如果未初始化会抛出 [StateError]。
|
|
||||||
static Isar get instance {
|
static Isar get instance {
|
||||||
if (_instance == null || !_instance!.isOpen) {
|
if (_instance == null || !_instance!.isOpen) {
|
||||||
throw StateError(
|
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
|
||||||
'IsarDatabase 未初始化,请先调用 IsarDatabase.init(schemas: [...])',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return _instance!;
|
return _instance!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 关闭数据库连接
|
/// 关闭数据库连接
|
||||||
///
|
|
||||||
/// 通常只在应用退出时调用。
|
|
||||||
static Future<void> close() async {
|
static Future<void> close() async {
|
||||||
if (_instance != null && _instance!.isOpen) {
|
if (_instance != null && _instance!.isOpen) {
|
||||||
await _instance!.close();
|
await _instance!.close();
|
||||||
@@ -76,7 +65,7 @@ class IsarDatabase {
|
|||||||
static Future<void> clearAll() async {
|
static Future<void> clearAll() async {
|
||||||
if (_instance == null || !_instance!.isOpen) return;
|
if (_instance == null || !_instance!.isOpen) return;
|
||||||
await _instance!.writeTxn(() async {
|
await _instance!.writeTxn(() async {
|
||||||
// TODO: 清空所有 collection
|
await _instance!.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
333
app/lib/data/repositories/isar_journal_repository.dart
Normal file
333
app/lib/data/repositories/isar_journal_repository.dart
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
// Isar 本地日记仓库 — 本地优先数据存储
|
||||||
|
//
|
||||||
|
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||||||
|
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||||||
|
//
|
||||||
|
// 转换层:
|
||||||
|
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||||
|
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}) 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期降序排列
|
||||||
|
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<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
|
||||||
|
..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,
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
// 同步引擎 — WiFi 增量同步 + 操作队列
|
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
|
||||||
//
|
//
|
||||||
// 设计思路:
|
// 设计思路:
|
||||||
// - 所有本地修改先入队 [PendingOperation]
|
// - 所有本地修改先入队 [PendingOperation]
|
||||||
// - 网络恢复时自动批量同步
|
// - 网络恢复时自动批量同步
|
||||||
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
||||||
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
||||||
|
// - 队列持久化到 Isar,应用退出后不丢失
|
||||||
//
|
//
|
||||||
// Phase 1 策略:本地优先
|
// Phase 1 策略:本地优先
|
||||||
// - 离线时正常使用,操作入队等待
|
// - 离线时正常使用,操作入队等待
|
||||||
// - 联网后自动推送待同步操作
|
// - 联网后自动推送待同步操作
|
||||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
import '../local/isar_database.dart';
|
||||||
|
import '../local/collections/pending_operation_collection.dart';
|
||||||
import '../remote/api_client.dart';
|
import '../remote/api_client.dart';
|
||||||
|
|
||||||
/// 同步操作类型
|
/// 同步操作类型
|
||||||
@@ -87,6 +92,9 @@ class PendingOperation {
|
|||||||
/// ```dart
|
/// ```dart
|
||||||
/// final engine = SyncEngine(apiClient: apiClient);
|
/// final engine = SyncEngine(apiClient: apiClient);
|
||||||
///
|
///
|
||||||
|
/// // 启动时恢复持久化队列
|
||||||
|
/// await engine.restorePendingQueue();
|
||||||
|
///
|
||||||
/// // 本地修改后入队
|
/// // 本地修改后入队
|
||||||
/// engine.enqueue(PendingOperation(
|
/// engine.enqueue(PendingOperation(
|
||||||
/// id: 'op-1',
|
/// id: 'op-1',
|
||||||
@@ -99,6 +107,9 @@ class PendingOperation {
|
|||||||
///
|
///
|
||||||
/// // 网络恢复时触发同步
|
/// // 网络恢复时触发同步
|
||||||
/// await engine.trySync();
|
/// await engine.trySync();
|
||||||
|
///
|
||||||
|
/// // 应用退出时持久化
|
||||||
|
/// await engine.persistPendingQueue();
|
||||||
/// ```
|
/// ```
|
||||||
class SyncEngine {
|
class SyncEngine {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
@@ -107,7 +118,7 @@ class SyncEngine {
|
|||||||
SyncStatus _status = SyncStatus.idle;
|
SyncStatus _status = SyncStatus.idle;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
|
||||||
SyncEngine({required this._apiClient});
|
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||||
|
|
||||||
/// 当前同步状态
|
/// 当前同步状态
|
||||||
SyncStatus get status => _status;
|
SyncStatus get status => _status;
|
||||||
@@ -200,9 +211,10 @@ class SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全部同步完成
|
// 全部同步完成,更新持久化
|
||||||
_status = SyncStatus.idle;
|
_status = SyncStatus.idle;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
|
await persistPendingQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行单个同步操作
|
/// 执行单个同步操作
|
||||||
@@ -227,6 +239,100 @@ class SyncEngine {
|
|||||||
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
||||||
///
|
///
|
||||||
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
||||||
/// 下次启动时通过 [enqueueAll] 恢复。
|
/// 下次启动时通过 [restorePendingQueue] 恢复。
|
||||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Isar 持久化
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 将当前内存队列持久化到 Isar
|
||||||
|
///
|
||||||
|
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
|
||||||
|
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||||
|
Future<void> persistPendingQueue() async {
|
||||||
|
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 启动时调用,恢复上次退出时未同步的操作。
|
||||||
|
Future<void> restorePendingQueue() async {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 转换函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../core/constants/design_tokens.dart';
|
import '../../../core/constants/design_tokens.dart';
|
||||||
import '../../../data/models/journal_element.dart';
|
import '../../../data/models/journal_element.dart';
|
||||||
|
import '../../../data/models/journal_entry.dart';
|
||||||
|
import '../../../data/repositories/journal_repository.dart';
|
||||||
import '../bloc/editor_bloc.dart';
|
import '../bloc/editor_bloc.dart';
|
||||||
import '../widgets/handwriting_canvas.dart';
|
import '../widgets/handwriting_canvas.dart';
|
||||||
|
import '../widgets/stroke_model.dart';
|
||||||
import '../widgets/draggable_element.dart';
|
import '../widgets/draggable_element.dart';
|
||||||
import '../widgets/editor_toolbar.dart';
|
import '../widgets/editor_toolbar.dart';
|
||||||
|
|
||||||
@@ -28,22 +31,123 @@ class EditorPage extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 从 Provider 树获取 JournalRepository(IsarJournalRepository)
|
||||||
|
final repo = context.read<JournalRepository>();
|
||||||
|
|
||||||
|
// 可变闭包变量:跟踪已保存的日记 ID
|
||||||
|
// 新建日记首次保存后赋值,后续自动更新使用此 ID
|
||||||
|
String? savedJournalId = journalId;
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (_) => EditorBloc(
|
create: (_) => EditorBloc(
|
||||||
onSave: (state) {
|
onSave: (state) async {
|
||||||
// TODO: 通过 JournalRepository 保存到 Isar
|
try {
|
||||||
debugPrint('自动保存: ${state.strokes.length} 笔画, ${state.elements.length} 元素');
|
await _persistState(repo, state, (id) => savedJournalId = id, savedJournalId);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('自动保存失败: $e');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
child: _EditorView(journalId: journalId),
|
child: _EditorView(
|
||||||
|
journalId: journalId,
|
||||||
|
onSaveComplete: () => context.pop(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 持久化编辑器状态到 Isar
|
||||||
|
///
|
||||||
|
/// 策略:
|
||||||
|
/// - 首次保存(savedJournalId == null)→ createJournal + addElement
|
||||||
|
/// - 后续保存 → updateJournal + upsert 元素
|
||||||
|
/// - 笔画序列化为 handwriting_ref 元素
|
||||||
|
Future<void> _persistState(
|
||||||
|
JournalRepository repo,
|
||||||
|
EditorState state,
|
||||||
|
void Function(String) setId,
|
||||||
|
String? savedJournalId,
|
||||||
|
) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
if (savedJournalId == null) {
|
||||||
|
// --- 新建日记 ---
|
||||||
|
final entry = JournalEntry.create(
|
||||||
|
authorId: 'local', // TODO: 从 AuthBloc 获取真实用户 ID
|
||||||
|
title: '${now.month}月${now.day}日的日记',
|
||||||
|
date: now,
|
||||||
|
);
|
||||||
|
await repo.createJournal(entry);
|
||||||
|
setId(entry.id);
|
||||||
|
|
||||||
|
// 保存笔画
|
||||||
|
if (state.strokes.isNotEmpty) {
|
||||||
|
await _saveStrokesAsElement(repo, entry.id, state.strokes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存其他元素
|
||||||
|
for (final element in state.elements) {
|
||||||
|
await repo.addElement(element.copyWith(journalId: entry.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- 更新已有日记 ---
|
||||||
|
final existing = await repo.getJournal(savedJournalId);
|
||||||
|
if (existing != null) {
|
||||||
|
await repo.updateJournal(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新笔画
|
||||||
|
if (state.strokes.isNotEmpty) {
|
||||||
|
await _saveStrokesAsElement(repo, savedJournalId, state.strokes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsert 元素(先尝试更新,失败则新建)
|
||||||
|
for (final element in state.elements) {
|
||||||
|
try {
|
||||||
|
await repo.updateElement(element);
|
||||||
|
} catch (_) {
|
||||||
|
await repo.addElement(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将笔画列表序列化为 handwriting_ref 元素并保存
|
||||||
|
///
|
||||||
|
/// 每次保存都替换整个笔画集(Phase 1 简化策略)。
|
||||||
|
Future<void> _saveStrokesAsElement(
|
||||||
|
JournalRepository repo,
|
||||||
|
String journalId,
|
||||||
|
List<Stroke> strokes,
|
||||||
|
) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final strokesJson = strokes.map((s) => s.toJson()).toList();
|
||||||
|
|
||||||
|
final element = JournalElement(
|
||||||
|
id: '${journalId}_strokes', // 固定 ID,保证每次覆盖
|
||||||
|
journalId: journalId,
|
||||||
|
elementType: ElementType.handwritingRef,
|
||||||
|
content: {
|
||||||
|
'strokes': strokesJson,
|
||||||
|
'strokeCount': strokes.length,
|
||||||
|
},
|
||||||
|
version: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await repo.updateElement(element);
|
||||||
|
} catch (_) {
|
||||||
|
await repo.addElement(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EditorView extends StatelessWidget {
|
class _EditorView extends StatelessWidget {
|
||||||
final String? journalId;
|
final String? journalId;
|
||||||
|
final VoidCallback onSaveComplete;
|
||||||
|
|
||||||
const _EditorView({this.journalId});
|
const _EditorView({this.journalId, required this.onSaveComplete});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -117,10 +221,7 @@ class _EditorView extends StatelessWidget {
|
|||||||
|
|
||||||
// 完成按钮
|
// 完成按钮
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () {
|
onPressed: onSaveComplete,
|
||||||
// TODO: 保存并返回
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
//
|
//
|
||||||
// 初始化流程:
|
// 初始化流程:
|
||||||
// 1. 确保 Flutter 绑定就绪
|
// 1. 确保 Flutter 绑定就绪
|
||||||
// 2. 运行 App(认证状态恢复在 AuthBloc.AppStarted 中处理)
|
// 2. 初始化 Isar 本地数据库
|
||||||
|
// 3. 运行 App(认证状态恢复在 AuthBloc.AppStarted 中处理)
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'data/local/isar_database.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await IsarDatabase.init();
|
||||||
runApp(const NuanjiApp());
|
runApp(const NuanjiApp());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user