Compare commits

..

64 Commits

Author SHA1 Message Date
iven
7af7cd64e6 feat(app): E.1 内测 APK 构建配置 — Android 品牌化 + 签名 + ProGuard
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 更新应用名称为「暖记」(AndroidManifest + strings.xml)
- 添加必要权限: INTERNET, CAMERA, READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE
- 生成 release 签名密钥 (RSA 2048, 10000 天有效期)
- 配置 ProGuard/R8 代码混淆 + 资源压缩
- 品牌化启动页: 奶油白背景 + 珊瑚色圆形「暖」字 logo
- 品牌化应用图标: 各密度 mipmap (mdpi~xxxhdpi)
- 添加阿里云 Maven 镜像加速依赖下载
- AGP 9.x 兼容: 自动为旧 Flutter 插件注入 namespace
- Gradle 性能优化: 并行编译 + 构建缓存
2026-06-07 20:17:19 +08:00
iven
ec8a04c80a feat(app): D.3 中等优先级 UX 改进 — 保存指示器 + 触摸目标 + 主题持久化
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
D.3.2 三态保存指示器:
- 未保存 (灰色) → 保存中 (琥珀色脉冲点) → 已保存 (绿色点)
- _PulsingDot 动画组件,800ms 呼吸效果
- 点击'完成'时显示保存中状态

D.3.3 工具栏触摸目标:
- BoxConstraints 36x36 → 44x44,符合 WCAG 标准

D.3.4 主题偏好持久化:
- SettingsBloc 接受 SharedPreferences,保存/恢复 themeMode
- NuanjiApp 改为 StatefulWidget,异步初始化 SharedPreferences
- 启动时显示 loading,初始化完成后渲染 app
2026-06-07 13:50:34 +08:00
iven
750605e479 feat(app): 全局离线提示横幅 — 网络不可用时显示黄色警告
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增 OfflineBanner widget: 监听 connectivity_plus 自动显示/隐藏
- AnimatedCrossFade 滑入滑出动画 + warmCurve 弹性曲线
- 黄色警告条: wifi_off 图标 + '网络不可用,部分功能受限'
- 嵌入 ResponsiveScaffold 的 body 上方 (手机/平板/桌面三端)
- 只在离线时显示,恢复网络后自动消失
2026-06-07 13:44:40 +08:00
iven
346c751cbb refactor(app): 迁移 4 个页面到共享 EmptyStateWidget + ErrorStateWidget
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
迁移统计:
- discover_page: _buildError → ErrorStateWidget, _buildEmptyHint → EmptyStateWidget
- sticker_library_page: 错误 + 空列表 → 共享组件
- class_page: 错误/班级列表空/日记墙空/话题空 → 共享组件 (4 处)
- calendar_page: CalendarError → ErrorStateWidget

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

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

Phase 1 — Bug 修复:
- 班级评论按 journalId 过滤,避免显示在错误日记卡片下
- moodCellColors key 修正: love/tired → angry/thinking
- 日历非 CalendarLoaded 状态改为加载指示器 (不再 SizedBox.shrink)
- 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
2026-06-07 13:36:10 +08:00
iven
1f48a67db5 docs: 更新路线图 — A+B+C 阶段完成,9 条链路 8 通过
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-07 13:15:53 +08:00
iven
225af89e41 fix(app): Token 刷新彻底失败时通知 AuthBloc 派发 AuthExpired
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
审计 9a-AUTH-01 修复:
- ApiClient 新增 onAuthFailed 回调,在 401 刷新失败后触发
- app.dart 注册回调:派发 AuthExpired → GoRouter 重定向到登录页
- 之前刷新失败只清除内存 token,用户停留在死页面
- 现在 401 → 尝试刷新 → 失败 → AuthExpired → 自动跳转登录
2026-06-07 12:59:12 +08:00
iven
dbb74b6545 fix(diary): 系统性修复 DTO 输入验证 — 42 项审计发现中输入验证类全部修复
DTO 字段级验证:
- version 字段全部添加 range(min=0) 防止负数
- 标签内容验证: 单个标签最长 30 字符,不允许空白
- 班级码正则: 仅允许字母数字,拒绝特殊字符
- 贴纸包 price 添加 range(min=0) 防止负价格
- thumbnail_url/image_url 添加 length(max=500) 限制
- 同步请求 data payload 限制 1MB/条

Handler validate() 调用补齐:
- delete_journal: DeleteJournalReq 添加 Validate derive + handler 调用
- bind_child / unbind_child / delete_child_data: 补齐 req.validate() 调用
- join_class: 添加 validate_code() 字母数字检查
- sync_journals: 添加 validate_changes_data() payload 大小检查

审计覆盖: 5a-C01/02/03 + 5a-H02/03/04 + B-03 + 7b-C02
2026-06-07 12:55:50 +08:00
iven
3c3d70c751 fix(app): 日历页切换月份后保留选中日期的日记列表
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-07 10:44:55 +08:00
iven
ed8252d7c8 docs: 更新 Wiki 文档 — 数据层/前端/后端/健康/索引同步至最新 2026-06-07 10:44:26 +08:00
iven
41ef28f20b test(app): CalendarBloc 新增 31 个单元测试 2026-06-07 10:44:15 +08:00
iven
d67eedf7de feat(app): 多页面动态化 — 搜索/资料/教师/贴纸库/模板/日历
- SearchPage: 热搜词从日记标签频率动态生成 + 模板搜索网格
- ProfilePage: 成就徽章从 AchievementBloc 动态加载 + 头像首字母
- TeacherPage: 班级码改为对话框展示 (班级名+码+人数)
- StickerLibraryPage: 分类从 API 动态合并 + 精选包卡片动态化
- TemplateGalleryPage: 适配动态数据
- ClassPage: 微调
- HomePage: 路由适配
- CalendarBloc: 新增测试
- AppRouter: 路由更新
2026-06-07 10:44:04 +08:00
iven
a05374e8d1 feat(app): 编辑器增强 — 查看模式 + 图层排序 + 标签/贴纸动态化
- EditorPage 新增查看模式: 打开已保存日记默认只读,编辑按钮切换
- EditorBloc 新增 ElementLayerChanged 事件,支持置顶/置底图层排序
- DraggableElement 添加图层控制按钮 (置顶/置底/删除)
- TagPanel 标签建议改为从日记历史动态生成 (Top 10 频率)
- StickerPickerSheet 重构,预留 API 扩展点
2026-06-07 10:43:37 +08:00
iven
a5d2b0409f feat(app): 发现页动态化 — DiscoverBloc + API 驱动 + 下拉刷新
- 新增 DiscoverBloc (LoadData/Refresh) + DiscoverModels 4 个数据类
- DiscoverPage 改为 BlocBuilder 驱动: loading/loaded/error/empty 四态
- 替换全部硬编码占位数据为 API 实时数据
- 添加 RefreshIndicator 下拉刷新
- 离线异常时保留已有数据,友好错误提示
2026-06-07 10:43:23 +08:00
iven
3bc2ca7332 feat(diary): 添加发现页 Discover API — 每日灵感/热门标签/精选模板/专家日记
新增 DiscoverService 并发聚合 4 个数据区:
- daily_inspiration: MD5 哈希确定性日更推荐,匿名作者名
- hot_topics: 标签频率统计 Top 8
- featured_templates: 官方模板最多 6 个
- expert_diaries: 评论数热度排序,去重最多 5 位作者

GET /api/v1/diary/discover + utoipa 文档 + diary.journal.read 权限守卫
2026-06-07 10:43:02 +08:00
iven
4cb91f3ac9 fix(app): 修复打开已保存日记时笔迹不可见 — syncStrokes 后缺少 setState
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因:_syncCacheAfterBuild 调用 _cache.syncStrokes() 后没有触发
setState,导致 CachedStrokesPainter 不知道缓存已更新,不执行重绘。

对比正常绘画流程(addStroke 后有 setState),加载笔迹流程遗漏了
重绘触发。添加 .then(() => setState) 后,合成图重建完成即刷新画布。
2026-06-04 23:12:51 +08:00
iven
c253c8ddcf chore(scripts): 开发脚本支持 Flutter Windows 桌面端
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- pnpm start:dev 默认在 Windows 上启动桌面端(非 Web)
- 新增 pnpm start:dev:app:win 强制 Windows 桌面
- 新增 pnpm start:dev:app:web 强制 Chrome Web
- SIGINT 清理时同时终止 nuanji_app.exe
- 汇总输出区分桌面/Web 模式
2026-06-04 20:35:39 +08:00
iven
bb388ed8ff fix(app): 日记可见性修复 — 私密日记仅本地 + Web 端 ID 修复 + 分享按钮
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
问题修复:
1. Web端保存的日记看不到:createJournal 返回值未捕获,server ID 丢失导致
   后续元素保存用错 ID。现在使用 saved.id 贯穿全部操作。
2. 管理端看不到新建日记:后端 list_journals 添加 is_private 过滤,admin/teacher
   查看他人日记时排除私密日记。
3. RemoteJournalRepository 添加 onJournalChanged 变更通知流,HomeBloc 可自动刷新。
4. SyncEngine(native + web)enqueue 添加 is_private 防御性检查,私密日记不入队。
5. 编辑器 _persistState 条件入队:仅非私密日记同步到后端。
6. 分享流程改造:首次从私密变为公开时入队 create 操作上传。
7. 日记卡片添加可见性标签(仅自己可见/班级可见/公开),私密日记可点击分享。
8. 首页 _sharePrivateJournal 弹出 ShareBottomSheet 主动分享。
2026-06-04 12:03:24 +08:00
iven
c441aa4e34 fix(app): RemoteJournalRepository 创建日记 date 格式修复 — ISO 8601 → NaiveDate
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因: JournalEntry.toJson() 发送 '2026-06-04T12:00:00.123456',
后端 CreateJournalReq.date 是 chrono::NaiveDate,只接受 '2026-06-04'。
反序列化失败导致创建日记被拒绝,前端静默吞掉错误。

修复: createJournal 发送前将 date 截取为 YYYY-MM-DD 格式。
2026-06-04 10:47:14 +08:00
iven
e635557e67 fix(app): 修复 RemoteJournalRepository API 响应解析 — 分页信封嵌套
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因: 后端列表接口返回 { success, data: { data: [...], total, ... }, message }
但前端直接 body['data'] as List,类型不匹配导致 TypeError 被静默吞掉。

修复: getJournals 和 getJournalCount 正确解析分页信封
body['data']['data'] 获取列表, body['data']['total'] 获取总数。
2026-06-04 09:35:54 +08:00
iven
138bfa9723 fix(app): Flutter Web 改用 HTML 渲染器,避免 Google Fonts CDN 加载中文字体
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因: Flutter Web CanvasKit 渲染器会从 fonts.gstatic.com 按需加载
CJK 字体 woff2 子集,在中国网络环境下完全不可达。

修复: 启动参数添加 --web-renderer html,使用浏览器原生 CSS
font-face 机制加载本地字体文件,无需外网访问。

面向中国国内市场,不依赖任何外部 CDN。
2026-06-04 09:22:48 +08:00
iven
b72009718f fix(app): 日记保存逻辑修复 — EditorPage 改为 StatefulWidget + 更新合并编辑器状态
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因分析:
1. EditorPage 是 StatelessWidget,savedJournalId 作为 build() 局部变量
   每次重建都重置为 null,导致每次自动保存都走新建而非更新分支
2. 更新分支直接 repo.updateJournal(existing),没有把编辑器当前
   状态(标题/心情/标签)合并到已有日记中

修复:
- EditorPage 改为 StatefulWidget,_savedJournalId 存储在 State 中
- 更新分支用 existing.copyWith() 合并编辑器当前状态后保存
2026-06-04 00:13:51 +08:00
iven
9fce34f4ef fix(app): 修复 4 个 Flutter 交互问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged
   Stream 变更通知,HomeBloc 订阅后自动刷新
2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件,
   工具栏检测已激活工具时发出重新激活信号
3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数
   (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35)
4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制
   半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘
5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势
   替换 Pan 手势,支持双指缩放和旋转
2026-06-04 00:05:22 +08:00
iven
988ee7335a feat(app): 内容安全词库 + 过滤服务 + 分享前检查 — 28 个测试全覆盖
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
新增文件:
- sensitive_words.dart — 8 分类 ~200 条敏感词 + 谐音/形近/数字变体映射
- content_filter_service.dart — 精确匹配 + 变体匹配 + 文本预处理(去零宽/空格/符号)
- content_filter_service_test.dart — 28 个测试(8分类精确/安全内容/预处理/变体/边界/词库完整性)

修改:
- share_bottom_sheet.dart — 分享到班级前调用 ContentFilterService,
  有敏感词时弹出警告对话框(返回修改/仍然分享),新增 contentText 参数
2026-06-03 19:40:13 +08:00
iven
9c92cba87f test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖
ClassBloc (13 tests):
- 班级列表加载(成功/失败/loading 状态)
- 选中班级详情 + 子事件触发
- 成员/日记墙/主题/评语加载
- 创建班级 + 错误处理
- 加入班级 + 列表刷新
- 布置主题
- sharedToClass 过滤逻辑

SearchBloc (20 tests):
- 关键词搜索(标题/摘要/标签匹配、大小写不敏感)
- 心情筛选(null/有值/失败)
- 标签搜索
- 搜索历史(记录/去重)
- 清除搜索 + Tab 切换
- hasActiveFilter 属性
2026-06-03 19:03:29 +08:00
iven
f6d394afb6 test(app): 手写引擎 Canvas 集成测试 — 55 个测试全覆盖
4 个测试文件:
- stroke_model_test.dart (12 tests) — StrokePoint/Stroke 序列化、不可变性、默认值
- stroke_renderer_test.dart (19 tests) — parseHexColor/pointsToOutline/buildStrokePath/createPaintForStroke
- stroke_cache_test.dart (15 tests) — StrokeRasterCache 添加/同步/清除/尺寸变化/边界条件
- handwriting_canvas_test.dart (9 tests) — Widget 渲染结构、手势回调、去抖、预加载、连续笔画
2026-06-03 18:57:41 +08:00
iven
4cd08535d3 chore(app): 管理端品牌替换 — 移除所有 ERP 面向用户文字,统一暖记风格
- index.css 注释头 → 暖记管理后台 Design System
- PluginMarket.tsx 用户提示 → 扩展暖记能力
- package.json → nuanji-admin v0.1.0 + 项目描述
- CSS 注释 Typography → Chinese-first 暖记
2026-06-03 18:48:47 +08:00
iven
271f0c4f29 test(diary): 添加 9 个集成测试 + 修复 mood_stats 表名
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
集成测试 (TestDb + Service 层直接调用):
- test_journal_crud_full_lifecycle: 创建/查询/更新/列表/软删除全流程
- test_journal_version_conflict_on_update: 乐观锁版本冲突检测
- test_journal_tenant_isolation: 多租户数据隔离验证
- test_class_create_and_join: 班级创建+学生加入+成员查询+班级码重置
- test_sync_batch_create_and_fetch: 批量创建 3 篇日记同步
- test_sync_version_conflict_detection: 同步版本冲突检测
- test_mood_stats_aggregation: 心情统计 GROUP BY 聚合
- test_parent_binding_two_step_verification: 家长绑定两步验证
- test_achievement_list: 成就查询

修复:
- mood_stats_service: journal_entry → journal_entries 表名修正

测试: 518/518 全仓库通过 (含 9 新增集成测试)
2026-06-03 18:04:58 +08:00
iven
4cd381295a fix(app): Flutter Web 开发模式默认连接 localhost:3000 API
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- app.dart: kDebugMode 下使用 AppConfig.dev (localhost:3000)
- dev.mjs: flutter run 传入 --dart-define API_BASE_URL/SSE_BASE_URL
2026-06-03 17:50:55 +08:00
iven
8300822232 fix(diary): JournalResp 补充 assigned_topic_id 字段
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- dto.rs: JournalResp 添加 assigned_topic_id: Option<Uuid>
- journal_service model_to_resp: 映射 model.assigned_topic_id
- parent_handler journal_model_to_resp: 同步映射

Flutter 端 JournalEntry 已有 assignedTopicId,无需修改
测试: 84/84 通过
2026-06-03 17:46:50 +08:00
iven
367f21de08 feat(app): 统一同步协议 — SyncModels + ApiClient.sync + SyncEngine.tryBatchSync
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Flutter ↔ Rust 同步协议对齐:
- 新增 sync_models.dart: SyncReq/SyncResp/SyncChange/ConflictInfo
  与 Rust dto.rs 一一对应 (CreateJournal/UpdateJournal/DeleteJournal)
