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:
iven
2026-06-01 14:41:40 +08:00
parent e07da7addb
commit 2481c8fce6
12 changed files with 6876 additions and 46 deletions

View File

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

File diff suppressed because it is too large Load Diff

View 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;
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,36 @@
// Isar 数据库初始化 — 本地持久化存储
//
// Isar 3.x 要求 open() 时传入 List<CollectionSchema> 位置参数
// 由于我们使用手写不可变类而非 isar_generator 代码生成,
// 需要在调用 [init] 时传入 schema 列表。
// 当前阶段使用 [ensureInitialized] 占位,待后续添加 Isar Collection 后正式注册。
// Isar 3.x 要求 open() 时传入 List<CollectionSchema>。
// 通过 build_runner 生成 Schema在 main.dart 启动时调用 init()。
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 数据库单例管理
///
/// 使用方式Phase 1 — 无 schema 时):
/// ```dart
/// // 直接使用,不初始化 Isar内存仓库模式
/// ```
///
/// 使用方式Phase 2 — 有 schema 后):
/// ```dart
/// final isar = await IsarDatabase.init(schemas: [JournalEntrySchema]);
/// ```
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;
/// 初始化数据库(需在 app 启动时调用,传入所有 CollectionSchema
/// 初始化数据库
///
/// - [schemas]: Isar Collection Schema 列表(由 isar_generator 生成)
/// - 在应用文档目录下创建 isar 数据库文件
/// - 开发模式开启 inspectorflutter pub global run isar_inspector
static Future<Isar> init({
required List<CollectionSchema<dynamic>> schemas,
}) async {
/// 在 main() 中调用open 之前需确保 WidgetsFlutterBinding 已初始化。
static Future<Isar> init() async {
if (_instance != null && _instance!.isOpen) return _instance!;
final dir = await getApplicationDocumentsDirectory();
@@ -50,20 +45,14 @@ class IsarDatabase {
}
/// 获取 Isar 实例(必须先调用 [init]
///
/// 如果未初始化会抛出 [StateError]。
static Isar get instance {
if (_instance == null || !_instance!.isOpen) {
throw StateError(
'IsarDatabase 未初始化,请先调用 IsarDatabase.init(schemas: [...])',
);
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
}
return _instance!;
}
/// 关闭数据库连接
///
/// 通常只在应用退出时调用。
static Future<void> close() async {
if (_instance != null && _instance!.isOpen) {
await _instance!.close();
@@ -76,7 +65,7 @@ class IsarDatabase {
static Future<void> clearAll() async {
if (_instance == null || !_instance!.isOpen) return;
await _instance!.writeTxn(() async {
// TODO: 清空所有 collection
await _instance!.clear();
});
}
}

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

View File

@@ -1,20 +1,25 @@
// 同步引擎 — WiFi 增量同步 + 操作队列
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
//
// 设计思路:
// - 所有本地修改先入队 [PendingOperation]
// - 网络恢复时自动批量同步
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
// - 队列持久化到 Isar应用退出后不丢失
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
import 'dart:convert';
import 'dart:collection';
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';
/// 同步操作类型
@@ -87,6 +92,9 @@ class PendingOperation {
/// ```dart
/// final engine = SyncEngine(apiClient: apiClient);
///
/// // 启动时恢复持久化队列
/// await engine.restorePendingQueue();
///
/// // 本地修改后入队
/// engine.enqueue(PendingOperation(
/// id: 'op-1',
@@ -99,6 +107,9 @@ class PendingOperation {
///
/// // 网络恢复时触发同步
/// await engine.trySync();
///
/// // 应用退出时持久化
/// await engine.persistPendingQueue();
/// ```
class SyncEngine {
final ApiClient _apiClient;
@@ -107,7 +118,7 @@ class SyncEngine {
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required this._apiClient});
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
/// 当前同步状态
SyncStatus get status => _status;
@@ -200,9 +211,10 @@ class SyncEngine {
}
}
// 全部同步完成
// 全部同步完成,更新持久化
_status = SyncStatus.idle;
_lastError = null;
await persistPendingQueue();
}
/// 执行单个同步操作
@@ -227,6 +239,100 @@ class SyncEngine {
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
///
/// 应用退出时调用此方法,将待同步操作保存到 Isar
/// 下次启动时通过 [enqueueAll] 恢复。
/// 下次启动时通过 [restorePendingQueue] 恢复。
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 {};
}
}
}