- ApiClient.sync(): 调用 POST /diary/sync 批量同步端点
- SyncEngine.tryBatchSync(): PendingOperation → SyncChange 批量提交
  成功清空队列,冲突保留待用户处理

保留原有逐个同步 trySync() 作为降级方案
后端 509/509 测试通过, Flutter analyze 0 error
2026-06-03 17:20:51 +08:00
iven
1766cefde9 refactor(diary): Service 层改用 DiaryEvent 枚举替代字符串事件
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- journal_service: 3 处 (JournalCreated/Updated/Deleted)
- class_service: 2 处 (ClassCreated/StudentJoinedClass)
- comment_service: 1 处 (CommentCreated)
- topic_service: 1 处 (TopicAssigned)
- parent_service: 1 处 confirm_binding → ParentBound

保留 DomainEvent::new 的场景:
- class_service deactivate_class (diary.class.deactivated)
- parent_service bind_child (diary.parent.binding_requested)
- parent_service delete_child_data (diary.parent.data_deleted)
以上事件不在 DiaryEvent 枚举中(非核心创建事件)

测试: 509/509 全部通过
2026-06-03 17:15:00 +08:00
iven
38592d61ce refactor(diary): Phase 3 质量提升 — 201 状态码 + OpenAPI 文档 + DiaryEvent 类型安全
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
前端:
- fix(app): Isar native 文件直接导入 isar_database_native.dart,消除 5 个条件导出类型错误
- chore(app): build_runner 重新生成 .g.dart 文件 (102 outputs)
- fix(app): 移除 secure_token_store_factory 未使用的 kIsWeb import

后端:
- refactor(diary): 所有创建端点 POST 返回 201 Created (9 handler, 11 端点)
- feat(diary): DiaryApiDoc OpenApi derive — 42 路径 + 32 Schema 汇总到 Swagger
- feat(diary): DiaryEvent 枚举添加 event_type/payload/to_domain_event 方法 + 4 测试

测试: 84/84 erp-diary 通过, 509/509 全仓库通过, Flutter analyze 0 error
2026-06-03 17:06:03 +08:00
iven
e8df3a9562 fix(app): 修复登录页 Logo 和文字未居中 — Stack alignment + Column mainAxisSize
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-03 16:36:20 +08:00
iven
32a91551c4 perf(app): Phase 2 前端性能优化 5 项 — 8b-D01/D02/D03/M02/N01
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 8b-D01: Isar 添加 authorId+dateEpoch 复合索引和 dateEpoch 单独索引
- 8b-D02: getJournals 分页改为 DB 层 .offset().limit() 替代 Dart 层 sublist
- 8b-D03: home_bloc monthCount 改用日期范围独立查询(不受分页限制)
- 8b-M02: 笔画光栅化改为 BBox 裁剪 — 短笔画不再创建全画布尺寸图像
  - _CacheEntry 增加 offset 字段记录 BBox 偏移
  - _rasterizeStroke 计算包围盒 + 4px padding
  - _compositeIncremental 使用 offset 定位
- 8b-N01: SyncEngine enqueue 合并同一资源的操作
  - create+update → create(最新数据)
  - update+update → update(最新数据)
  - update+delete → delete
  - create+delete → 取消(不发送)
- 注意: Isar .g.dart 需运行 build_runner 重新生成
2026-06-03 16:05:11 +08:00
iven
b6ffc60331 perf(diary): sticker_service 批量 GROUP BY 替代 N+1 贴纸计数 — 8a-C04
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- list_sticker_packs: 单次 SQL GROUP BY pack_id 获取所有计数
- 2 次查询(packs + counts)替代 N+1 次
- 使用 PostgreSQL ANY() 传递 UUID 数组
- 测试 80/80 通过
2026-06-03 15:51:05 +08:00
iven
4e5c1287a6 perf(diary): parent_service 批量软删除替代逐条 UPDATE — 8a-C03
- delete_child_data 改用单条 SQL UPDATE ... WHERE 批量软删除
- 1 次 SQL 替代 N 次逐条 UPDATE(从 O(N) 降到 O(1) 查询)
- 移除不再需要的 TransactionTrait 导入
- 测试 80/80 通过
2026-06-03 15:48:29 +08:00
iven
3258acaa77 perf(diary): sync_service 批量预查询 + 事务化 — 8a-C02
- 按操作类型分组 create/update/delete
- UPDATE/DELETE 目标单次 IN 查询替代逐条 find(消除 N+1)
- 冲突前置检测: 从预取 HashMap 判断版本,再过滤有效操作
- 所有写操作在单个事务内完成(原子化)
- 辅助函数改用 ConnectionTrait 泛型(兼容 DatabaseConnection 和 DatabaseTransaction)
- 测试 80/80 通过
2026-06-03 15:45:36 +08:00
iven
0c9ada242a perf(diary): mood_stats 改用 SQL GROUP BY 替代全量加载 — 8a-C01
- get_mood_stats: SELECT mood, COUNT(*) GROUP BY 替代 all() + Rust 迭代
- calculate_streak: 仅查 date 列 + DISTINCT + 366天窗口裁剪
- 新增 mood_counts_map_aggregation 单元测试
- 测试 78/78 通过
2026-06-03 15:37:09 +08:00
iven
99db8e5cb0 fix(app): 家长同意验证流程 — PIPL 第28条合规
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增 ParentalConsentPage: 显示隐私政策要点 + 双重确认复选框
- 角色选择流程: 学生 → 家长同意确认 → 班级码输入
- Authenticated 状态: 添加 needsParentalConsent/parentalConsentAt/selectedRole
- ParentalConsentAccepted 事件: 记录同意时间戳
- 路由守卫: 注册 /parental-consent 路径和重定向逻辑
- 非学生角色(老师/家长/独立用户)不需要经过同意流程

审计 ID: S-03
2026-06-03 10:25:23 +08:00
iven
a34c9fd176 fix(app): 强制 HTTPS — Android 网络安全配置 + 生产默认 HTTPS
- Android: 添加 network_security_config.xml,默认禁止明文流量
- Android: 仅允许 localhost/127.0.0.1/10.0.2.2 明文(开发调试)
- Android: 更新 AndroidManifest 引用网络安全配置
- ApiClient: 默认 URL 改为 https://api.nuanji.app/api/v1
- AppConfig: fromEnvironment 默认值改为 HTTPS 生产地址
- AppConfig: dev 常量保留 localhost(仅用于本地开发)
- iOS: ATS 默认已强制 HTTPS,无需修改

审计 ID: 6b-C01
2026-06-03 10:13:20 +08:00
iven
45949e3ed0 fix(app): Token 自动刷新拦截器 — 401 时自动刷新 + 重试原请求
- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新
- ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新
- ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环)
- ApiClient: 刷新成功后自动重试原始请求
- AuthRepository: 注册 _handleAutoRefresh 回调
- 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖

审计 ID: 9a-AUTH-01
2026-06-03 10:07:33 +08:00
iven
c4b2de8294 fix(diary): 家长绑定改为两步验证 — 孩子确认后才生效
- bind_child: 创建 pending 状态绑定(不再自动 verified)
- validate_child_user: 验证目标用户存在且有 student 角色
- confirm_binding: 孩子确认后状态变为 verified,家长获得访问权限
- reject_binding: 孩子拒绝绑定请求
- list_pending_for_child: 孩子查看待确认绑定列表
- 新增 3 个 API 端点: /parent/pending, /bindings/{id}/confirm, /bindings/{id}/reject
- 防止未授权绑定(任何人不验证即可绑定孩子的漏洞)

审计 ID: S-10
2026-06-03 10:03:50 +08:00
iven
cca2d77ea2 fix(diary): 班级码改用字母数字混合 — 16^6 提升到 62^6(568 亿组合)
- 从 UUID hex 后 6 位(0-9a-f,16^6 ≈ 1677 万)改为字母数字混合
- 字符集: 0-9A-Za-z(62 字符,62^6 ≈ 568 亿)
- 使用 UUID v7 后 8 字节随机部分作为熵源,避免同毫秒碰撞
- 符合 CLAUDE.md 设计规格要求

审计 ID: 7b-C01
2026-06-03 09:56:24 +08:00
iven
6d7ac05d0f fix(auth): Token 黑名单改用 SHA-256 替代 SipHash
- access token 黑名单 hash 函数从 std::collections::DefaultHasher (SipHash)
  改为 sha2::Sha256,与 refresh token 存储一致
- SipHash 是非密码学 hash,理论上可被构造碰撞绕过黑名单检查
- SHA-256 提供密码学安全保证,且 sha2 已在 Cargo.toml 依赖中

审计 ID: S-01
2026-06-03 09:51:47 +08:00
iven
11d0971a67 feat(app): pnpm 一键启动 + Flutter Web 编译修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
1. 新增 pnpm start:dev / pnpm start:stop 命令
   - scripts/dev.mjs: 跨平台启动脚本(后端+管理端+学生端)
   - scripts/stop.mjs: 端口清理停止脚本
   - 根 package.json 定义 pnpm 脚本

2. 修复 Flutter Web 编译(Isar 3.x + flutter_secure_storage 不兼容)
   - isar_database: 条件导出,Web 用空 stub
   - isar_journal_repository: 条件导出,Web 用空 stub
   - sync_engine: 条件导出,Web 用内存队列(无 Isar 持久化)
   - 移除 flutter_secure_storage(v9 web 插件用 dart:html)
   - 新增 SecureTokenStore 接口 + shared_preferences 实现
   - auth_repository 改用 SecureTokenStore 接口
2026-06-03 09:50:19 +08:00
iven
b81a972245 fix(diary): 为所有 DTO 添加 Validate derive + handler 调用 validate()
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
DTO 验证规则:
- CreateJournalReq: title 1-200, tags ≤20
- UpdateJournalReq: title 1-200, tags ≤20
- CreateClassReq: name 1-50, school_name ≤100
- JoinClassReq: class_code = 6位
- UpdateClassReq: name 1-50, school_name ≤100
- SyncReq: changes ≤100 条
- CreateTopicReq: title 1-200, description ≤2000
- UpdateTopicReq: title 1-200, description ≤2000
- CreateCommentReq: content 1-1000
- CreateStickerPackReq: name 1-50, description ≤500
- UpdateStickerPackReq: name 1-50, description ≤500
- CreateStickerReq: name 1-30, image_url 1-500
- BindChildReq/DeleteChildDataReq: Validate derive (Uuid 已由 serde 验证)

Handler 调用: validate() 放在 require_permission() 之前(先验证输入再检查权限)

审计 ID: 5a-C01, 5a-C02, 5a-C03
2026-06-03 01:14:23 +08:00
iven
af7d3f65fd fix(diary): 修复日记列表 IDOR — 非管理角色只能查看自己的日记
- list_journals: 学生/家长等非管理角色强制 author_id = ctx.user_id
- get_journal: 非管理角色查看他人日记返回 404(避免信息泄露)
- 老师/管理员角色保留查看任意日记的能力(点评/管理需要)
- 家长应通过 parent_service 专用端点查看孩子日记

审计 ID: S-07
2026-06-03 01:08:00 +08:00
iven
9ce300ddb9 fix(app): 修复笔画缓存 use-after-dispose — 移除增量合成时的提前 dispose
- _compositeIncremental 中不再 dispose strokeImage,因为 _cache 持有同一引用
- 提前 dispose 导致 syncStrokes/clear/dispose 时 double-dispose(use-after-free)
- 单笔画 image 生命周期由缓存统一管理:移除/清除/销毁时释放
- 更新 _rebuildComposite 注释,移除过时说明

审计 ID: 8b-R01
2026-06-03 01:06:34 +08:00
iven
e0052ea99b fix(diary): 添加事务 — create_class/join_class/parent 删除原子化
- create_class: 班级创建 + 老师成员插入包裹在 db.transaction() 中
- join_class: 成员插入 + member_count 更新包裹在事务中
- delete_child_data: PIPL 删除权 — 逐条软删除包裹在事务中(避免部分删除)
- DiaryError: 添加 From<TransactionError<DiaryError>> 支持事务闭包

审计 ID: B-07, B-11, 8a-C03
2026-06-03 01:03:57 +08:00
iven
1750f17f41 fix(diary): 修复 class_service unwrap() — 改为从 Model 安全取值
- join_class 中 member_count.unwrap() 和 version.unwrap() 替换为
  在 Model → ActiveModel 转换前直接读取 model 字段值
- 消除潜在的 panic 风险,保持代码可读性

审计 ID: B-01
2026-06-03 00:58:58 +08:00
iven
5f06056d26 fix(server): 添加权限守卫 — 审计日志 + 文件上传 + diary.comment.delete 种子
- audit_log handler: 添加 require_permission("audit.log.list") 守卫
- upload handler: 添加 require_permission("file.upload") 守卫
- 种子数据: 新增 audit.log.list / file.upload / diary.comment.delete 权限定义
- 角色种子: admin 获得 audit.log.list + file.upload + diary.comment.delete 权限
- diary.comment.delete 已在 teacher 列表中(种子定义之前缺失)

审计 ID: 5b-C01, 5b-C02, 4a-C02
2026-06-03 00:57:39 +08:00
iven
935918c9ab fix(server): 修复 RLS 变量名 bug — app.current_tenant → app.current_tenant_id + 空值保护
- 变量名从不存在的 app.current_tenant 修正为 app.current_tenant_id(与中间件一致)
- 添加空值保护:current_setting(...) != '' AND tenant_id = ...(与基座 m000088 严格模式一致)
- 移除 FORCE ROW LEVEL SECURITY,与基座表保持一致(允许迁移/管理操作绕过)
- 添加 DROP POLICY IF EXISTS 幂等保护

审计 ID: 4a-C01, 4b-C01, 4b-C02
2026-06-03 00:55:00 +08:00
iven
d482497e49 fix(app): 修复 smoke test — 改为验证主题构建,避免 Isar 依赖
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-03 00:00:15 +08:00
iven
45530616ee feat(diary): 添加贴纸包 UpdateStickerPackReq DTO + update service/handler — Task 13
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-02 23:54:04 +08:00
iven
d6dd017155 feat(web): 贴纸包 CRUD UI + 主题编辑/停用 — Task 14-15 完成
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Task 14: StickerPackList 补全 CRUD UI
- stickers.ts: 添加 createPack/deletePack/createSticker API
- StickerPackList: 新建贴纸包按钮 + 创建表单 Modal
- StickerPackList: 卡片添加删除按钮 (Popconfirm)

Task 15: TopicList 补全编辑/停用
- topics.ts: 添加 update/deactivate API
- TopicList: 编辑 Modal (标题/描述/截止日期)
- TopicList: 卡片添加编辑+停用按钮

附带修复:
- types.ts: SchoolClass/TopicAssignment 添加 version 字段
- ClassList.tsx: 修复 onUpdate 回调参数签名
- tsconfig.app.json: 排除 src/test 避免缺失模块编译错误
2026-06-02 23:40:46 +08:00
iven
f0741450bc feat(app): 家长端数据导出 — 添加 JSON 文件下载 + 预览
- 新建 file_download.dart 跨平台下载工具(conditional import)
- download_impl_web.dart: Web 平台通过 html.AnchorElement + Blob 下载
- download_impl.dart: 非 Web 平台 stub(Phase 2 扩展 path_provider)
- _ExportDataView: 添加下载按钮 + JSON 折叠预览 + PIPL 提示
- 移除 'Phase 1 预览' 占位文案,替换为完整下载功能
2026-06-02 23:36:35 +08:00
iven
c9a69d0be1 feat(app): 添加评论列表展示组件 — FutureBuilder 轮询模式 2026-06-02 23:26:54 +08:00
iven
9e53ca8555 feat(app): EditorPage 顶栏添加评语入口 — 仅已有日记显示
- 添加评语图标按钮(仅 journalId != null 时显示)
- 实现 _showComments 方法打开 CommentListSheet
- 补充 api_client + comment_list_sheet imports
2026-06-02 23:26:24 +08:00
iven
6c9a38b27b feat(app): 添加 EditorBloc.LoadJournal event — 加载已有日记数据
- LoadJournal event: 原子加载 title/mood/tags/strokes/elements/lastSavedAt
- _onLoadJournal handler: 不触发 auto-save (isDirty=false)
- 单元测试: 验证 LoadJournal 正确还原所有状态字段
- mood_bloc: linter 补充 foundation.dart import
2026-06-02 23:23:17 +08:00
iven
e57c3427a4 fix(app): 18 处 catch(e) 添加 debugPrint 异常日志
- parent_bloc: 6 处 (LoadChildren/BindChild/ViewJournals/ExportData/DeleteData/UnbindChild)
- search_bloc: 3 处 (SearchByMood/SearchByTag/SearchByKeyword)
- achievement_bloc: 1 处 (_fetchAchievements)
- sticker_bloc: 2 处 (_fetchPacks/fetchStickersInPack)
- template_bloc: 1 处 (_fetchTemplates)
- mood_bloc: 1 处 (_loadStats)
- home_bloc: 1 处 (_onLoadData)
- calendar_bloc: 1 处 (_onMonthChanged)
- sync_engine: 1 处 (trySync)
- weekly_page: 已有 debugPrint,无需修改
2026-06-02 23:21:16 +08:00
iven
c92ead60e3 feat(app): EditorPage 加载已有日记 — 替换为 LoadJournal 原子事件
- _loadExistingJournal 改用单一 LoadJournal event 替代多个细粒度事件
- 添加 _titleController 同步,确保 LoadJournal 后标题输入框正确显示
- 不触发 auto-save (isDirty=false),因为这是加载而非用户编辑
2026-06-02 23:16:58 +08:00
iven
ab45f40cc8 docs: 修订实施计划 — 修复 EditorView 目标类/apiClient.data/主题后端已存在 2026-06-02 23:06:11 +08:00
169 changed files with 12203 additions and 2119 deletions

View File

@@ -1,9 +1,19 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "com.nuanji.nuanji_app" namespace = "com.nuanji.nuanji_app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -15,21 +25,33 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.nuanji.nuanji_app" applicationId = "com.nuanji.nuanji_app"
// You can update the following values to match your application needs. minSdk = 24 // Android 7.0+ — 支持 Isar 原生库 + CameraX
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName 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 { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.getByName("release")
// Signing with the debug keys for now, so `flutter run --release` works. isMinifyEnabled = true
signingConfig = signingConfigs.getByName("debug") isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
} }

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

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

View File

@@ -1,8 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <application
android:label="nuanji_app" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item android:drawable="@color/bg_light" />
<item>
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
android:src="@mipmap/launch_image" /> android:src="@drawable/launch_logo" />
</item> --> </item>
</layer-list> </layer-list>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <item name="android:windowBackground">@drawable/launch_background_dark</item>
the Flutter engine draws its first frame --> <item name="android:statusBarColor">@color/bg_dark</item>
<item name="android:windowBackground">@drawable/launch_background</item>
</style> </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"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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"> <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:windowBackground">@drawable/launch_background</item>
<item name="android:statusBarColor">@color/bg_light</item>
</style> </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"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>

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

View File

@@ -1,5 +1,8 @@
allprojects { allprojects {
repositories { repositories {
// 阿里云 Maven 镜像 — 加速中国大陆依赖下载
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google() google()
mavenCentral() mavenCentral()
} }
@@ -15,6 +18,28 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) 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 { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }

View File

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

View File

@@ -11,6 +11,10 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories { 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() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()

View File

@@ -10,15 +10,17 @@
// └─ BlocProvider<AuthBloc> // └─ BlocProvider<AuthBloc>
// └─ MaterialApp.router // └─ 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart' show ListenableProvider; import 'package:provider/provider.dart' show ListenableProvider;
import 'package:shared_preferences/shared_preferences.dart';
import 'config/app_config.dart'; import 'config/app_config.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/routing/app_router.dart'; import 'core/routing/app_router.dart';
import 'data/local/secure_token_store_factory.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';
@@ -30,63 +32,99 @@ import 'features/auth/bloc/auth_bloc.dart';
import 'features/profile/bloc/settings_bloc.dart'; import 'features/profile/bloc/settings_bloc.dart';
/// 暖记 App — 根组件 /// 暖记 App — 根组件
class NuanjiApp extends StatelessWidget { class NuanjiApp extends StatefulWidget {
const NuanjiApp({super.key}); const NuanjiApp({super.key});
@override @override
Widget build(BuildContext context) { State<NuanjiApp> createState() => _NuanjiAppState();
// 创建全局依赖App 生命周期内单例) }
final config = AppConfig.fromEnvironment();
final apiClient = ApiClient(baseUrl: config.apiBaseUrl); class _NuanjiAppState extends State<NuanjiApp> {
final authRepository = AuthRepository(apiClient: apiClient); late final ApiClient _apiClient;
// 离线优先Isar 为主要本地仓库Remote 供 SyncEngine 推送 late final AuthRepository _authRepository;
// Web 平台Isar 3.x 不支持 Web直接使用远程仓库 late final JournalRepository _journalRepository;
final journalRepository = kIsWeb late final RemoteJournalRepository _remoteJournalRepository;
? RemoteJournalRepository(api: apiClient) 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(); : IsarJournalRepository();
final remoteJournalRepository = RemoteJournalRepository(api: apiClient); _remoteJournalRepository = RemoteJournalRepository(api: _apiClient);
final syncEngine = SyncEngine(apiClient: apiClient); _syncEngine = SyncEngine(apiClient: _apiClient);
final classRepository = ClassRepository(api: apiClient); _classRepository = ClassRepository(api: _apiClient);
final settingsBloc = SettingsBloc(); _settingsBloc = SettingsBloc(
final authBloc = AuthBloc( prefs: await SharedPreferences.getInstance(),
authRepository: authRepository, );
classRepository: classRepository, _authBloc = AuthBloc(
authRepository: _authRepository,
classRepository: _classRepository,
); );
// 启动时检查认证状态 // 启动时检查认证状态
authBloc.add(const AppStarted()); _authBloc.add(const AppStarted());
// 异步恢复 SyncEngine 持久化队列fire-and-forget不阻塞 UI // 异步恢复 SyncEngine 持久化队列
syncEngine.restorePendingQueue(); _syncEngine.restorePendingQueue();
// 启动网络监听 — 网络恢复时自动触发 trySync() _syncEngine.startAutoSync();
syncEngine.startAutoSync();
// 认证状态监听:登出时清除 token // 认证状态监听:登出时清除 token
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient _authBloc.stream.listen((state) {
authBloc.stream.listen((state) {
if (state is! Authenticated) { if (state is! Authenticated) {
apiClient.clearToken(); _apiClient.clearToken();
} }
}); });
// Token 刷新彻底失败时 → 派发 AuthExpired
_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( return MultiRepositoryProvider(
providers: [ providers: [
RepositoryProvider<ApiClient>.value(value: apiClient), RepositoryProvider<ApiClient>.value(value: _apiClient),
RepositoryProvider<AuthRepository>.value(value: authRepository), RepositoryProvider<AuthRepository>.value(value: _authRepository),
RepositoryProvider<JournalRepository>.value(value: journalRepository), RepositoryProvider<JournalRepository>.value(value: _journalRepository),
RepositoryProvider<RemoteJournalRepository>.value(value: remoteJournalRepository), RepositoryProvider<RemoteJournalRepository>.value(value: _remoteJournalRepository),
RepositoryProvider<SyncEngine>.value(value: syncEngine), RepositoryProvider<SyncEngine>.value(value: _syncEngine),
RepositoryProvider<ClassRepository>.value(value: classRepository), RepositoryProvider<ClassRepository>.value(value: _classRepository),
], ],
child: ListenableProvider<SettingsBloc>.value( child: ListenableProvider<SettingsBloc>.value(
value: settingsBloc, value: _settingsBloc,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final settings = context.watch<SettingsBloc>(); final settings = context.watch<SettingsBloc>();
return BlocProvider<AuthBloc>.value( return BlocProvider<AuthBloc>.value(
value: authBloc, value: _authBloc,
child: _AppView( child: _AppView(
router: createAppRouter(authBloc), router: createAppRouter(_authBloc),
themeMode: settings.state.themeMode, themeMode: settings.state.themeMode,
), ),
); );

View File

@@ -1,8 +1,12 @@
// 应用环境配置 — 通过 --dart-define 注入 // 应用环境配置 — 通过 --dart-define 注入
// //
// 使用方式: // 使用方式:
// flutter run --dart-define=API_BASE_URL=http://localhost:3000/api/v1 // flutter run # 开发模式localhost
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 // flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 # 生产模式
//
// 安全说明:
// - 生产环境强制 HTTPSAndroid network_security_config 禁止明文流量)
// - 开发模式使用 localhostAndroid 网络安全配置已允许 localhost 明文)
/// 应用环境配置 — 集中管理所有外部服务地址 /// 应用环境配置 — 集中管理所有外部服务地址
class AppConfig { class AppConfig {
@@ -19,19 +23,20 @@ class AppConfig {
/// 从编译时环境变量构建配置 /// 从编译时环境变量构建配置
/// ///
/// 使用 `--dart-define` 注入,未设置时使用默认值。 /// 使用 `--dart-define` 注入,未设置时使用生产 HTTPS 默认值。
/// 开发环境使用 [dev] 常量或通过 --dart-define 覆盖。
factory AppConfig.fromEnvironment({ factory AppConfig.fromEnvironment({
String defaultApiBaseUrl = 'http://localhost:3000/api/v1', String defaultApiBaseUrl = 'https://api.nuanji.app/api/v1',
String defaultSseBaseUrl = 'http://localhost:3000/api/v1', String defaultSseBaseUrl = 'https://api.nuanji.app/api/v1',
}) { }) {
// const String.fromEnvironment 在编译时求值 // const String.fromEnvironment 在编译时求值
const apiBaseUrl = String.fromEnvironment( const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'http://localhost:3000/api/v1', defaultValue: 'https://api.nuanji.app/api/v1',
); );
const sseBaseUrl = String.fromEnvironment( const sseBaseUrl = String.fromEnvironment(
'SSE_BASE_URL', 'SSE_BASE_URL',
defaultValue: 'http://localhost:3000/api/v1', defaultValue: 'https://api.nuanji.app/api/v1',
); );
return AppConfig( return AppConfig(
@@ -40,7 +45,7 @@ class AppConfig {
); );
} }
/// 开发环境默认配置 /// 开发环境默认配置localhost 明文 — 仅用于本地调试)
static const dev = AppConfig( static const dev = AppConfig(
apiBaseUrl: 'http://localhost:3000/api/v1', apiBaseUrl: 'http://localhost:3000/api/v1',
sseBaseUrl: 'http://localhost:3000/api/v1', sseBaseUrl: 'http://localhost:3000/api/v1',

View File

@@ -28,6 +28,7 @@ import '../../features/profile/views/profile_page.dart';
import '../../features/editor/views/editor_page.dart'; import '../../features/editor/views/editor_page.dart';
import '../../features/auth/views/login_page.dart'; import '../../features/auth/views/login_page.dart';
import '../../features/auth/views/role_selection_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/auth/views/class_code_join_page.dart';
import '../../features/onboarding/views/splash_page.dart'; import '../../features/onboarding/views/splash_page.dart';
import '../../features/onboarding/views/onboarding_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/settings/views/settings_page.dart';
import '../../features/auth/bloc/auth_bloc.dart'; import '../../features/auth/bloc/auth_bloc.dart';
import '../../features/search/bloc/search_bloc.dart'; import '../../features/search/bloc/search_bloc.dart';
import '../../features/discover/bloc/discover_bloc.dart';
import '../../data/repositories/journal_repository.dart'; import '../../data/repositories/journal_repository.dart';
import '../../data/remote/api_client.dart'; import '../../data/remote/api_client.dart';
@@ -49,7 +51,7 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = 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 /// 创建路由配置 — 需要注入 AuthBloc
GoRouter createAppRouter(AuthBloc authBloc) { GoRouter createAppRouter(AuthBloc authBloc) {
@@ -74,6 +76,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
// 已认证 + 访问公开页面 → 根据状态重定向 // 已认证 + 访问公开页面 → 根据状态重定向
if (isAuthenticated && isPublicPath) { if (isAuthenticated && isPublicPath) {
if (authState.needsRoleSelection) return '/role-selection'; if (authState.needsRoleSelection) return '/role-selection';
if (authState.needsParentalConsent) return '/parental-consent';
if (authState.needsClassCode) return '/class-code'; if (authState.needsClassCode) return '/class-code';
return '/home'; return '/home';
} }
@@ -83,9 +86,14 @@ GoRouter createAppRouter(AuthBloc authBloc) {
if (authState.needsRoleSelection && currentPath != '/role-selection') { if (authState.needsRoleSelection && currentPath != '/role-selection') {
return '/role-selection'; return '/role-selection';
} }
if (authState.needsParentalConsent &&
currentPath != '/parental-consent') {
return '/parental-consent';
}
if (authState.needsClassCode && if (authState.needsClassCode &&
currentPath != '/class-code' && currentPath != '/class-code' &&
currentPath != '/role-selection') { currentPath != '/role-selection' &&
currentPath != '/parental-consent') {
return '/class-code'; return '/class-code';
} }
return null; return null;
@@ -125,6 +133,11 @@ GoRouter createAppRouter(AuthBloc authBloc) {
name: 'roleSelection', name: 'roleSelection',
builder: (context, state) => const RoleSelectionPage(), builder: (context, state) => const RoleSelectionPage(),
), ),
GoRoute(
path: '/parental-consent',
name: 'parentalConsent',
builder: (context, state) => const ParentalConsentPage(),
),
GoRoute( GoRoute(
path: '/class-code', path: '/class-code',
name: 'classCode', name: 'classCode',
@@ -156,7 +169,13 @@ GoRouter createAppRouter(AuthBloc authBloc) {
GoRoute( GoRoute(
path: '/discover', path: '/discover',
name: '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( GoRoute(

View File

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

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

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

View File

@@ -0,0 +1,23 @@
// 文件下载工具 — 跨平台接口
//
// Web: 通过 html.AnchorElement + Blob 触发浏览器下载
// 非 Web: 返回 falsePhase 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');
}

View File

@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
const JournalElementCollectionSchema = CollectionSchema( const JournalElementCollectionSchema = CollectionSchema(
name: r'JournalElementCollection', name: r'JournalElementCollection',
id: -1002, id: -3625932583395690305,
properties: { properties: {
r'contentJson': PropertySchema( r'contentJson': PropertySchema(
id: 0, id: 0,
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
idName: r'isarId', idName: r'isarId',
indexes: { indexes: {
r'id': IndexSchema( r'id': IndexSchema(
id: -2002, id: -3268401673993471357,
name: r'id', name: r'id',
unique: false, unique: false,
replace: false, replace: false,
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
], ],
), ),
r'journalId': IndexSchema( r'journalId': IndexSchema(
id: 3001, id: 1745640946427815323,
name: r'journalId', name: r'journalId',
unique: false, unique: false,
replace: false, replace: false,

View File

@@ -16,7 +16,8 @@ class JournalEntryCollection {
@Index() @Index()
String id = ''; String id = '';
/// 作者 ID /// 作者 ID(索引 + 组合索引 authorId+dateEpoch覆盖按作者查询并按日期排序的场景
@Index(composite: [CompositeIndex('dateEpoch')])
String authorId = ''; String authorId = '';
/// 班级 ID可选 /// 班级 ID可选
@@ -25,7 +26,8 @@ class JournalEntryCollection {
/// 日记标题 /// 日记标题
String title = ''; String title = '';
/// 日记日期epoch milliseconds /// 日记日期epoch milliseconds— 单独索引支持日期范围查询
@Index()
int dateEpoch = 0; int dateEpoch = 0;
/// 心情enum → string /// 心情enum → string

View File

@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
const JournalEntryCollectionSchema = CollectionSchema( const JournalEntryCollectionSchema = CollectionSchema(
name: r'JournalEntryCollection', name: r'JournalEntryCollection',
id: -1001, id: -6325316395299921961,
properties: { properties: {
r'assignedTopicId': PropertySchema( r'assignedTopicId': PropertySchema(
id: 0, id: 0,
@@ -106,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
idName: r'isarId', idName: r'isarId',
indexes: { indexes: {
r'id': IndexSchema( r'id': IndexSchema(
id: -2001, id: -3268401673993471357,
name: r'id', name: r'id',
unique: false, unique: false,
replace: false, replace: false,
@@ -117,6 +117,37 @@ const JournalEntryCollectionSchema = CollectionSchema(
caseSensitive: true, 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: {}, links: {},
@@ -277,6 +308,15 @@ extension JournalEntryCollectionQueryWhereSort
return query.addWhereClause(const IdWhereClause.any()); 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< 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< extension JournalEntryCollectionQueryFilter on QueryBuilder<

View File

@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
const PendingOperationCollectionSchema = CollectionSchema( const PendingOperationCollectionSchema = CollectionSchema(
name: r'PendingOperationCollection', name: r'PendingOperationCollection',
id: -1003, id: -6885010264946527864,
properties: { properties: {
r'createdAtEpoch': PropertySchema( r'createdAtEpoch': PropertySchema(
id: 0, id: 0,
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
idName: r'isarId', idName: r'isarId',
indexes: { indexes: {
r'id': IndexSchema( r'id': IndexSchema(
id: -2003, id: -3268401673993471357,
name: r'id', name: r'id',
unique: false, unique: false,
replace: false, replace: false,

View File

@@ -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 初始化,使用纯内存/远程模式。 // dart.library.io 存在 → 原生平台,使用 native 实现
// 生产环境以移动端 (Android/iOS) 为主。 // 否则Web→ 使用 web stub
//
// 使用方式不变import 'isar_database.dart';
// 用 IsarDatabase.isAvailable 判断平台可用性。
import 'package:flutter/foundation.dart' show kIsWeb; export 'isar_database_web.dart' if (dart.library.io) 'isar_database_native.dart';
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();
});
}
}

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

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

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

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

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

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

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

View File

@@ -1,14 +1,17 @@
// API 客户端 — Dio 封装 + JWT 注入 + 离线感知 // API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
// //
// 核心职责: // 核心职责:
// - 封装 Dio HTTP 客户端,统一配置超时和头信息 // - 封装 Dio HTTP 客户端,统一配置超时和头信息
// - JWT token 自动注入(请求拦截器) // - JWT token 自动注入(请求拦截器)
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01
// - 离线状态感知(网络不可用时抛出明确异常) // - 离线状态感知(网络不可用时抛出明确异常)
// - 为 SyncEngine 提供远程操作能力 // - 为 SyncEngine 提供远程操作能力
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import '../models/sync_models.dart';
/// 网络离线异常 — 网络不可用时由 ApiClient 抛出 /// 网络离线异常 — 网络不可用时由 ApiClient 抛出
class OfflineException implements Exception { class OfflineException implements Exception {
final String message; final String message;
@@ -26,7 +29,27 @@ class ApiClient {
/// 基础 URL默认指向本地开发服务器 /// 基础 URL默认指向本地开发服务器
final String baseUrl; 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( _dio = Dio(BaseOptions(
baseUrl: baseUrl, baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10), connectTimeout: const Duration(seconds: 10),
@@ -48,12 +71,39 @@ class ApiClient {
}, },
)); ));
// 响应拦截器:统一错误处理 // 响应拦截器:401 自动刷新 token + 重试
_dio.interceptors.add(InterceptorsWrapper( _dio.interceptors.add(InterceptorsWrapper(
onError: (error, handler) { onError: (error, handler) async {
// 401 时自动清除 token需要重新登录
if (error.response?.statusCode == 401) { 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; _token = null;
onAuthFailed?.call();
} }
handler.next(error); handler.next(error);
}, },
@@ -146,4 +196,19 @@ class ApiClient {
}); });
return _dio.post<T>(path, data: formData); 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!);
}
} }

View File

@@ -2,14 +2,14 @@
// //
// 职责: // 职责:
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出) // - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌PIPL 合规) // - 通过 SecureTokenStore 安全持久化 JWT 令牌PIPL 合规)
// - 为 AuthBloc 提供干净的认证数据访问接口 // - 为 AuthBloc 提供干净的认证数据访问接口
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import '../local/secure_token_store.dart';
import '../models/auth_token.dart'; import '../models/auth_token.dart';
import '../models/user.dart'; import '../models/user.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
@@ -33,11 +33,11 @@ class AuthException implements Exception {
/// 认证仓库 — 管理用户登录状态和令牌 /// 认证仓库 — 管理用户登录状态和令牌
/// ///
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。 /// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求 /// 原生平台使用加密存储Web 平台使用 shared_preferences
class AuthRepository { class AuthRepository {
final ApiClient _apiClient; final ApiClient _apiClient;
final FlutterSecureStorage _secureStorage; final SecureTokenStore _tokenStore;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0)); final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthToken? _currentToken; AuthToken? _currentToken;
@@ -45,13 +45,12 @@ class AuthRepository {
AuthRepository({ AuthRepository({
required ApiClient apiClient, required ApiClient apiClient,
FlutterSecureStorage? secureStorage, required SecureTokenStore tokenStore,
}) : _apiClient = apiClient, }) : _apiClient = apiClient,
_secureStorage = secureStorage ?? _tokenStore = tokenStore {
const FlutterSecureStorage( // 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
aOptions: AndroidOptions(encryptedSharedPreferences: true), _apiClient.onRefreshToken = _handleAutoRefresh;
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), }
);
/// 当前用户(可能为 null /// 当前用户(可能为 null
User? get currentUser => _currentUser; User? get currentUser => _currentUser;
@@ -167,10 +166,10 @@ class AuthRepository {
_logger.d('恢复认证状态'); _logger.d('恢复认证状态');
try { try {
final accessToken = await _secureStorage.read(key: _keyAccessToken); final accessToken = await _tokenStore.read(_keyAccessToken);
final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken); final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt); final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
final userJsonStr = await _secureStorage.read(key: _keyUserJson); final userJsonStr = await _tokenStore.read(_keyUserJson);
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) { if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
_logger.d('无存储的认证信息'); _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 字段 /// 从 API 响应中提取 data 字段
@@ -238,27 +264,27 @@ class AuthRepository {
_currentToken = token; _currentToken = token;
_currentUser = user; _currentUser = user;
await _saveToken(token); await _saveToken(token);
await _secureStorage.write( await _tokenStore.write(
key: _keyUserJson, _keyUserJson,
value: jsonEncode(user.toJson()), jsonEncode(user.toJson()),
); );
} }
/// 仅保存令牌到安全存储 /// 仅保存令牌到安全存储
Future<void> _saveToken(AuthToken token) async { Future<void> _saveToken(AuthToken token) async {
_currentToken = token; _currentToken = token;
await _secureStorage.write(key: _keyAccessToken, value: token.accessToken); await _tokenStore.write(_keyAccessToken, token.accessToken);
await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken); await _tokenStore.write(_keyRefreshToken, token.refreshToken);
await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String()); await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
} }
/// 清除所有认证数据 /// 清除所有认证数据
Future<void> _clearAuth() async { Future<void> _clearAuth() async {
_currentToken = null; _currentToken = null;
_currentUser = null; _currentUser = null;
await _secureStorage.delete(key: _keyAccessToken); await _tokenStore.delete(_keyAccessToken);
await _secureStorage.delete(key: _keyRefreshToken); await _tokenStore.delete(_keyRefreshToken);
await _secureStorage.delete(key: _keyExpiresAt); await _tokenStore.delete(_keyExpiresAt);
await _secureStorage.delete(key: _keyUserJson); await _tokenStore.delete(_keyUserJson);
} }
} }

View File

@@ -1,362 +1,7 @@
// Isar 本地日记仓库 — 本地优先数据存储 // Isar 本地日记仓库 — 条件导出
// //
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。 // 根据平台选择实现:
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。 // - 原生平台 → isar_journal_repository_native.dartIsar 本地数据库)
// // - Web 平台 → isar_journal_repository_web.dart空 stub应使用 RemoteJournalRepository
// 转换层:
// - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection
// - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection
import 'dart:convert'; export 'isar_journal_repository_web.dart' if (dart.library.io) 'isar_journal_repository_native.dart';
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),
);
}
}

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

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

View File

@@ -6,6 +6,8 @@
// - SyncEngine 负责协调本地和远程仓库之间的数据同步 // - SyncEngine 负责协调本地和远程仓库之间的数据同步
// - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代 // - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代
import 'dart:async';
import '../models/journal_entry.dart'; import '../models/journal_entry.dart';
import '../models/journal_element.dart'; import '../models/journal_element.dart';
@@ -52,6 +54,9 @@ abstract class JournalRepository {
/// 从日记中移除元素 /// 从日记中移除元素
Future<void> removeElement(String elementId); Future<void> removeElement(String elementId);
/// 日记变更通知流 — create/update/delete 时发出信号
Stream<void> get onJournalChanged;
} }
/// 内存实现 — 用于开发阶段快速迭代和单元测试 /// 内存实现 — 用于开发阶段快速迭代和单元测试
@@ -61,6 +66,10 @@ abstract class JournalRepository {
class InMemoryJournalRepository implements JournalRepository { class InMemoryJournalRepository implements JournalRepository {
final Map<String, JournalEntry> _journals = {}; final Map<String, JournalEntry> _journals = {};
final Map<String, JournalElement> _elements = {}; final Map<String, JournalElement> _elements = {};
final StreamController<void> _changeController = StreamController<void>.broadcast();
@override
Stream<void> get onJournalChanged => _changeController.stream;
@override @override
Future<List<JournalEntry>> getJournals({ Future<List<JournalEntry>> getJournals({
@@ -122,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository {
@override @override
Future<JournalEntry> createJournal(JournalEntry entry) async { Future<JournalEntry> createJournal(JournalEntry entry) async {
_journals[entry.id] = entry; _journals[entry.id] = entry;
_changeController.add(null);
return entry; return entry;
} }
@@ -145,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository {
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
); );
_journals[entry.id] = updated; _journals[entry.id] = updated;
_changeController.add(null);
return updated; return updated;
} }
@@ -154,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository {
_journals.remove(id); _journals.remove(id);
// 同时移除关联元素 // 同时移除关联元素
_elements.removeWhere((_, e) => e.journalId == id); _elements.removeWhere((_, e) => e.journalId == id);
_changeController.add(null);
} }
@override @override

View File

@@ -1,5 +1,7 @@
// 远程日记仓库 — 通过 API 客户端连接后端 // 远程日记仓库 — 通过 API 客户端连接后端
import 'dart:async';
import '../models/journal_element.dart'; import '../models/journal_element.dart';
import '../models/journal_entry.dart'; import '../models/journal_entry.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
@@ -11,6 +13,10 @@ import 'journal_repository.dart';
class RemoteJournalRepository implements JournalRepository { class RemoteJournalRepository implements JournalRepository {
final ApiClient _api; final ApiClient _api;
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
final StreamController<void> _changeController =
StreamController<void>.broadcast();
RemoteJournalRepository({required ApiClient api}) : _api = api; RemoteJournalRepository({required ApiClient api}) : _api = api;
@override @override
@@ -39,7 +45,9 @@ class RemoteJournalRepository implements JournalRepository {
final response = await _api.get('/diary/journals', queryParams: queryParams); final response = await _api.get('/diary/journals', queryParams: queryParams);
final body = response.data as Map<String, dynamic>; 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 return items
.map((json) => JournalEntry.fromJson(json as Map<String, dynamic>)) .map((json) => JournalEntry.fromJson(json as Map<String, dynamic>))
.toList(); .toList();
@@ -52,7 +60,9 @@ class RemoteJournalRepository implements JournalRepository {
'page_size': 1, 'page_size': 1,
}); });
final body = response.data as Map<String, dynamic>; 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 @override
@@ -69,9 +79,14 @@ class RemoteJournalRepository implements JournalRepository {
@override @override
Future<JournalEntry> createJournal(JournalEntry entry) async { 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>; 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 @override
@@ -89,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository {
}, },
); );
final body = response.data as Map<String, dynamic>; final body = response.data as Map<String, dynamic>;
_changeController.add(null); // 通知 UI 刷新列表
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>); return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
} }
@override @override
Future<void> deleteJournal(String id) async { Future<void> deleteJournal(String id) async {
await _api.delete('/diary/journals/$id'); await _api.delete('/diary/journals/$id');
_changeController.add(null); // 通知 UI 刷新列表
} }
@override @override
@@ -131,6 +148,10 @@ class RemoteJournalRepository implements JournalRepository {
Future<void> removeElement(String elementId) async { Future<void> removeElement(String elementId) async {
await _api.delete('/diary/elements/$elementId'); await _api.delete('/diary/elements/$elementId');
} }
/// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
@override
Stream<void> get onJournalChanged => _changeController.stream;
} }
/// API 异常封装 — 后端返回非 2xx 状态码时抛出 /// API 异常封装 — 后端返回非 2xx 状态码时抛出

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

View 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'],
// 欺凌
'': [''],
'': [''],
};

View File

@@ -1,364 +1,7 @@
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化 // 同步引擎 — 条件导出
// //
// 设计思路 // 根据平台选择实现
// - 所有本地修改先入队 [PendingOperation] // - 原生平台 → sync_engine_native.dartIsar 持久化队列)
// - 网络恢复时自动批量同步 // - Web 平台 → sync_engine_web.dart纯内存队列
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
// - 队列持久化到 Isar应用退出后不丢失
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
import 'dart:async'; export 'sync_engine_web.dart' if (dart.library.io) 'sync_engine_native.dart';
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 {};
}
}
}

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

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

View File

@@ -1,5 +1,6 @@
// 成就 BLoC — 通过 API 加载成就列表 // 成就 BLoC — 通过 API 加载成就列表
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:nuanji_app/data/remote/api_client.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); _state = _state.copyWith(isLoading: false, achievements: achievements);
} catch (e) { } catch (e) {
debugPrint('AchievementBloc._fetchAchievements 失败: $e');
_state = _state.copyWith( _state = _state.copyWith(
isLoading: false, isLoading: false,
errorMessage: '加载成就列表失败', errorMessage: '加载成就列表失败',

View File

@@ -34,6 +34,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<LoginRequested>(_onLoginRequested); on<LoginRequested>(_onLoginRequested);
on<RegisterRequested>(_onRegisterRequested); on<RegisterRequested>(_onRegisterRequested);
on<RoleSelected>(_onRoleSelected); on<RoleSelected>(_onRoleSelected);
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
on<ClassCodeSubmitted>(_onClassCodeSubmitted); on<ClassCodeSubmitted>(_onClassCodeSubmitted);
on<LogoutRequested>(_onLogoutRequested); on<LogoutRequested>(_onLogoutRequested);
on<TokenRefreshed>(_onTokenRefreshed); on<TokenRefreshed>(_onTokenRefreshed);
@@ -124,16 +125,38 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final currentState = state; final currentState = state;
if (currentState is! Authenticated) return; if (currentState is! Authenticated) return;
// 学生角色需要先经过家长同意确认PIPL 第28条
final needsParentalConsent = event.role == UserRoleType.student;
// 根据角色决定下一步 // 根据角色决定下一步
final needsClassCode = final needsClassCode =
event.role == UserRoleType.student || event.role == UserRoleType.parent; event.role == UserRoleType.student || event.role == UserRoleType.parent;
emit(currentState.copyWith( emit(currentState.copyWith(
needsRoleSelection: false, 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,
));
} }
/// 班级码加入 /// 班级码加入

View File

@@ -43,6 +43,12 @@ final class RoleSelected extends AuthEvent {
const RoleSelected(this.role); 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 class ClassCodeSubmitted extends AuthEvent {
final String classCode; final String classCode;

View File

@@ -37,6 +37,9 @@ final class Authenticated extends AuthState {
/// 是否需要角色选择(新注册用户还没有角色) /// 是否需要角色选择(新注册用户还没有角色)
final bool needsRoleSelection; final bool needsRoleSelection;
/// 是否需要家长/监护人同意PIPL 第28条 — 学生角色)
final bool needsParentalConsent;
/// 是否需要班级码加入(学生/家长角色) /// 是否需要班级码加入(学生/家长角色)
final bool needsClassCode; final bool needsClassCode;
@@ -46,27 +49,43 @@ final class Authenticated extends AuthState {
/// 班级码验证错误信息 /// 班级码验证错误信息
final String? classCodeError; final String? classCodeError;
/// 已选择的角色(角色选择后暂存)
final UserRoleType? selectedRole;
/// 家长同意时间戳
final DateTime? parentalConsentAt;
const Authenticated({ const Authenticated({
required this.user, required this.user,
this.needsRoleSelection = false, this.needsRoleSelection = false,
this.needsParentalConsent = false,
this.needsClassCode = false, this.needsClassCode = false,
this.isLoading = false, this.isLoading = false,
this.classCodeError, this.classCodeError,
this.selectedRole,
this.parentalConsentAt,
}); });
Authenticated copyWith({ Authenticated copyWith({
User? user, User? user,
bool? needsRoleSelection, bool? needsRoleSelection,
bool? needsParentalConsent,
bool? needsClassCode, bool? needsClassCode,
bool? isLoading, bool? isLoading,
String? classCodeError, String? classCodeError,
UserRoleType? selectedRole,
DateTime? parentalConsentAt,
}) => }) =>
Authenticated( Authenticated(
user: user ?? this.user, user: user ?? this.user,
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection, needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
needsParentalConsent:
needsParentalConsent ?? this.needsParentalConsent,
needsClassCode: needsClassCode ?? this.needsClassCode, needsClassCode: needsClassCode ?? this.needsClassCode,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
classCodeError: classCodeError, classCodeError: classCodeError,
selectedRole: selectedRole ?? this.selectedRole,
parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt,
); );
} }

View File

@@ -171,6 +171,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
), ),
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
alignment: Alignment.center,
children: [ children: [
// 装饰圆圈 // 装饰圆圈
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)), 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)), Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
Column( Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Logo — 自定义笔记本图标 // Logo — 自定义笔记本图标
Container( Container(

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

View File

@@ -1,5 +1,6 @@
// 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据 // 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart';
@@ -130,12 +131,21 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
if (state is CalendarLoaded) { if (state is CalendarLoaded) {
final current = state as 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( emit(current.copyWith(
journalsByDate: byDate, journalsByDate: byDate,
selectedDayJournals: selectedJournals,
isLoading: false, isLoading: false,
)); ));
} }
} catch (e) { } catch (e) {
debugPrint('CalendarBloc._onMonthChanged 失败: $e');
if (state is CalendarLoaded) { if (state is CalendarLoaded) {
emit((state as CalendarLoaded).copyWith(isLoading: false)); emit((state as CalendarLoaded).copyWith(isLoading: false));
} }

View File

@@ -9,6 +9,7 @@ import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/core/utils/mood_utils.dart'; import 'package:nuanji_app/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/calendar_bloc.dart'; import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴 /// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
@@ -41,25 +42,17 @@ class _CalendarView extends StatelessWidget {
} }
if (state is CalendarError) { if (state is CalendarError) {
return Center( return ErrorStateWidget(
child: Column( message: state.message,
mainAxisSize: MainAxisSize.min, onRetry: () => context.read<CalendarBloc>()
children: [ .add(CalendarMonthChanged(DateTime.now())),
Icon(Icons.error_outline, size: 48, color: colorScheme.error), icon: Icons.error_outline,
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('重试'),
),
],
),
); );
} }
if (state is! CalendarLoaded) return const SizedBox.shrink(); if (state is! CalendarLoaded) {
return const Center(child: CircularProgressIndicator());
}
final loaded = state; final loaded = state;
return Column( return Column(
@@ -81,6 +74,9 @@ class _CalendarView extends StatelessWidget {
); );
context.read<CalendarBloc>().add(CalendarMonthChanged(next)); 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.month,
required this.onPrevious, required this.onPrevious,
required this.onNext, required this.onNext,
this.onToday,
}); });
final DateTime month; final DateTime month;
final VoidCallback onPrevious; final VoidCallback onPrevious;
final VoidCallback onNext; final VoidCallback onNext;
final VoidCallback? onToday;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -578,6 +576,22 @@ class _MonthNavigator extends StatelessWidget {
fontWeight: FontWeight.bold, 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( SizedBox(
width: 44, width: 44,
height: 44, height: 44,
@@ -605,6 +619,11 @@ class _MonthNavigator extends StatelessWidget {
]; ];
return '${date.year}${months[date.month - 1]}'; return '${date.year}${months[date.month - 1]}';
} }
bool _isCurrentMonth(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month;
}
} }
// ===== 星期标题行 ===== // ===== 星期标题行 =====

View File

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

View 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 '请稍后重试';
}
}

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

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

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

View File

@@ -8,8 +8,10 @@
// 5. 达人日记 expert-diaries (纵向列表) // 5. 达人日记 expert-diaries (纵向列表)
// //
// 注意:本页是发现/灵感浏览,区别于 /search主动搜索 // 注意:本页是发现/灵感浏览,区别于 /search主动搜索
// 数据来源GET /diary/discover → DiscoverBloc
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.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_radius.dart';
import '../../../core/theme/app_shadows.dart'; import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.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 { class DiscoverPage extends StatelessWidget {
const DiscoverPage({super.key}); const DiscoverPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
return Scaffold( return Scaffold(
backgroundColor: bg, backgroundColor: _bgColor(context),
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: RefreshIndicator(
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing20), onRefresh: () async {
child: Column( context.read<DiscoverBloc>().add(const DiscoverRefresh());
crossAxisAlignment: CrossAxisAlignment.start, // 等待状态变化完成
children: [ await context.read<DiscoverBloc>().stream.firstWhere(
const SizedBox(height: DesignTokens.spacing12), (s) => s is DiscoverLoaded || s is DiscoverError,
_SearchBar(onTap: () => context.push('/search')), orElse: () => const DiscoverLoaded(DiscoverData()),
const SizedBox(height: DesignTokens.spacing20), );
const _InspirationCard( },
title: '今日推荐:图书馆的午后时光', child: BlocBuilder<DiscoverBloc, DiscoverState>(
author: '小暖 · 5月31日', builder: (context, state) {
emoji: '📚', return SingleChildScrollView(
), physics: const AlwaysScrollableScrollPhysics(),
const SizedBox(height: DesignTokens.spacing24), padding: const EdgeInsets.symmetric(
_SectionTitle(title: '热门话题'), horizontal: DesignTokens.spacing20),
const SizedBox(height: DesignTokens.spacing12), child: Column(
const _HotTopicsChips(), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: DesignTokens.spacing24), children: [
_SectionTitle(title: '精选模板'), const SizedBox(height: DesignTokens.spacing12),
const SizedBox(height: DesignTokens.spacing12), _SearchBar(onTap: () => context.push('/search')),
const _FeaturedTemplatesGrid(), const SizedBox(height: DesignTokens.spacing20),
const SizedBox(height: DesignTokens.spacing24), _buildContent(context, state),
_SectionTitle(title: '达人日记'), const SizedBox(height: DesignTokens.spacing24),
const SizedBox(height: DesignTokens.spacing12), ],
const _ExpertDiariesList(), ),
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 /// 1. 搜索框(点击跳转 /search
@@ -77,7 +184,8 @@ class _SearchBar extends StatelessWidget {
borderRadius: AppRadius.pillBorder, borderRadius: AppRadius.pillBorder,
child: Container( child: Container(
height: 48, height: 48,
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16), padding:
const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
borderRadius: AppRadius.pillBorder, borderRadius: AppRadius.pillBorder,
@@ -85,7 +193,8 @@ class _SearchBar extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ 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), const SizedBox(width: DesignTokens.spacing12),
Text( Text(
'搜索日记、模板、话题...', '搜索日记、模板、话题...',
@@ -103,18 +212,49 @@ class _SearchBar extends StatelessWidget {
/// 2. 每日推荐卡片(渐变背景) /// 2. 每日推荐卡片(渐变背景)
class _InspirationCard extends StatelessWidget { class _InspirationCard extends StatelessWidget {
const _InspirationCard({ const _InspirationCard({required this.item});
required this.title, final InspirationItem? item;
required this.author,
required this.emoji,
});
final String title;
final String author;
final String emoji;
@override @override
Widget build(BuildContext context) { 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( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(DesignTokens.spacing20), padding: const EdgeInsets.all(DesignTokens.spacing20),
@@ -191,17 +331,19 @@ class _InspirationCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
title, item!.title,
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: Colors.white,
height: 1.25, height: 1.25,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
author, '${item!.authorName} · ${_formatDate(item!.date)}',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.white.withValues(alpha: 0.75), 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 { class _SectionTitle extends StatelessWidget {
@@ -241,12 +387,8 @@ class _SectionTitle extends StatelessWidget {
/// 3. 热门话题(横向滚动 chips /// 3. 热门话题(横向滚动 chips
class _HotTopicsChips extends StatelessWidget { class _HotTopicsChips extends StatelessWidget {
const _HotTopicsChips(); const _HotTopicsChips({required this.topics});
final List<TagCount> topics;
static const _topics = [
'#期末备考', '#读书笔记', '#旅行手账', '#美食日记',
'#校园生活', '#自我成长', '#心情日记', '#手写摘抄',
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -255,24 +397,31 @@ class _HotTopicsChips extends StatelessWidget {
height: 44, height: 44,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: _topics.length, itemCount: topics.length,
separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.spacing8), separatorBuilder: (_, __) =>
const SizedBox(width: DesignTokens.spacing8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final isHot = index < 3; final isHot = index < 3;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isHot ? theme.colorScheme.primary : theme.colorScheme.surface, color:
isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
borderRadius: AppRadius.pillBorder, borderRadius: AppRadius.pillBorder,
border: isHot ? null : Border.all(color: theme.colorScheme.outlineVariant), border: isHot
? null
: Border.all(color: theme.colorScheme.outlineVariant),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
_topics[index], '#${topics[index].tag}',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, 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 列网格) /// 4. 精选模板2 列网格)
class _FeaturedTemplatesGrid extends StatelessWidget { class _FeaturedTemplatesGrid extends StatelessWidget {
const _FeaturedTemplatesGrid(); const _FeaturedTemplatesGrid({required this.templates});
final List<DiscoverTemplateItem> templates;
static const _templates = [
('📖', '每日心情日记', '2.3k 人使用', AppColors.secondarySoftLight),
('🎓', '期末复习计划', '1.8k 人使用', AppColors.tertiarySoftLight),
('🌿', '植物观察日记', '956 人使用', AppColors.roseSoftLight),
('✈️', '旅行手账本', '742 人使用', AppColors.secondarySoftLight),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -304,13 +447,28 @@ class _FeaturedTemplatesGrid extends StatelessWidget {
crossAxisSpacing: DesignTokens.spacing12, crossAxisSpacing: DesignTokens.spacing12,
childAspectRatio: 0.85, childAspectRatio: 0.85,
), ),
itemCount: _templates.length, itemCount: templates.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final t = _templates[index]; final t = templates[index];
return _TemplateCard(emoji: t.$1, name: t.$2, usage: t.$3, bg: t.$4); 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 { class _TemplateCard extends StatelessWidget {
@@ -368,7 +526,9 @@ class _TemplateCard extends StatelessWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
usage, 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. 达人日记(纵向列表) /// 5. 达人日记(纵向列表)
class _ExpertDiariesList extends StatelessWidget { class _ExpertDiariesList extends StatelessWidget {
const _ExpertDiariesList(); const _ExpertDiariesList({required this.diaries});
final List<ExpertDiaryItem> diaries;
static const _experts = [
('🌸', '小桃子', '春日漫步手账', '记录春天的每一朵花开...', '342 赞'),
('', '咖啡少年', '咖啡馆日记', '今天尝试了一家新店...', '218 赞'),
('📝', '学习达人', '考研倒计时30天', '坚持就是胜利...', '556 赞'),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Column( return Column(
children: _experts.map((e) { children: diaries.map((diary) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: DesignTokens.spacing12), margin: const EdgeInsets.only(bottom: DesignTokens.spacing12),
padding: const EdgeInsets.all(DesignTokens.spacing16), padding: const EdgeInsets.all(DesignTokens.spacing16),
@@ -412,7 +567,8 @@ class _ExpertDiariesList extends StatelessWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
alignment: Alignment.center, 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), const SizedBox(width: DesignTokens.spacing12),
Expanded( Expanded(
@@ -422,7 +578,7 @@ class _ExpertDiariesList extends StatelessWidget {
Row( Row(
children: [ children: [
Text( Text(
e.$2, diary.authorName,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -432,12 +588,13 @@ class _ExpertDiariesList extends StatelessWidget {
const SizedBox(width: DesignTokens.spacing8), const SizedBox(width: DesignTokens.spacing8),
Text( Text(
'·', '·',
style: TextStyle(color: theme.colorScheme.onSurfaceVariant), style: TextStyle(
color: theme.colorScheme.onSurfaceVariant),
), ),
const SizedBox(width: DesignTokens.spacing8), const SizedBox(width: DesignTokens.spacing8),
Expanded( Expanded(
child: Text( child: Text(
e.$3, diary.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@@ -452,7 +609,9 @@ class _ExpertDiariesList extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
e.$4, diary.contentPreview.isNotEmpty
? diary.contentPreview
: '...',
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@@ -468,11 +627,14 @@ class _ExpertDiariesList extends StatelessWidget {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.favorite_rounded, size: 14, color: AppColors.rose), Icon(Icons.favorite_rounded,
size: 14, color: AppColors.rose),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
e.$5, diary.likeText,
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant),
), ),
], ],
), ),

View File

@@ -99,6 +99,16 @@ class ElementSelected extends EditorEvent {
ElementSelected(this.elementId); 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); ToolChanged(this.tool);
} }
/// 再次点击已激活的工具 — 重新弹出设置面板
class ToolReactivated extends EditorEvent {
final EditorTool tool;
ToolReactivated(this.tool);
}
/// 加载已有元素 /// 加载已有元素
class ElementsLoaded extends EditorEvent { class ElementsLoaded extends EditorEvent {
final List<JournalElement> elements; 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 bool isDirty;
final DateTime? lastSavedAt; final DateTime? lastSavedAt;
// 工具重新激活时间戳(用于驱动面板重新弹出)
final int toolReactivatedAt;
const EditorState({ const EditorState({
this.strokes = const [], this.strokes = const [],
this.redoStack = const [], this.redoStack = const [],
@@ -221,6 +262,7 @@ class EditorState {
this.title = '', this.title = '',
this.isDirty = false, this.isDirty = false,
this.lastSavedAt, this.lastSavedAt,
this.toolReactivatedAt = 0,
}); });
EditorState copyWith({ EditorState copyWith({
@@ -239,6 +281,7 @@ class EditorState {
String? title, String? title,
bool? isDirty, bool? isDirty,
DateTime? lastSavedAt, DateTime? lastSavedAt,
int? toolReactivatedAt,
}) => }) =>
EditorState( EditorState(
strokes: strokes ?? this.strokes, strokes: strokes ?? this.strokes,
@@ -257,6 +300,7 @@ class EditorState {
title: title ?? this.title, title: title ?? this.title,
isDirty: isDirty ?? this.isDirty, isDirty: isDirty ?? this.isDirty,
lastSavedAt: lastSavedAt ?? this.lastSavedAt, lastSavedAt: lastSavedAt ?? this.lastSavedAt,
toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt,
); );
/// 是否处于手写模式 /// 是否处于手写模式
@@ -301,10 +345,15 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
on<ElementResized>(_onElementResized); on<ElementResized>(_onElementResized);
on<ElementRotated>(_onElementRotated); on<ElementRotated>(_onElementRotated);
on<ElementSelected>(_onElementSelected); on<ElementSelected>(_onElementSelected);
on<ElementLayerChanged>(_onElementLayerChanged);
on<ElementsLoaded>(_onElementsLoaded); on<ElementsLoaded>(_onElementsLoaded);
// 日记加载事件
on<LoadJournal>(_onLoadJournal);
// 工具栏事件 // 工具栏事件
on<ToolChanged>(_onToolChanged); on<ToolChanged>(_onToolChanged);
on<ToolReactivated>(_onToolReactivated);
// 标签/心情/标题事件 // 标签/心情/标题事件
on<TagAdded>(_onTagAdded); 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) { void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(elements: event.elements)); emit(state.copyWith(elements: event.elements));
} }
// ============================================================
// 日记加载事件处理
// ============================================================
/// 加载已有日记 — 原子操作,一次性还原所有状态
///
/// 不触发 auto-saveisDirty=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,
));
}
// ============================================================ // ============================================================
// 标签/心情/标题事件处理 // 标签/心情/标题事件处理
// ============================================================ // ============================================================

View File

@@ -19,9 +19,11 @@ import '../../../data/models/journal_element.dart';
import '../../../data/models/journal_entry.dart' show JournalEntry, Mood; import '../../../data/models/journal_entry.dart' show JournalEntry, Mood;
import '../../../data/repositories/journal_repository.dart'; import '../../../data/repositories/journal_repository.dart';
import '../../../data/repositories/class_repository.dart'; import '../../../data/repositories/class_repository.dart';
import '../../../data/remote/api_client.dart';
import '../../../data/services/sync_engine.dart'; import '../../../data/services/sync_engine.dart';
import '../../auth/bloc/auth_bloc.dart'; import '../../auth/bloc/auth_bloc.dart';
import '../bloc/editor_bloc.dart'; import '../bloc/editor_bloc.dart';
import '../widgets/comment_list_sheet.dart';
import '../widgets/handwriting_canvas.dart'; import '../widgets/handwriting_canvas.dart';
import '../widgets/stroke_model.dart'; import '../widgets/stroke_model.dart';
import '../widgets/draggable_element.dart'; import '../widgets/draggable_element.dart';
@@ -35,12 +37,26 @@ import '../widgets/brush_panel.dart';
import '../widgets/dot_grid_painter.dart'; import '../widgets/dot_grid_painter.dart';
/// 手账编辑器页面 /// 手账编辑器页面
class EditorPage extends StatelessWidget { class EditorPage extends StatefulWidget {
final String? journalId; final String? journalId;
final String? templateId; final String? templateId;
const EditorPage({super.key, this.journalId, this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 从 Provider 树获取 JournalRepositoryIsarJournalRepository // 从 Provider 树获取 JournalRepositoryIsarJournalRepository
@@ -48,10 +64,6 @@ class EditorPage extends StatelessWidget {
// 从 Provider 树获取 SyncEngine同步到后端 // 从 Provider 树获取 SyncEngine同步到后端
final syncEngine = context.read<SyncEngine>(); final syncEngine = context.read<SyncEngine>();
// 可变闭包变量:跟踪已保存的日记 ID
// 新建日记首次保存后赋值,后续自动更新使用此 ID
String? savedJournalId = journalId;
return BlocProvider( return BlocProvider(
create: (_) => EditorBloc( create: (_) => EditorBloc(
onSave: (state) async { onSave: (state) async {
@@ -64,7 +76,7 @@ class EditorPage extends StatelessWidget {
} }
await _persistState( await _persistState(
repo, state, (id) => savedJournalId = id, savedJournalId, repo, state, (id) => _savedJournalId = id, _savedJournalId,
syncEngine: syncEngine, syncEngine: syncEngine,
authorId: authorId, authorId: authorId,
); );
@@ -74,12 +86,12 @@ class EditorPage extends StatelessWidget {
}, },
), ),
child: _EditorView( child: _EditorView(
journalId: journalId, journalId: widget.journalId,
templateId: templateId, templateId: widget.templateId,
savedJournalId: savedJournalId, savedJournalId: _savedJournalId,
repo: repo, repo: repo,
onSaveComplete: () { onSaveComplete: () {
_showShareSheetAndNavigate(context, repo, savedJournalId); _showShareSheetAndNavigate(context, repo, _savedJournalId);
}, },
), ),
); );
@@ -109,43 +121,59 @@ class EditorPage extends StatelessWidget {
title: '${now.month}${now.day}日的日记', title: '${now.month}${now.day}日的日记',
date: now, 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) { if (state.strokes.isNotEmpty) {
await _saveStrokesAsElement(repo, entry.id, state.strokes); await _saveStrokesAsElement(repo, journalId, state.strokes);
} }
// 保存其他元素 // 保存其他元素
for (final element in state.elements) { for (final element in state.elements) {
await repo.addElement(element.copyWith(journalId: entry.id)); await repo.addElement(element.copyWith(journalId: journalId));
} }
// 入队 SyncEngine 等待同步到后端 // 仅非私密日记入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation( // 私密日记is_private=true仅保存在本地不上传
id: entry.id, if (!saved.isPrivate) {
type: SyncOperationType.create, syncEngine.enqueue(PendingOperation(
endpoint: '/diary/journals', id: journalId,
data: entry.toJson(), type: SyncOperationType.create,
version: entry.version, endpoint: '/diary/journals',
createdAt: now, data: saved.toJson(),
)); version: saved.version,
createdAt: now,
));
}
} else { } else {
// --- 更新已有日记 --- // --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId); final existing = await repo.getJournal(savedJournalId);
if (existing != null) { 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
syncEngine.enqueue(PendingOperation( if (!updated.isPrivate) {
id: existing.id, syncEngine.enqueue(PendingOperation(
type: SyncOperationType.update, id: updated.id,
endpoint: '/diary/journals/${existing.id}', type: SyncOperationType.update,
data: existing.toJson(), endpoint: '/diary/journals/${updated.id}',
version: existing.version, data: updated.toJson(),
createdAt: now, 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( static Future<void> _showShareSheetAndNavigate(
BuildContext context, BuildContext context,
JournalRepository repo, JournalRepository repo,
@@ -227,14 +260,41 @@ class EditorPage extends StatelessWidget {
classId: userClassId, classId: userClassId,
className: userClassName, className: userClassName,
onDecision: (shareToClass) async { onDecision: (shareToClass) async {
// 更新日记的 sharedToClass 状态
if (savedJournalId != null) { if (savedJournalId != null) {
try { try {
final entry = await repo.getJournal(savedJournalId); final entry = await repo.getJournal(savedJournalId);
if (entry != null) { if (entry != null) {
await repo.updateJournal( final wasPrivate = entry.isPrivate;
entry.copyWith(sharedToClass: shareToClass), // 分享到班级/所有人 → 取消私密标记
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) { } catch (e) {
debugPrint('更新分享状态失败: $e'); debugPrint('更新分享状态失败: $e');
@@ -274,56 +334,71 @@ class _EditorView extends StatefulWidget {
} }
class _EditorViewState extends State<_EditorView> { class _EditorViewState extends State<_EditorView> {
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
bool _isViewMode = false;
/// 保存中状态 — 用于显示"保存中..."指示器
bool _isSaving = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 当 journalId 非空时,从 Isar 加载已有日记数据 // 当 journalId 非空时,进入查看模式
_isViewMode = widget.journalId != null;
if (widget.journalId != null) { if (widget.journalId != null) {
_loadExistingJournal(widget.journalId!); _loadExistingJournal(widget.journalId!);
} }
} }
/// 从 Isar 加载已有日记的笔画、元素、标签、心情、标题 /// 从查看模式切换到编辑模式
void _enterEditMode() {
setState(() => _isViewMode = false);
// 切换到画笔工具,进入编辑状态
context.read<EditorBloc>().add(ToolChanged(EditorTool.brush));
}
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
Future<void> _loadExistingJournal(String id) async { Future<void> _loadExistingJournal(String id) async {
try { try {
// 加载日记元数据
final entry = await widget.repo.getJournal(id); final entry = await widget.repo.getJournal(id);
if (entry == null || !mounted) return; 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); final elements = await widget.repo.getElements(id);
if (!mounted) return; if (!mounted) return;
for (final element in elements) { // 从 handwriting_ref 元素中反序列化笔画
if (element.elementType == ElementType.handwritingRef) { List<Stroke> strokes = [];
// 从 handwriting_ref 元素中恢复笔画 final strokesElement = elements
final strokesData = element.content['strokes']; .where((e) => e.elementType == ElementType.handwritingRef)
if (strokesData is List) { .firstOrNull;
final strokes = strokesData if (strokesElement != null) {
.map((s) => Stroke.fromJson(s as Map<String, dynamic>)) final strokesData = strokesElement.content['strokes'];
.toList(); if (strokesData is List) {
bloc.add(StrokesLoaded(strokes)); strokes = strokesData
} .map((s) => Stroke.fromJson(s as Map<String, dynamic>))
.toList();
} }
} }
// 加载非笔画元素(贴纸/文字/图片 // 过滤掉 handwriting_ref 元素(笔画单独管理
final nonStrokeElements = elements final otherElements = elements
.where((e) => e.elementType != ElementType.handwritingRef) .where((e) => e.elementType != ElementType.handwritingRef)
.toList(); .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) { } catch (e) {
debugPrint('加载日记数据失败: $e'); debugPrint('加载日记数据失败: $e');
@@ -349,26 +424,31 @@ class _EditorViewState extends State<_EditorView> {
Expanded( Expanded(
child: BlocBuilder<EditorBloc, EditorState>( child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) { builder: (context, state) {
return _EditorStack(state: state, journalId: widget.journalId); return _EditorStack(
state: state,
journalId: widget.journalId,
isViewMode: _isViewMode,
);
}, },
), ),
), ),
// 底部工具栏(自带底部安全区) // 底部工具栏 — 仅编辑模式显示
BlocBuilder<EditorBloc, EditorState>( if (!_isViewMode)
builder: (context, state) { BlocBuilder<EditorBloc, EditorState>(
return EditorToolbar( builder: (context, state) {
state: state, return EditorToolbar(
onEvent: (event) => context.read<EditorBloc>().add(event), state: state,
); onEvent: (event) => context.read<EditorBloc>().add(event),
}, );
), },
),
], ],
), ),
); );
} }
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成 /// 顶部操作栏 — 查看模式: 返回/日期/评语/编辑按钮;编辑模式: 返回/日期/撤销重做/标签/完成
Widget _buildTopBar(BuildContext context, EditorState state) { Widget _buildTopBar(BuildContext context, EditorState state) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
@@ -411,51 +491,83 @@ class _EditorViewState extends State<_EditorView> {
), ),
), ),
), ),
// 撤销 if (_isViewMode) ...[
IconButton( // 查看模式:评语按钮 + 编辑按钮
icon: const Icon(Icons.undo_rounded, size: 18), if (widget.journalId != null)
onPressed: () => context.read<EditorBloc>().add(Undo()), IconButton(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
), onPressed: () => _showComments(context),
// 重做 constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
IconButton( ),
icon: const Icon(Icons.redo_rounded, size: 18), Padding(
onPressed: () => context.read<EditorBloc>().add(Redo()), padding: const EdgeInsets.only(left: 4),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), child: FilledButton.tonal(
), onPressed: _enterEditMode,
// 自动保存状态 style: FilledButton.styleFrom(
_buildAutosaveIndicator(state), padding: const EdgeInsets.symmetric(horizontal: 16),
// 标签按钮 minimumSize: const Size(0, 32),
IconButton( ),
icon: const Icon(Icons.sell_rounded, size: 18), child: const Text('编辑', style: TextStyle(fontSize: 14)),
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),
), ),
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) // 日期 + 心情条 (40px) — 仅编辑模式显示
_buildDateMoodStrip(context, state), if (!_isViewMode) _buildDateMoodStrip(context, state),
], ],
), ),
); );
} }
/// 返回处理 /// 返回处理 — 有未保存修改时弹出确认
void _handleBack(BuildContext context) { 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()) { if (context.canPop()) {
context.pop(); context.pop();
} else { } 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) { 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) { Widget _buildAutosaveIndicator(EditorState state) {
// 保存中 — 琥珀色脉冲点
if (_isSaving) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(color: AppColors.tertiary),
const SizedBox(width: 4),
Text(
'保存中...',
style: TextStyle(fontSize: 11, color: Colors.amber[700]),
),
],
),
);
}
// 未保存
if (state.lastSavedAt == null) { if (state.lastSavedAt == null) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
@@ -486,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
), ),
); );
} }
// 已保存 — 绿色点
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row( child: Row(
@@ -595,8 +773,13 @@ class _EditorViewState extends State<_EditorView> {
class _EditorStack extends StatefulWidget { class _EditorStack extends StatefulWidget {
final EditorState state; final EditorState state;
final String? journalId; final String? journalId;
final bool isViewMode;
const _EditorStack({required this.state, this.journalId}); const _EditorStack({
required this.state,
this.journalId,
this.isViewMode = false,
});
@override @override
State<_EditorStack> createState() => _EditorStackState(); State<_EditorStack> createState() => _EditorStackState();
@@ -604,6 +787,7 @@ class _EditorStack extends StatefulWidget {
class _EditorStackState extends State<_EditorStack> { class _EditorStackState extends State<_EditorStack> {
EditorTool? _lastTool; EditorTool? _lastTool;
int _lastReactivatedAt = 0;
late final TextEditingController _titleController; late final TextEditingController _titleController;
@override @override
@@ -621,6 +805,13 @@ class _EditorStackState extends State<_EditorStack> {
@override @override
void didUpdateWidget(covariant _EditorStack oldWidget) { void didUpdateWidget(covariant _EditorStack oldWidget) {
super.didUpdateWidget(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; final currentTool = widget.state.activeTool;
// 防止重复弹窗:只在工具切换时触发 // 防止重复弹窗:只在工具切换时触发
@@ -647,6 +838,26 @@ class _EditorStackState extends State<_EditorStack> {
}); });
} }
_lastTool = currentTool; _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), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField( child: TextField(
controller: _titleController, controller: _titleController,
enabled: !widget.isViewMode,
style: TextStyle( style: TextStyle(
fontFamily: 'Quicksand', fontFamily: 'Quicksand',
fontSize: 18, fontSize: 18,
@@ -836,8 +1048,8 @@ class _EditorStackState extends State<_EditorStack> {
if (state.elements.isNotEmpty) if (state.elements.isNotEmpty)
_buildElementLayer(context, state), _buildElementLayer(context, state),
// 文字输入覆盖层(文字工具激活时显示) // 文字输入覆盖层(文字工具激活时显示)— 仅编辑模式
if (state.activeTool == EditorTool.text) if (!widget.isViewMode && state.activeTool == EditorTool.text)
TextInputOverlay( TextInputOverlay(
onConfirmed: (text, fontSize, fontColor) { onConfirmed: (text, fontSize, fontColor) {
final center = Offset( 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( Center(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, 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), _buildEmptyHint(context),
], ],
); );
@@ -934,9 +1149,27 @@ class _EditorStackState extends State<_EditorStack> {
positionY: y, 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) { onDeleted: (id) {
context.read<EditorBloc>().add(ElementRemoved(id)); context.read<EditorBloc>().add(ElementRemoved(id));
}, },
onLayerChanged: (id, change) {
context.read<EditorBloc>().add(
ElementLayerChanged(elementId: id, change: change),
);
},
); );
}).toList(), }).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,
),
),
);
},
);
}
}

View File

@@ -53,6 +53,16 @@ class ActiveStrokePainter extends CustomPainter {
final path = buildStrokePath(outlinePoints); 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 // 构造临时 Stroke 用于获取 Paint
final stroke = Stroke( final stroke = Stroke(
id: '__active__', id: '__active__',

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

View File

@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../data/models/journal_element.dart'; import '../../../data/models/journal_element.dart';
import '../bloc/editor_bloc.dart' show LayerChange;
/// 可拖拽日记元素组件 /// 可拖拽日记元素组件
class DraggableElement extends StatefulWidget { class DraggableElement extends StatefulWidget {
@@ -19,7 +20,10 @@ class DraggableElement extends StatefulWidget {
final bool isSelected; final bool isSelected;
final ValueChanged<String> onTap; final ValueChanged<String> onTap;
final void Function(String id, double x, double y) onMoved; 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 ValueChanged<String> onDeleted;
final void Function(String id, LayerChange change)? onLayerChanged;
const DraggableElement({ const DraggableElement({
super.key, super.key,
@@ -27,7 +31,10 @@ class DraggableElement extends StatefulWidget {
this.isSelected = false, this.isSelected = false,
required this.onTap, required this.onTap,
required this.onMoved, required this.onMoved,
this.onResized,
this.onRotated,
required this.onDeleted, required this.onDeleted,
this.onLayerChanged,
}); });
@override @override
@@ -41,6 +48,11 @@ class _DraggableElementState extends State<DraggableElement> {
late double _height; late double _height;
late double _rotation; late double _rotation;
// Scale 手势状态
double _baseWidth = 0;
double _baseHeight = 0;
double _baseRotation = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -76,15 +88,35 @@ class _DraggableElementState extends State<DraggableElement> {
child: Transform.rotate( child: Transform.rotate(
angle: _rotation, angle: _rotation,
child: GestureDetector( child: GestureDetector(
// 拖拽移动 // 缩放开始 — 记录基准值
onPanUpdate: (details) { onScaleStart: (details) {
_baseWidth = _width;
_baseHeight = _height;
_baseRotation = _rotation;
},
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
onScaleUpdate: (details) {
setState(() { 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); 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); widget.onMoved(widget.element.id, _x, _y);
}, },
@@ -113,26 +145,41 @@ class _DraggableElementState extends State<DraggableElement> {
), ),
), ),
// 选中时显示删除按钮 // 选中时显示操作按钮:图层 + 删除
if (widget.isSelected) if (widget.isSelected)
Positioned( Positioned(
top: -12, top: -12,
right: -12, right: -12,
child: GestureDetector( child: Row(
onTap: () => widget.onDeleted(widget.element.id), mainAxisSize: MainAxisSize.min,
child: Container( children: [
width: 24, // 置顶
height: 24, _ActionButton(
decoration: BoxDecoration( 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, 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),
),
);
}
}

View File

@@ -68,10 +68,10 @@ class EditorToolbar extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return GestureDetector( return GestureDetector(
onTap: () => onEvent(ToolChanged(tool)), onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Container( child: Container(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -134,10 +134,15 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
} }
/// 在 build 完成后同步缓存(避免在 build 中触发异步操作) /// 在 build 完成后同步缓存(避免在 build 中触发异步操作)
///
/// syncStrokes 重建合成图后必须调用 setState
/// 否则 CachedStrokesPainter 不知道缓存已更新,不会触发重绘。
void _syncCacheAfterBuild() { void _syncCacheAfterBuild() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; 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); widget.onStrokeCompleted?.call(stroke);
// 光栅化新笔画到缓存(异步,不阻塞 UI // 光栅化新笔画到缓存(异步,不阻塞 UI
_cache.addStroke(stroke); // 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘
_cache.addStroke(stroke).then((_) {
if (mounted) setState(() {});
});
} }
/// 指针取消(如来电打断):丢弃当前笔画。 /// 指针取消(如来电打断):丢弃当前笔画。

View File

@@ -4,20 +4,27 @@
// - 温暖友好的文案(面向小学生) // - 温暖友好的文案(面向小学生)
// - 分享到班级(有班级时显示)/ 仅自己可见 // - 分享到班级(有班级时显示)/ 仅自己可见
// - 无班级时提示加入班级后可分享 // - 无班级时提示加入班级后可分享
// - 分享前自动进行内容安全检查(敏感词过滤)
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../data/services/content_filter_service.dart';
/// 编辑器完成后的分享选择面板 /// 编辑器完成后的分享选择面板
class ShareBottomSheet extends StatelessWidget { class ShareBottomSheet extends StatelessWidget {
final String? classId; final String? classId;
final String className; final String className;
final void Function(bool shareToClass) onDecision; final void Function(bool shareToClass) onDecision;
/// 用于内容安全检查的文本内容(标题 + 文本元素)
final String contentText;
const ShareBottomSheet({ const ShareBottomSheet({
super.key, super.key,
required this.classId, required this.classId,
required this.className, required this.className,
required this.onDecision, required this.onDecision,
this.contentText = '',
}); });
@override @override
@@ -65,10 +72,7 @@ class ShareBottomSheet extends StatelessWidget {
width: double.infinity, width: double.infinity,
height: 52, height: 52,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () { onPressed: () => _handleShare(context, shareToClass: true),
onDecision(true);
Navigator.pop(context);
},
icon: const Icon(Icons.group), icon: const Icon(Icons.group),
label: Text('分享到 $className'), label: Text('分享到 $className'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -86,10 +90,7 @@ class ShareBottomSheet extends StatelessWidget {
width: double.infinity, width: double.infinity,
height: 52, height: 52,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () { onPressed: () => _handleShare(context, shareToClass: false),
onDecision(false);
Navigator.pop(context);
},
icon: const Icon(Icons.lock_outline), icon: const Icon(Icons.lock_outline),
label: const Text('仅自己可见'), label: const Text('仅自己可见'),
style: OutlinedButton.styleFrom( 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('仍然分享'),
),
],
),
);
}
} }

View File

@@ -1,7 +1,8 @@
// 贴纸选择底部面板 // 贴纸选择底部面板
// //
// Phase 1 使用内置 emoji 贴纸6 类 60 个),后续替换为贴纸包资源 // Phase 1 使用内置 emoji 贴纸6 类 60 个)。
// 分类:心情/动物/自然/食物/学校/装饰 // 当贴纸包 API 有数据时自动追加到"更多贴纸"分类。
// 后续 Phase 2 将完全迁移到贴纸包资源。
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -14,8 +15,8 @@ class StickerPickerSheet extends StatelessWidget {
required this.onStickerSelected, required this.onStickerSelected,
}); });
// Phase 1 内置贴纸集 // 内置基础贴纸集Phase 1 保底,保证离线可用)
static const _stickerCategories = <String, List<String>>{ static const _builtinStickers = <String, List<String>>{
'心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'], '心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'],
'动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'], '动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'],
'自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '', '🌙', '☀️', '❄️', '🍃'], '自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '', '🌙', '☀️', '❄️', '🍃'],
@@ -24,6 +25,9 @@ class StickerPickerSheet extends StatelessWidget {
'装饰': ['💕', '', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'], '装饰': ['💕', '', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'],
}; };
/// 合并后的贴纸分类(预留 API 扩展入口)
Map<String, List<String>> get _stickerCategories => _builtinStickers;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(

View File

@@ -20,8 +20,10 @@ import 'stroke_renderer.dart';
class _CacheEntry { class _CacheEntry {
final ui.Image image; final ui.Image image;
final Stroke stroke; 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 { Future<void> addStroke(Stroke stroke) async {
if (_canvasSize == Size.zero) return; if (_canvasSize == Size.zero) return;
// 光栅化单笔画 // 光栅化单笔画BBox 裁剪)
final image = await _rasterizeStroke(stroke); final result = await _rasterizeStroke(stroke);
if (image == null) return; 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 之上 // 增量合成:将新笔画绘制到现有 compositeImage 之上
await _compositeIncremental(stroke, image); await _compositeIncremental(stroke, result.image, result.offset);
} }
/// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐) /// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐)
@@ -108,9 +114,13 @@ class StrokeRasterCache {
final toAdd = currentIds.difference(cachedIds); final toAdd = currentIds.difference(cachedIds);
for (final stroke in strokes) { for (final stroke in strokes) {
if (toAdd.contains(stroke.id)) { if (toAdd.contains(stroke.id)) {
final image = await _rasterizeStroke(stroke); final result = await _rasterizeStroke(stroke);
if (image != null) { if (result != null) {
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke); _cache[stroke.id] = _CacheEntry(
image: result.image,
stroke: stroke,
offset: result.offset,
);
} }
} }
} }
@@ -155,8 +165,12 @@ class StrokeRasterCache {
// ===== 光栅化 ===== // ===== 光栅化 =====
/// 将单条笔画光栅化为 ui.Image /// 将单条笔画光栅化为 ui.Image — 仅光栅化 BBox 区域(性能优化 8b-M02
Future<ui.Image?> _rasterizeStroke(Stroke stroke) async { ///
/// 计算笔画的包围盒 (bounding box),仅对该区域光栅化,
/// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。
/// 返回 null 表示笔画无有效点。
Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async {
final outlinePoints = pointsToOutline( final outlinePoints = pointsToOutline(
stroke.points, stroke.points,
stroke.brushType, stroke.brushType,
@@ -165,16 +179,38 @@ class StrokeRasterCache {
); );
if (outlinePoints.isEmpty) return null; 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 path = buildStrokePath(outlinePoints);
final paint = createPaintForStroke(stroke); final paint = createPaintForStroke(stroke);
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder); final canvas = Canvas(recorder);
// 平移坐标系,使 BBox 左上角对齐 (0, 0)
canvas.translate(-bboxLeft, -bboxTop);
// 橡皮擦需要 saveLayer 保护,避免穿透 // 橡皮擦需要 saveLayer 保护,避免穿透
if (stroke.brushType == BrushType.eraser) { if (stroke.brushType == BrushType.eraser) {
canvas.saveLayer( canvas.saveLayer(
Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height), Rect.fromLTWH(0, 0, bboxWidth, bboxHeight),
Paint(), Paint(),
); );
} }
@@ -186,16 +222,20 @@ class StrokeRasterCache {
} }
final picture = recorder.endRecording(); final picture = recorder.endRecording();
return picture.toImage( final image = await picture.toImage(
_canvasSize.width.toInt().clamp(1, 4096), bboxWidth.toInt().clamp(1, 4096),
_canvasSize.height.toInt().clamp(1, 4096), bboxHeight.toInt().clamp(1, 4096),
); );
return (image: image, offset: Offset(bboxLeft, bboxTop));
} }
// ===== 合成 ===== // ===== 合成 =====
/// 增量合成:将新笔画图像绘制到现有 compositeImage 上 /// 增量合成:将新笔画图像绘制到现有 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 recorder = ui.PictureRecorder();
final canvas = Canvas(recorder); final canvas = Canvas(recorder);
@@ -210,15 +250,15 @@ class StrokeRasterCache {
canvas.drawImage(_compositeImage!, Offset.zero, Paint()); canvas.drawImage(_compositeImage!, Offset.zero, Paint());
} }
// 再绘制新笔画(橡皮擦用 dstOut // 再绘制新笔画(橡皮擦用 dstOut,使用 BBox offset 定位
if (stroke.brushType == BrushType.eraser) { if (stroke.brushType == BrushType.eraser) {
canvas.drawImage( canvas.drawImage(
strokeImage, strokeImage,
Offset.zero, offset,
Paint()..blendMode = BlendMode.dstOut, Paint()..blendMode = BlendMode.dstOut,
); );
} else { } else {
canvas.drawImage(strokeImage, Offset.zero, Paint()); canvas.drawImage(strokeImage, offset, Paint());
} }
canvas.restore(); canvas.restore();
@@ -232,14 +272,10 @@ class StrokeRasterCache {
_canvasSize.height.toInt().clamp(1, 4096), _canvasSize.height.toInt().clamp(1, 4096),
); );
// 单笔画图像已合成,释放以节省 GPU 内存 // 注意:不在此处 dispose strokeImage
strokeImage.dispose(); // _cache 和此方法持有同一 image 引用,提前 dispose 会导致
// 注意:从 _cache 中移除该条目的 image保留 stroke 引用以备重建) // syncStrokes/clear/dispose 时 use-after-dispose审计 ID: 8b-R01
// 不,缓存保留 image 引用以便撤销时重建。增量合成时释放 strokeImage // 单笔画 image 由缓存统一管理生命周期(移除/清除/销毁时释放)。
// 但 _cache 仍持有引用,所以需要用另一个方式
// 实际上增量合成后可以释放单笔画图像——合成图已包含其内容
// 但撤销时需要重建,需要原始数据。保留 Stroke 数据,释放 image。
// 如果后续撤销syncStrokes 会重新光栅化。
_layerVersion++; _layerVersion++;
} }
@@ -265,8 +301,7 @@ class StrokeRasterCache {
); );
// 按笔画顺序重新绘制所有单笔画 // 按笔画顺序重新绘制所有单笔画
// 注意:增量合成时已释放了单笔画 image这里需要重新光栅化 // 直接从 stroke 数据重绘路径,确保与增量合成结果一致
// 所以全量重建时,直接用 stroke 数据重绘路径(不依赖缓存的 image
for (final entry in _cache.values) { for (final entry in _cache.values) {
final stroke = entry.stroke; final stroke = entry.stroke;
final outlinePoints = pointsToOutline( final outlinePoints = pointsToOutline(

View File

@@ -39,19 +39,21 @@ class _BrushConfig {
/// 各画笔的渲染参数。 /// 各画笔的渲染参数。
const Map<BrushType, _BrushConfig> _brushConfigs = { const Map<BrushType, _BrushConfig> _brushConfigs = {
/// 钢笔:中等粗细,强压感变化,模拟笔效果 /// 钢笔:粗壮平滑,模拟签字笔效果
BrushType.pen: _BrushConfig( BrushType.pen: _BrushConfig(
size: 8, size: 10,
thinning: 0.7, thinning: 0.65,
smoothing: 0.5, smoothing: 0.65,
streamline: 0.6,
simulatePressure: true, simulatePressure: true,
), ),
/// 铅笔:细线,轻微压感,高平滑度产生自然线条 /// 铅笔:纤细有质感,保留书写抖动
BrushType.pencil: _BrushConfig( BrushType.pencil: _BrushConfig(
size: 4, size: 3,
thinning: 0.3, thinning: 0.4,
smoothing: 0.7, smoothing: 0.35,
streamline: 0.3,
simulatePressure: true, simulatePressure: true,
), ),

View File

@@ -1,9 +1,12 @@
// 标签面板 -- 底部抽屉 // 标签面板 -- 底部抽屉
// 支持添加/移除自定义标签 + 推荐标签快捷选择 // 支持添加/移除自定义标签 + 推荐标签快捷选择
// 推荐标签从用户历史标签动态推导,无数据时使用默认推荐
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../data/repositories/journal_repository.dart';
/// 标签面板 -- 底部抽屉 /// 标签面板 -- 底部抽屉
class TagPanel extends StatefulWidget { class TagPanel extends StatefulWidget {
@@ -26,15 +29,37 @@ class _TagPanelState extends State<TagPanel> {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _focusNode = FocusNode(); final _focusNode = FocusNode();
static const _suggestedTags = [ /// 推荐标签 — 动态推导
'日常', '学习', '读书', '心情', '学校', '旅行', List<String> _suggestedTags = ['日常', '学习', '读书', '心情', '学校', '旅行', '美食', '运动'];
'美食', '运动', '音乐', '梦想',
];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusNode.requestFocus(); _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 @override

View File

@@ -1,5 +1,8 @@
// 首页 BLoC — 加载最近日记和心情概览 // 首页 BLoC — 加载最近日记和心情概览
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.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> { class HomeBloc extends Bloc<HomeEvent, HomeState> {
final JournalRepository _journalRepo; final JournalRepository _journalRepo;
StreamSubscription<void>? _changeSubscription;
HomeBloc({required JournalRepository journalRepository}) HomeBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository, : _journalRepo = journalRepository,
super(const HomeInitial()) { super(const HomeInitial()) {
on<HomeLoadData>(_onLoadData); on<HomeLoadData>(_onLoadData);
on<HomeRefresh>(_onRefresh); on<HomeRefresh>(_onRefresh);
// 监听日记变更,自动刷新首页数据
_changeSubscription = _journalRepo.onJournalChanged.listen((_) {
add(const HomeRefresh());
});
}
@override
Future<void> close() {
_changeSubscription?.cancel();
return super.close();
} }
Future<void> _onLoadData( Future<void> _onLoadData(
@@ -115,9 +130,14 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
// 推算连续天数 // 推算连续天数
final streakDays = _calculateStreak(journals); final streakDays = _calculateStreak(journals);
// 本月日记数spec §3.4 quick-stats // 本月日记数 — 使用日期范围查询,不受分页限制(修复 8b-D03
final monthCount = journals.where((j) => final monthStart = DateTime(today.year, today.month, 1);
j.date.year == today.year && j.date.month == today.month).length; 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(); final totalCount = await _journalRepo.getJournalCount();
@@ -141,6 +161,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
todayWeather: todayWeather, todayWeather: todayWeather,
)); ));
} catch (e) { } catch (e) {
debugPrint('HomeBloc._onLoadData 失败: $e');
emit(const HomeLoaded()); // 空状态而非错误,离线友好 emit(const HomeLoaded()); // 空状态而非错误,离线友好
} }
} }

View File

@@ -24,6 +24,10 @@ import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_typography.dart';
import '../../../data/models/journal_entry.dart'; import '../../../data/models/journal_entry.dart';
import '../../../data/repositories/journal_repository.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'; import '../bloc/home_bloc.dart';
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
@@ -87,7 +91,10 @@ class _HomeView extends StatelessWidget {
children: [ children: [
_GreetingHeader( _GreetingHeader(
greeting: greeting, greeting: greeting,
username: '小暖', username: context.select<AuthBloc, String>((bloc) {
final s = bloc.state;
return s is Authenticated ? s.user.displayLabel : '同学';
}),
dateText: dateText, dateText: dateText,
onSearchTap: () => context.push('/search'), onSearchTap: () => context.push('/search'),
), ),
@@ -659,9 +666,22 @@ class _JournalCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
'${journal.date.month}${journal.date.day}', children: [
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), 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), const SizedBox(height: 2),
Text( 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 { class _EmptyJournalState extends StatelessWidget {

View File

@@ -1,5 +1,6 @@
// 心情 BLoC — 通过 API 加载心情统计数据 // 心情 BLoC — 通过 API 加载心情统计数据
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/remote/api_client.dart'; import 'package:nuanji_app/data/remote/api_client.dart';
@@ -138,6 +139,7 @@ class MoodBloc extends ChangeNotifier {
), ),
); );
} catch (e) { } catch (e) {
debugPrint('MoodBloc._loadStats 失败: $e');
_state = _state.copyWith( _state = _state.copyWith(
isLoading: false, isLoading: false,
errorMessage: '加载统计数据失败', errorMessage: '加载统计数据失败',

View File

@@ -3,6 +3,7 @@
// 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError // 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError
// API: /diary/parent/* 端点 // API: /diary/parent/* 端点
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/remote/api_client.dart'; import '../../../data/remote/api_client.dart';
@@ -40,6 +41,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
.toList(); .toList();
emit(ParentChildrenLoaded(children)); emit(ParentChildrenLoaded(children));
} catch (e) { } catch (e) {
debugPrint('ParentBloc._onLoadChildren 失败: $e');
emit(const ParentError('加载孩子列表失败')); emit(const ParentError('加载孩子列表失败'));
} }
} }
@@ -57,6 +59,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
// 绑定成功后重新加载列表 // 绑定成功后重新加载列表
add(const ParentLoadChildren()); add(const ParentLoadChildren());
} catch (e) { } catch (e) {
debugPrint('ParentBloc._onBindChild 失败: $e');
emit(const ParentError('绑定失败,请检查孩子 ID')); emit(const ParentError('绑定失败,请检查孩子 ID'));
} }
} }
@@ -83,6 +86,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
journals: items.cast<Map<String, dynamic>>(), journals: items.cast<Map<String, dynamic>>(),
)); ));
} catch (e) { } catch (e) {
debugPrint('ParentBloc._onViewJournals 失败: $e');
emit(const ParentError('加载日记失败')); emit(const ParentError('加载日记失败'));
} }
} }
@@ -103,6 +107,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
data: response.data as Map<String, dynamic>, data: response.data as Map<String, dynamic>,
)); ));
} catch (e) { } catch (e) {
debugPrint('ParentBloc._onExportData 失败: $e');
emit(const ParentError('导出失败')); emit(const ParentError('导出失败'));
} }
} }
@@ -119,6 +124,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
}); });
emit(ParentDataDeleted(event.childId)); emit(ParentDataDeleted(event.childId));
} catch (e) { } catch (e) {
debugPrint('ParentBloc._onDeleteData 失败: $e');
emit(const ParentError('删除失败')); emit(const ParentError('删除失败'));
} }
} }
@@ -134,6 +140,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
}); });
add(const ParentLoadChildren()); add(const ParentLoadChildren());
} catch (e) { } catch (e) {
debugPrint('ParentBloc._onUnbindChild 失败: $e');
emit(const ParentError('解绑失败')); emit(const ParentError('解绑失败'));
} }
} }

View File

@@ -9,6 +9,8 @@
// - 数据删除 → 确认对话框 → ParentDeleteData // - 数据删除 → 确认对话框 → ParentDeleteData
// 保留 PIPL 合规提示。 // 保留 PIPL 合规提示。
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -16,6 +18,7 @@ import 'package:intl/intl.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_radius.dart';
import '../../../core/utils/file_download.dart';
import '../bloc/parent_bloc.dart'; import '../bloc/parent_bloc.dart';
/// 家长中心页面 — 家长查看孩子日记和统计数据 /// 家长中心页面 — 家长查看孩子日记和统计数据
@@ -929,7 +932,7 @@ class _JournalCard extends StatelessWidget {
} }
} }
/// 导出数据视图 — 展示导出结果 /// 导出数据视图 — 展示导出结果 + 下载按钮
class _ExportDataView extends StatelessWidget { class _ExportDataView extends StatelessWidget {
const _ExportDataView({ const _ExportDataView({
required this.childId, required this.childId,
@@ -1037,7 +1040,27 @@ class _ExportDataView extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), 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( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1048,14 +1071,15 @@ class _ExportDataView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Icons.info_outline, Icons.shield_outlined,
size: 18, size: 18,
color: AppColors.tertiary, color: AppColors.tertiary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'数据已生成。Phase 1 展示 JSON 预览,后续版本将支持文件下载', '根据《个人信息保护法》,您有权导出孩子的全部个人数据'
'导出数据仅供个人查阅,请妥善保管。',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.fg2Light, 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),
),
),
),
),
],
],
),
);
}
} }
/// 导出信息行 /// 导出信息行

View File

@@ -1,9 +1,12 @@
// 设置 BLoC — 主题切换 + 应用设置管理 // 设置 BLoC — 主题切换 + 应用设置管理
// //
// ChangeNotifier 模式(同 MoodBloc通过 ListenableBuilder 消费。 // ChangeNotifier 模式(同 MoodBloc通过 ListenableBuilder 消费。
// Phase 1: 内存态 + TODO 持久化到 SharedPreferences。 // 主题偏好持久化到 SharedPreferences。
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kThemeMode = 'settings_theme_mode';
// ===== State ===== // ===== State =====
@@ -28,14 +31,34 @@ class SettingsState {
/// 设置管理器 — 全局单例,在 NuanjiApp 中创建 /// 设置管理器 — 全局单例,在 NuanjiApp 中创建
class SettingsBloc extends ChangeNotifier { class SettingsBloc extends ChangeNotifier {
SettingsBloc({SharedPreferences? prefs}) : _prefs = prefs {
_loadSavedTheme();
}
final SharedPreferences? _prefs;
SettingsState _state = const SettingsState(); SettingsState _state = const SettingsState();
SettingsState get state => _state; SettingsState get state => _state;
/// 从 SharedPreferences 恢复保存的主题
void _loadSavedTheme() {
if (_prefs == null) return;
final saved = _prefs?.getString(_kThemeMode);
if (saved != null) {
final mode = switch (saved) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
_state = _state.copyWith(themeMode: mode);
notifyListeners();
}
}
/// 切换主题模式 /// 切换主题模式
void changeTheme(ThemeMode mode) { void changeTheme(ThemeMode mode) {
_state = _state.copyWith(themeMode: mode); _state = _state.copyWith(themeMode: mode);
notifyListeners(); notifyListeners();
// TODO: 持久化到 SharedPreferences _prefs?.setString(_kThemeMode, mode.name);
} }
/// 循环切换: system → light → dark → system /// 循环切换: system → light → dark → system

View File

@@ -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/features/profile/bloc/settings_bloc.dart';
import 'package:nuanji_app/data/models/user.dart'; import 'package:nuanji_app/data/models/user.dart';
import 'package:nuanji_app/data/repositories/journal_repository.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 { class ProfilePage extends StatelessWidget {
@@ -60,7 +62,10 @@ class ProfilePage extends StatelessWidget {
), ),
), ),
alignment: Alignment.center, 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), const SizedBox(height: 12),
// 用户名 // 用户名
@@ -91,7 +96,7 @@ class ProfilePage extends StatelessWidget {
_LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme), _LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme),
const SizedBox(height: 20), const SizedBox(height: 20),
// ---- 成就徽章 ---- // ---- 成就徽章(动态加载) ----
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith( child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith(
@@ -101,21 +106,11 @@ class ProfilePage extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
height: 100, height: 100,
child: ListView( child: _AchievementBadges(
scrollDirection: Axis.horizontal, accentSoft: accentSoft,
children: [ tertiarySoft: tertiarySoft,
_BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false), roseSoft: roseSoft,
const SizedBox(width: 12), secondarySoft: secondarySoft,
_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),
],
), ),
), ),
const SizedBox(height: 20), 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,
);
},
);
}
}

View File

@@ -3,6 +3,7 @@
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError // 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
// 支持关键词搜索、标签筛选、心情筛选、结果分类 tab。 // 支持关键词搜索、标签筛选、心情筛选、结果分类 tab。
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/models/journal_entry.dart'; import '../../../data/models/journal_entry.dart';
@@ -50,6 +51,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
searchHistory: List.unmodifiable(_searchHistory), searchHistory: List.unmodifiable(_searchHistory),
)); ));
} catch (e) { } catch (e) {
debugPrint('SearchBloc._onSearchByMood 失败: $e');
emit(const SearchError('搜索失败,请重试')); emit(const SearchError('搜索失败,请重试'));
} }
} }
@@ -73,6 +75,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
searchHistory: List.unmodifiable(_searchHistory), searchHistory: List.unmodifiable(_searchHistory),
)); ));
} catch (e) { } catch (e) {
debugPrint('SearchBloc._onSearchByTag 失败: $e');
emit(const SearchError('搜索失败,请重试')); emit(const SearchError('搜索失败,请重试'));
} }
} }
@@ -113,6 +116,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
searchHistory: List.unmodifiable(_searchHistory), searchHistory: List.unmodifiable(_searchHistory),
)); ));
} catch (e) { } catch (e) {
debugPrint('SearchBloc._onSearchByKeyword 失败: $e');
emit(const SearchError('搜索失败,请重试')); emit(const SearchError('搜索失败,请重试'));
} }
} }

View File

@@ -17,6 +17,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_radius.dart';
import '../../../core/utils/mood_utils.dart'; import '../../../core/utils/mood_utils.dart';
import '../../../data/models/journal_entry.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'; import '../bloc/search_bloc.dart';
/// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类 /// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类
@@ -31,18 +34,42 @@ class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _searchFocusNode = FocusNode(); final _searchFocusNode = FocusNode();
// 热门搜索占位数据 // 热门搜索 — 从用户日记标签动态推导,无数据时使用默认推荐
final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸']; List<String> _hotSearches = ['日常', '学校', '旅行', '心情', '读书', '手账'];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_deriveHotSearches();
// 自动弹出键盘 // 自动弹出键盘
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus(); _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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -490,79 +517,10 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
// ===== 6E: 模板结果(占位 ===== // ===== 6E: 模板结果(动态加载 =====
Widget _buildTemplateResults(ThemeData theme, bool isDark) { Widget _buildTemplateResults(ThemeData theme, bool isDark) {
// Phase 1 占位 — 模板功能未实现 return _TemplateSearchGrid(theme: theme, isDark: isDark);
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),
),
),
],
),
),
],
),
);
},
);
} }
// ===== 6E: 标签结果 ===== // ===== 6E: 标签结果 =====
@@ -781,3 +739,124 @@ extension _PadAll on Widget {
child: this, 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),
),
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -1,5 +1,6 @@
// 贴纸 BLoC — 通过 API 加载贴纸包和贴纸数据 // 贴纸 BLoC — 通过 API 加载贴纸包和贴纸数据
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:nuanji_app/data/remote/api_client.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); _state = _state.copyWith(isLoading: false, packs: packs);
} catch (e) { } catch (e) {
debugPrint('StickerBloc._fetchPacks 失败: $e');
_state = _state.copyWith( _state = _state.copyWith(
isLoading: false, isLoading: false,
errorMessage: '加载贴纸包失败', errorMessage: '加载贴纸包失败',
@@ -194,6 +196,7 @@ class StickerBloc extends ChangeNotifier {
); );
}).toList(); }).toList();
} catch (e) { } catch (e) {
debugPrint('StickerBloc.fetchStickersInPack 失败: $e');
return []; return [];
} }
} }

View File

@@ -5,6 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/remote/api_client.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'; import '../bloc/sticker_bloc.dart';
/// 贴纸库页面 — 分类浏览贴纸包 /// 贴纸库页面 — 分类浏览贴纸包
@@ -19,10 +21,19 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
late final StickerBloc _bloc; late final StickerBloc _bloc;
final _searchController = TextEditingController(); final _searchController = TextEditingController();
/// 设计规格中的 8 个分类 /// 默认分类 — 从 API 数据动态补充
static const _specCategories = [ 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 @override
void initState() { void initState() {
@@ -55,18 +66,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
} }
if (state.errorMessage != null) { if (state.errorMessage != null) {
return Center( return ErrorStateWidget(
child: Column( message: state.errorMessage ?? '加载失败',
mainAxisAlignment: MainAxisAlignment.center, onRetry: _bloc.load,
children: [ icon: Icons.error_outline,
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
); );
} }
@@ -120,7 +123,7 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
children: _specCategories.map((cat) { children: _categories.map((cat) {
final isSelected = cat == state.selectedCategory || final isSelected = cat == state.selectedCategory ||
(cat == '推荐' && state.selectedCategory == '全部'); (cat == '推荐' && state.selectedCategory == '全部');
return Padding( return Padding(
@@ -148,19 +151,22 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// ---- 精选贴纸包卡片 ---- // ---- 精选贴纸包卡片(动态数据) ----
if (state.selectedCategory == '全部') if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), 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), const SizedBox(height: 16),
// ---- 贴纸包网格 ---- // ---- 贴纸包网格 ----
Expanded( Expanded(
child: state.filteredPacks.isEmpty child: state.filteredPacks.isEmpty
? const Center(child: Text('暂无贴纸包')) ? const EmptyStateWidget(
icon: Icons.sticky_note_2_outlined,
title: '暂无贴纸包',
)
: GridView.builder( : GridView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
gridDelegate: gridDelegate:
@@ -188,9 +194,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
} }
} }
/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签 /// 精选贴纸包卡片 — 渐变背景 + 动态数据
class _FeaturedPackCard extends StatelessWidget { class _FeaturedPackCard extends StatelessWidget {
const _FeaturedPackCard(); const _FeaturedPackCard({required this.pack});
final StickerPack pack;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -198,7 +205,7 @@ class _FeaturedPackCard extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('打开精选贴纸包: 治愈小动物')), SnackBar(content: Text('打开精选贴纸包: ${pack.name}')),
); );
}, },
child: Container( child: Container(
@@ -214,7 +221,6 @@ class _FeaturedPackCard extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// emoji 图标区域
Container( Container(
width: 64, width: 64,
height: 64, height: 64,
@@ -223,30 +229,38 @@ class _FeaturedPackCard extends StatelessWidget {
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: const Text('🧸', style: TextStyle(fontSize: 36)), child: Text(pack.displayCover, style: const TextStyle(fontSize: 36)),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('治愈小动物', style: theme.textTheme.titleMedium?.copyWith( Text(pack.name, style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, color: Colors.white, fontWeight: FontWeight.w700, color: Colors.white,
)), )),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('超可爱的手绘小动物贴纸', style: theme.textTheme.bodySmall?.copyWith( Text(
color: Colors.white.withValues(alpha: 0.85), 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), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.secondary, color: pack.isFree ? AppColors.secondary : AppColors.rose,
borderRadius: AppRadius.pillBorder, borderRadius: AppRadius.pillBorder,
), ),
child: const Text('限时免费', style: TextStyle( child: Text(
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, pack.isFree ? '免费' : '精品',
)), style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
),
),
), ),
], ],
), ),

View File

@@ -67,11 +67,7 @@ class _TeacherView extends StatelessWidget {
iconColor: AppColors.tertiary, iconColor: AppColors.tertiary,
title: '班级码管理', title: '班级码管理',
subtitle: '查看和重置班级码', subtitle: '查看和重置班级码',
onTap: () { onTap: () => _showClassCodes(context),
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('班级码: a1b2c3')),
);
},
), ),
const SizedBox(height: 24), 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) { void _showAssignTopicDialog(BuildContext context) {
final titleController = TextEditingController(); final titleController = TextEditingController();
final descController = TextEditingController(); final descController = TextEditingController();

View File

@@ -1,5 +1,6 @@
// 模板 BLoC — 通过 API 加载模板列表 // 模板 BLoC — 通过 API 加载模板列表
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:nuanji_app/data/remote/api_client.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); _state = _state.copyWith(isLoading: false, templates: templates);
} catch (e) { } catch (e) {
debugPrint('TemplateBloc._fetchTemplates 失败: $e');
_state = _state.copyWith( _state = _state.copyWith(
isLoading: false, isLoading: false,
errorMessage: '加载模板列表失败', errorMessage: '加载模板列表失败',

View File

@@ -293,13 +293,22 @@ class _TemplateCard extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 标签 // 标签(从模板 category 动态生成)
Wrap( Wrap(
spacing: 6, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: [ children: [
_TagPill(label: '学生专属', bgColor: secondarySoft, textColor: AppColors.secondary), if (template.category != null && template.category!.isNotEmpty)
_TagPill(label: '简约', bgColor: tertiarySoft, textColor: AppColors.tertiary), _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), const SizedBox(height: 8),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -406,54 +406,6 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.34" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

View File

@@ -30,8 +30,11 @@ dependencies:
# 连接检测 # 连接检测
connectivity_plus: ^6.1.0 connectivity_plus: ^6.1.0
# 安全存储JWT 令牌持久化PIPL 合规 # 安全存储JWT 令牌持久化)
flutter_secure_storage: ^9.2.0 # 注意: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 perfect_freehand: ^1.0.0

View 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" 不在词库中',
);
}
});
});
}

Some files were not shown because too many files have changed in this diff Show More