Compare commits
117 Commits
5e6c6fdd62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af7cd64e6 | ||
|
|
ec8a04c80a | ||
|
|
750605e479 | ||
|
|
346c751cbb | ||
|
|
2f96f9a4f4 | ||
|
|
f64355946c | ||
|
|
1f48a67db5 | ||
|
|
225af89e41 | ||
|
|
dbb74b6545 | ||
|
|
3c3d70c751 | ||
|
|
ed8252d7c8 | ||
|
|
41ef28f20b | ||
|
|
d67eedf7de | ||
|
|
a05374e8d1 | ||
|
|
a5d2b0409f | ||
|
|
3bc2ca7332 | ||
|
|
4cb91f3ac9 | ||
|
|
c253c8ddcf | ||
|
|
bb388ed8ff | ||
|
|
c441aa4e34 | ||
|
|
e635557e67 | ||
|
|
138bfa9723 | ||
|
|
b72009718f | ||
|
|
9fce34f4ef | ||
|
|
988ee7335a | ||
|
|
9c92cba87f | ||
|
|
f6d394afb6 | ||
|
|
4cd08535d3 | ||
|
|
271f0c4f29 | ||
|
|
4cd381295a | ||
|
|
8300822232 | ||
|
|
367f21de08 | ||
|
|
1766cefde9 | ||
|
|
38592d61ce | ||
|
|
e8df3a9562 | ||
|
|
32a91551c4 | ||
|
|
b6ffc60331 | ||
|
|
4e5c1287a6 | ||
|
|
3258acaa77 | ||
|
|
0c9ada242a | ||
|
|
99db8e5cb0 | ||
|
|
a34c9fd176 | ||
|
|
45949e3ed0 | ||
|
|
c4b2de8294 | ||
|
|
cca2d77ea2 | ||
|
|
6d7ac05d0f | ||
|
|
11d0971a67 | ||
|
|
b81a972245 | ||
|
|
af7d3f65fd | ||
|
|
9ce300ddb9 | ||
|
|
e0052ea99b | ||
|
|
1750f17f41 | ||
|
|
5f06056d26 | ||
|
|
935918c9ab | ||
|
|
d482497e49 | ||
|
|
45530616ee | ||
|
|
d6dd017155 | ||
|
|
f0741450bc | ||
|
|
c9a69d0be1 | ||
|
|
9e53ca8555 | ||
|
|
6c9a38b27b | ||
|
|
e57c3427a4 | ||
|
|
c92ead60e3 | ||
|
|
ab45f40cc8 | ||
|
|
8ea1032c9d | ||
|
|
94bfb3297a | ||
|
|
85d6781372 | ||
|
|
860844a399 | ||
|
|
4d5ddf35a7 | ||
|
|
a83909dd24 | ||
|
|
49d4aa36a7 | ||
|
|
7e928ae1e1 | ||
|
|
75db6a7eb7 | ||
|
|
74551d48e6 | ||
|
|
78018a9a64 | ||
|
|
0a9e5b1cb3 | ||
|
|
8111471e93 | ||
|
|
181bfb1f3e | ||
|
|
b320641d9c | ||
|
|
749ef55b89 | ||
|
|
ffde0c9e77 | ||
|
|
f0921d554c | ||
|
|
4c743e150e | ||
|
|
3eaf83c79a | ||
|
|
973bb56af6 | ||
|
|
55285b57a7 | ||
|
|
b3fc066aac | ||
|
|
6cb288b4f2 | ||
|
|
0c6a33d96b | ||
|
|
6378da055f | ||
|
|
db881c25a0 | ||
|
|
57b45f7cbf | ||
|
|
89c1cefb11 | ||
|
|
cd86156590 | ||
|
|
f8a20d673e | ||
|
|
9785370922 | ||
|
|
fef2d629e5 | ||
|
|
d392515f4a | ||
|
|
417dcb08e4 | ||
|
|
92a70ca2ed | ||
|
|
075536660a | ||
|
|
ab58186ab3 | ||
|
|
c2a95798bd | ||
|
|
8e3e232278 | ||
|
|
33dc5e19e4 | ||
|
|
d1a07229e2 | ||
|
|
2481c8fce6 | ||
|
|
e07da7addb | ||
|
|
8331db63ba | ||
|
|
860e9e5d22 | ||
|
|
263ddf31a6 | ||
|
|
05317d50d5 | ||
|
|
c4a317c90f | ||
|
|
7e3597dc77 | ||
|
|
482eb244d5 | ||
|
|
0fe3bc705c | ||
|
|
232a53dbed |
54
.github/workflows/main-merge.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Main Merge
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: crates → target
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: cargo check
|
||||||
|
run: cargo check --all-targets
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --all
|
||||||
|
|
||||||
|
- name: cargo audit # 安全审计(可选,允许失败)
|
||||||
|
run: |
|
||||||
|
cargo install cargo-audit 2>/dev/null || true
|
||||||
|
cargo audit || true
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.x'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter test
|
||||||
|
run: flutter test
|
||||||
|
working-directory: app
|
||||||
56
.github/workflows/pr-check.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: PR Check
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 后端检查
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: crates → target
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: cargo check
|
||||||
|
run: cargo check --all-targets
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --all
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# 前端检查
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.x'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter test
|
||||||
|
run: flutter test
|
||||||
|
working-directory: app
|
||||||
3
.gitignore
vendored
@@ -98,6 +98,9 @@ trace-*.json
|
|||||||
# Graphify knowledge graph (regenerated locally)
|
# Graphify knowledge graph (regenerated locally)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
|
# Understand-Anything knowledge graph (local dev tool)
|
||||||
|
.understand-anything/
|
||||||
|
|
||||||
# Native miniprogram (separate project)
|
# Native miniprogram (separate project)
|
||||||
apps/mp-native/
|
apps/mp-native/
|
||||||
|
|
||||||
|
|||||||
183
CLAUDE.md
@@ -36,8 +36,13 @@ nj/ (一个仓库)
|
|||||||
│ ├── src/service/ # ~12 Service
|
│ ├── src/service/ # ~12 Service
|
||||||
│ ├── src/handler/ # ~10 Handler
|
│ ├── src/handler/ # ~10 Handler
|
||||||
│ └── src/{dto,error,event,state}.rs
|
│ └── src/{dto,error,event,state}.rs
|
||||||
├── app/ # Flutter 前端 (待创建)
|
├── apps/ # 🆕 管理端前端 (从 HMS 基座复用)
|
||||||
├── config/ # 服务器配置
|
│ └── web/ # React + Ant Design + Vite (:5174)
|
||||||
|
│ ├── src/pages/ # 管理页面 (用户/角色/权限/审计...)
|
||||||
|
│ └── vite.config.ts # API 代理 → localhost:3000
|
||||||
|
├── app/ # Flutter 学生端 (:8080)
|
||||||
|
├── scripts/dev.sh # 🆕 统一启动脚本 (自动清理端口)
|
||||||
|
├── config/ # 服务器配置 (CORS=*)
|
||||||
├── docker/ # Docker Compose (PG + Redis)
|
├── docker/ # Docker Compose (PG + Redis)
|
||||||
├── docs/ # 产品文档
|
├── docs/ # 产品文档
|
||||||
│ └── superpowers/specs/ # 设计规格 v1.2
|
│ └── superpowers/specs/ # 设计规格 v1.2
|
||||||
@@ -62,6 +67,7 @@ nj/ (一个仓库)
|
|||||||
| BPMN 工作流 | erp-workflow 继承 | 零开发 |
|
| BPMN 工作流 | erp-workflow 继承 | 零开发 |
|
||||||
| SeaORM 迁移框架 | erp-server 继承 | 零开发 |
|
| SeaORM 迁移框架 | erp-server 继承 | 零开发 |
|
||||||
| OpenAPI 文档 | utoipa 继承 | 零开发 |
|
| OpenAPI 文档 | utoipa 继承 | 零开发 |
|
||||||
|
| 管理端 Web 前端 | HMS apps/web/ 复用 | 零开发 (品牌替换待做) |
|
||||||
| student/teacher/parent 角色 | erp-auth 扩展 | 🆕 ~200 行 |
|
| student/teacher/parent 角色 | erp-auth 扩展 | 🆕 ~200 行 |
|
||||||
| 班级码认证 | erp-auth 扩展 | 🆕 ~500 行 |
|
| 班级码认证 | erp-auth 扩展 | 🆕 ~500 行 |
|
||||||
| 日记 CRUD + 同步 | erp-diary 新增 | 🆕 ~2000 行 |
|
| 日记 CRUD + 同步 | erp-diary 新增 | 🆕 ~2000 行 |
|
||||||
@@ -321,13 +327,148 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
|
|||||||
- 当遇到**新增数据表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
- 当遇到**新增数据表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||||
- 当遇到**跨模块通信** → 定义事件类型到 erp-diary/src/event.rs,通过 EventBus 发布
|
- 当遇到**跨模块通信** → 定义事件类型到 erp-diary/src/event.rs,通过 EventBus 发布
|
||||||
- 当遇到**新增 Flutter 功能** → 创建 features/{name}/ 目录,bloc/views/widgets 分层
|
- 当遇到**新增 Flutter 功能** → 创建 features/{name}/ 目录,bloc/views/widgets 分层
|
||||||
|
- 当遇到**管理端修改** → 在 apps/web/ 中修改 React 组件,`pnpm dev` 启动开发服务器
|
||||||
|
- 当遇到**管理端新增页面** → 在 apps/web/src/pages/ 添加,更新 routeConfig.ts + 侧边栏菜单
|
||||||
- 当遇到**手写性能问题** → 检查 shouldRepaint 守卫 + 笔画光栅化缓存 + Listener 替代 GestureDetector
|
- 当遇到**手写性能问题** → 检查 shouldRepaint 守卫 + 笔画光栅化缓存 + Listener 替代 GestureDetector
|
||||||
- 当遇到**同步冲突** → 版本号比对,Phase 1 使用"本地优先"简单策略
|
- 当遇到**同步冲突** → 版本号比对,Phase 1 使用"本地优先"简单策略
|
||||||
- 当遇到**儿童数据** → 确认 PIPL 合规检查清单(家长授权/最小数据/加密/注销机制)
|
- 当遇到**儿童数据** → 确认 PIPL 合规检查清单(家长授权/最小数据/加密/注销机制)
|
||||||
|
- 当遇到**启动端口占用** → `./scripts/dev.sh stop` 清理所有旧进程
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 参考文档
|
## 8. 上下文管理与会话交接
|
||||||
|
|
||||||
|
> **核心问题:** 长时间开发会话会触发 "API Error: The model has reached its context window limit.",导致工作中断。
|
||||||
|
> **解决方案:** 在上下文即将耗尽前,主动执行会话交接,确保新会话能无缝续接。
|
||||||
|
|
||||||
|
### 8.1 触发条件
|
||||||
|
|
||||||
|
当出现以下**任一**信号时,立即执行 §8.2 交接流程:
|
||||||
|
|
||||||
|
- 感觉对话已经很长,处理速度明显变慢
|
||||||
|
- 已经完成了 2 个以上独立的功能开发步骤
|
||||||
|
- 单次会话中修改了 10+ 个文件
|
||||||
|
- 用户提醒上下文快满了
|
||||||
|
|
||||||
|
### 8.2 会话交接流程
|
||||||
|
|
||||||
|
**不要等到报错才交接!** 主动在合适的节点(完成一个 Phase、通过测试后)执行:
|
||||||
|
|
||||||
|
**步骤 1 — 收尾当前工作:**
|
||||||
|
- 确保当前代码改动已提交(`git add` + `git commit`)
|
||||||
|
- 确认 `cargo check` 和 `cargo test`(涉及前端时 `flutter analyze`)通过
|
||||||
|
- 未完成的工作不要提交到 main — 用 stash 或临时分支暂存
|
||||||
|
|
||||||
|
**步骤 2 — 更新项目记忆文件:**
|
||||||
|
|
||||||
|
更新 `~/.claude/projects/g--nj/memory/project-status.md`,包含:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 已完成 (截至 YYYY-MM-DD)
|
||||||
|
- [x] Phase X: 具体完成内容 (commit hash)
|
||||||
|
- [x] Phase Y: 具体完成内容 (commit hash)
|
||||||
|
|
||||||
|
## 当前进行中 / 未完成
|
||||||
|
- 正在做的事情,描述到什么程度了,还剩什么
|
||||||
|
- 例如:"F2 认证模块 — Auth BLoC 已完成,角色选择页未开始"
|
||||||
|
|
||||||
|
## 下一步工作 (按优先级)
|
||||||
|
1. 具体的下一个任务,写清楚要做什么、在哪个文件
|
||||||
|
2. 第二个任务
|
||||||
|
3. 第三个任务
|
||||||
|
|
||||||
|
## 本会话的关键决策/发现
|
||||||
|
- 记录影响后续工作的决策、踩过的坑、发现的注意事项
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 3 — 向用户输出交接摘要:**
|
||||||
|
|
||||||
|
用以下格式明确告诉用户,方便在新会话中说"按计划继续":
|
||||||
|
|
||||||
|
```
|
||||||
|
## 交接摘要
|
||||||
|
|
||||||
|
### 本会话完成
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### 下一步 (新会话直接做)
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 新会话启动指南
|
||||||
|
|
||||||
|
新会话启动时,AI 应:
|
||||||
|
|
||||||
|
1. **读取记忆文件** — `~/.claude/projects/g--nj/memory/project-status.md`
|
||||||
|
2. **确认代码状态** — `git log --oneline -5` + `cargo check`
|
||||||
|
3. **读取规划文档** — `plans/hazy-petting-lampson.md` 中对应阶段
|
||||||
|
4. **直接开始工作** — 不要重复已完成的分析,按记忆中的"下一步工作"推进
|
||||||
|
|
||||||
|
### 8.4 交接质量标准
|
||||||
|
|
||||||
|
一份合格的交接必须让新会话**无需猜测**就能继续工作:
|
||||||
|
|
||||||
|
- ✅ 下一步是具体的文件和功能,不是模糊的方向
|
||||||
|
- ✅ 未完成的工作状态清晰(做了多少、还差什么)
|
||||||
|
- ✅ 关键决策已记录(为什么这样做、有哪些备选方案被否决)
|
||||||
|
- ✅ 所有代码已提交,工作区干净
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 参考文档
|
||||||
|
|
||||||
|
| 文档 | 位置 |
|
||||||
|
|------|------|
|
||||||
|
| **知识库首页** | `wiki/index.md` — 症状导航 + 模块索引 |
|
||||||
|
| 架构决策 | `wiki/architecture.md` — 基座剥离 + Feature Flag + 多租户 |
|
||||||
|
| 手写引擎 | `wiki/handwriting-engine.md` — 双层 Canvas + 光栅化缓存 |
|
||||||
|
| 数据层 | `wiki/data-layer.md` — Isar + SyncEngine 离线同步 |
|
||||||
|
| Flutter 前端 | `wiki/frontend.md` — 16 模块 + BLoC + 设计系统 |
|
||||||
|
| 管理端前端 | `wiki/admin-web.md` — React + Ant Design + 品牌定制清单 |
|
||||||
|
| 后端模块 | `wiki/erp-diary.md` — Entity/Service/Handler 清单 |
|
||||||
|
| 技术债看板 | `docs/tech-debt-board.md` — 10 条待偿还债务 |
|
||||||
|
| 产品设计规格 v1.2 | `docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md` |
|
||||||
|
| 实施规划 v2.1 | `plans/hazy-petting-lampson.md` |
|
||||||
|
| 头脑风暴文档 (8 份) | `.superpowers/brainstorm/734-1780218658/` |
|
||||||
|
| 基座仓库 | https://git.stableeasy.com/iven/base.git |
|
||||||
|
| HMS 源码 (只读参考) | G:\hms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 开发环境
|
||||||
|
|
||||||
|
### 三端启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键启动全部 (后端 + 管理端 + 学生端)
|
||||||
|
./scripts/dev.sh
|
||||||
|
|
||||||
|
# 单独启动
|
||||||
|
./scripts/dev.sh backend # Rust Axum → :3000
|
||||||
|
./scripts/dev.sh admin # React Vite → :5174
|
||||||
|
./scripts/dev.sh app # Flutter Web → :8080
|
||||||
|
|
||||||
|
# 停止所有 (自动清理端口)
|
||||||
|
./scripts/dev.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
管理端默认账号: `admin / admin123`
|
||||||
|
|
||||||
|
### 环境依赖
|
||||||
|
|
||||||
|
| 服务 | 地址 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| PostgreSQL 16 | localhost:5432 | 数据库 `nuanji` |
|
||||||
|
| Redis 7 | localhost:6379 | 缓存/速率限制 |
|
||||||
|
| Flutter SDK | D:\flutter\bin\flutter.bat | 学生端 |
|
||||||
|
| Node.js + pnpm | - | 管理端 |
|
||||||
|
| Rust toolchain | stable | 后端 |
|
||||||
|
|
||||||
|
### 参考文档
|
||||||
|
|
||||||
| 文档 | 位置 |
|
| 文档 | 位置 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -337,39 +478,3 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
|
|||||||
| 基座仓库 | https://git.stableeasy.com/iven/base.git |
|
| 基座仓库 | https://git.stableeasy.com/iven/base.git |
|
||||||
| HMS 源码 (只读参考) | G:\hms |
|
| HMS 源码 (只读参考) | G:\hms |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 开发环境
|
|
||||||
|
|
||||||
### 后端
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 编译检查
|
|
||||||
cargo check
|
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# 启动后端 (需要 PG + Redis)
|
|
||||||
cd crates/erp-server && cargo run
|
|
||||||
|
|
||||||
# Docker 环境
|
|
||||||
cd docker && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端 (待创建)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd app
|
|
||||||
flutter pub get
|
|
||||||
flutter run
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
配置文件:`config/default.toml`,环境变量覆盖前缀 `ERP__`,分隔符 `__`
|
|
||||||
|
|
||||||
数据库:PostgreSQL 16 (端口 5432)
|
|
||||||
Redis:7-alpine (端口 6379)
|
|
||||||
API:`http://localhost:8080/api/v1/`
|
|
||||||
|
|||||||
1
Cargo.lock
generated
@@ -1457,6 +1457,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"erp-auth",
|
"erp-auth",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
|
"redis",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ migration:
|
|||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: android
|
- platform: windows
|
||||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
|
||||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
|
||||||
- platform: ios
|
|
||||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,38 @@
|
|||||||
|
# 暖记 ProGuard 规则
|
||||||
|
# 保留 Flutter 引擎
|
||||||
|
-keep class io.flutter.app.** { *; }
|
||||||
|
-keep class io.flutter.plugin.** { *; }
|
||||||
|
-keep class io.flutter.util.** { *; }
|
||||||
|
-keep class io.flutter.view.** { *; }
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Isar 数据库 — 保留 native 调用
|
||||||
|
-keep class com.isor.** { *; }
|
||||||
|
-keep class isar.** { *; }
|
||||||
|
-keepclassmembers class ** {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dio 网络库
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
-keep class okhttp3.** { *; }
|
||||||
|
-keep interface okhttp3.** { *; }
|
||||||
|
|
||||||
|
# Gson / JSON 序列化
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
|
||||||
|
# freezed 生成的类 — 保留 JSON 序列化
|
||||||
|
-keepclassmembers class **.models.** {
|
||||||
|
*** fromJson(...);
|
||||||
|
*** toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保留所有序列化相关类
|
||||||
|
-keepclassmembers class * {
|
||||||
|
*** INSTANCE;
|
||||||
|
*** Companion;
|
||||||
|
}
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 暖记启动页 (深色模式) — 深色背景 + 居中 logo -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/bg_dark" />
|
||||||
|
<item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@drawable/launch_logo" />
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
BIN
app/android/app/src/main/res/drawable/launch_logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 12 KiB |
4
app/android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="launch_bg">#1A1614</color>
|
||||||
|
</resources>
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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>
|
||||||
|
|||||||
4
app/android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">暖记</string>
|
||||||
|
</resources>
|
||||||
9
app/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 暖记设计系统颜色 -->
|
||||||
|
<color name="bg_light">#FFF8F0</color> <!-- 奶油白背景 -->
|
||||||
|
<color name="bg_dark">#1A1614</color> <!-- 深色背景 -->
|
||||||
|
<color name="accent">#E07A5F</color> <!-- 珊瑚色主色 -->
|
||||||
|
<color name="fg_light">#2D2420</color> <!-- 浅色模式文字 -->
|
||||||
|
<color name="fg_dark">#F0E8DF</color> <!-- 深色模式文字 -->
|
||||||
|
</resources>
|
||||||
4
app/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">暖记</string>
|
||||||
|
</resources>
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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>
|
||||||
|
|||||||
20
app/android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 网络安全配置 — 强制 HTTPS,仅允许 localhost 明文(开发用)
|
||||||
|
审计 ID: 6b-C01 — Flutter 默认 HTTP 明文传输修复
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<!-- 生产配置:强制 HTTPS -->
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
|
||||||
|
<!-- 开发配置:允许 localhost/10.0.2.2 明文(模拟器/本地调试)
|
||||||
|
生产构建时应移除此段 -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
allprojects {
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
BIN
app/assets/fonts/Caveat-Bold.ttf
Normal file
BIN
app/assets/fonts/Caveat-Regular.ttf
Normal file
BIN
app/assets/fonts/NotoSansSC-Bold.ttf
Normal file
BIN
app/assets/fonts/NotoSansSC-Regular.ttf
Normal file
BIN
app/assets/fonts/Nunito-Bold.ttf
Normal file
BIN
app/assets/fonts/Nunito-Regular.ttf
Normal file
BIN
app/assets/fonts/Nunito-SemiBold.ttf
Normal file
93
app/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
app/assets/fonts/Quicksand-Bold.ttf
Normal file
BIN
app/assets/fonts/Quicksand-Regular.ttf
Normal file
BIN
app/assets/fonts/Quicksand-SemiBold.ttf
Normal file
143
app/lib/app.dart
@@ -1,10 +1,147 @@
|
|||||||
|
// 暖记 App 根组件 — MaterialApp + BLoC Provider 注入
|
||||||
|
//
|
||||||
|
// 依赖注入结构:
|
||||||
|
// MultiRepositoryProvider
|
||||||
|
// ├─ ApiClient
|
||||||
|
// ├─ AuthRepository
|
||||||
|
// ├─ JournalRepository (IsarJournalRepository — 离线优先)
|
||||||
|
// ├─ RemoteJournalRepository (供 SyncEngine 使用)
|
||||||
|
// └─ ClassRepository
|
||||||
|
// └─ BlocProvider<AuthBloc>
|
||||||
|
// └─ MaterialApp.router
|
||||||
|
|
||||||
|
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:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart' show ListenableProvider;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'config/app_config.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/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/repositories/auth_repository.dart';
|
||||||
|
import 'data/repositories/journal_repository.dart';
|
||||||
|
import 'data/repositories/isar_journal_repository.dart';
|
||||||
|
import 'data/repositories/remote_journal_repository.dart';
|
||||||
|
import 'data/repositories/class_repository.dart';
|
||||||
|
import 'data/services/sync_engine.dart';
|
||||||
|
import 'features/auth/bloc/auth_bloc.dart';
|
||||||
|
import 'features/profile/bloc/settings_bloc.dart';
|
||||||
|
|
||||||
class NuanjiApp extends StatelessWidget {
|
/// 暖记 App — 根组件
|
||||||
|
class NuanjiApp extends StatefulWidget {
|
||||||
const NuanjiApp({super.key});
|
const NuanjiApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NuanjiApp> createState() => _NuanjiAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NuanjiAppState extends State<NuanjiApp> {
|
||||||
|
late final ApiClient _apiClient;
|
||||||
|
late final AuthRepository _authRepository;
|
||||||
|
late final JournalRepository _journalRepository;
|
||||||
|
late final RemoteJournalRepository _remoteJournalRepository;
|
||||||
|
late final SyncEngine _syncEngine;
|
||||||
|
late final ClassRepository _classRepository;
|
||||||
|
late final SettingsBloc _settingsBloc;
|
||||||
|
late final AuthBloc _authBloc;
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initApp() async {
|
||||||
|
final config = kDebugMode ? AppConfig.dev : AppConfig.fromEnvironment();
|
||||||
|
_apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||||
|
final tokenStore = createSecureTokenStore();
|
||||||
|
_authRepository = AuthRepository(apiClient: _apiClient, tokenStore: tokenStore);
|
||||||
|
_journalRepository = kIsWeb
|
||||||
|
? RemoteJournalRepository(api: _apiClient)
|
||||||
|
: IsarJournalRepository();
|
||||||
|
_remoteJournalRepository = RemoteJournalRepository(api: _apiClient);
|
||||||
|
_syncEngine = SyncEngine(apiClient: _apiClient);
|
||||||
|
_classRepository = ClassRepository(api: _apiClient);
|
||||||
|
_settingsBloc = SettingsBloc(
|
||||||
|
prefs: await SharedPreferences.getInstance(),
|
||||||
|
);
|
||||||
|
_authBloc = AuthBloc(
|
||||||
|
authRepository: _authRepository,
|
||||||
|
classRepository: _classRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 启动时检查认证状态
|
||||||
|
_authBloc.add(const AppStarted());
|
||||||
|
|
||||||
|
// 异步恢复 SyncEngine 持久化队列
|
||||||
|
_syncEngine.restorePendingQueue();
|
||||||
|
_syncEngine.startAutoSync();
|
||||||
|
|
||||||
|
// 认证状态监听:登出时清除 token
|
||||||
|
_authBloc.stream.listen((state) {
|
||||||
|
if (state is! Authenticated) {
|
||||||
|
_apiClient.clearToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token 刷新彻底失败时 → 派发 AuthExpired
|
||||||
|
_apiClient.onAuthFailed = () {
|
||||||
|
_authBloc.add(const AuthExpired());
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(() => _initialized = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_initialized) {
|
||||||
|
return const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MultiRepositoryProvider(
|
||||||
|
providers: [
|
||||||
|
RepositoryProvider<ApiClient>.value(value: _apiClient),
|
||||||
|
RepositoryProvider<AuthRepository>.value(value: _authRepository),
|
||||||
|
RepositoryProvider<JournalRepository>.value(value: _journalRepository),
|
||||||
|
RepositoryProvider<RemoteJournalRepository>.value(value: _remoteJournalRepository),
|
||||||
|
RepositoryProvider<SyncEngine>.value(value: _syncEngine),
|
||||||
|
RepositoryProvider<ClassRepository>.value(value: _classRepository),
|
||||||
|
],
|
||||||
|
child: ListenableProvider<SettingsBloc>.value(
|
||||||
|
value: _settingsBloc,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final settings = context.watch<SettingsBloc>();
|
||||||
|
return BlocProvider<AuthBloc>.value(
|
||||||
|
value: _authBloc,
|
||||||
|
child: _AppView(
|
||||||
|
router: createAppRouter(_authBloc),
|
||||||
|
themeMode: settings.state.themeMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App 视图 — MaterialApp.router 包装
|
||||||
|
class _AppView extends StatelessWidget {
|
||||||
|
final GoRouter router;
|
||||||
|
final ThemeMode themeMode;
|
||||||
|
|
||||||
|
const _AppView({required this.router, this.themeMode = ThemeMode.system});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
@@ -12,8 +149,8 @@ class NuanjiApp extends StatelessWidget {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.dark(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: themeMode,
|
||||||
routerConfig: appRouter,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
app/lib/config/app_config.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 应用环境配置 — 通过 --dart-define 注入
|
||||||
|
//
|
||||||
|
// 使用方式:
|
||||||
|
// flutter run # 开发模式(localhost)
|
||||||
|
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 # 生产模式
|
||||||
|
//
|
||||||
|
// 安全说明:
|
||||||
|
// - 生产环境强制 HTTPS(Android network_security_config 禁止明文流量)
|
||||||
|
// - 开发模式使用 localhost(Android 网络安全配置已允许 localhost 明文)
|
||||||
|
|
||||||
|
/// 应用环境配置 — 集中管理所有外部服务地址
|
||||||
|
class AppConfig {
|
||||||
|
/// API 基础 URL(后端 Axum 服务地址)
|
||||||
|
final String apiBaseUrl;
|
||||||
|
|
||||||
|
/// SSE 推送服务 URL(通常与 API 同一地址)
|
||||||
|
final String sseBaseUrl;
|
||||||
|
|
||||||
|
const AppConfig({
|
||||||
|
required this.apiBaseUrl,
|
||||||
|
required this.sseBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 从编译时环境变量构建配置
|
||||||
|
///
|
||||||
|
/// 使用 `--dart-define` 注入,未设置时使用生产 HTTPS 默认值。
|
||||||
|
/// 开发环境使用 [dev] 常量或通过 --dart-define 覆盖。
|
||||||
|
factory AppConfig.fromEnvironment({
|
||||||
|
String defaultApiBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||||
|
String defaultSseBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||||
|
}) {
|
||||||
|
// const String.fromEnvironment 在编译时求值
|
||||||
|
const apiBaseUrl = String.fromEnvironment(
|
||||||
|
'API_BASE_URL',
|
||||||
|
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||||
|
);
|
||||||
|
const sseBaseUrl = String.fromEnvironment(
|
||||||
|
'SSE_BASE_URL',
|
||||||
|
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||||
|
);
|
||||||
|
|
||||||
|
return AppConfig(
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
|
sseBaseUrl: sseBaseUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开发环境默认配置(localhost 明文 — 仅用于本地调试)
|
||||||
|
static const dev = AppConfig(
|
||||||
|
apiBaseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
sseBaseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import 'package:flutter/animation.dart';
|
|||||||
class DesignTokens {
|
class DesignTokens {
|
||||||
DesignTokens._();
|
DesignTokens._();
|
||||||
|
|
||||||
// ===== 间距 =====
|
// ===== 间距(4px 基准,9 级)=====
|
||||||
static const double spacing4 = 4;
|
static const double spacing4 = 4;
|
||||||
static const double spacing8 = 8;
|
static const double spacing8 = 8;
|
||||||
static const double spacing12 = 12;
|
static const double spacing12 = 12;
|
||||||
@@ -13,8 +13,16 @@ class DesignTokens {
|
|||||||
static const double spacing20 = 20;
|
static const double spacing20 = 20;
|
||||||
static const double spacing24 = 24;
|
static const double spacing24 = 24;
|
||||||
static const double spacing32 = 32;
|
static const double spacing32 = 32;
|
||||||
|
static const double spacing40 = 40;
|
||||||
static const double spacing48 = 48;
|
static const double spacing48 = 48;
|
||||||
|
|
||||||
|
// ===== 安全区 & 布局常量(对齐 spec §1)=====
|
||||||
|
static const double safeTop = 54; // iPhone Dynamic Island
|
||||||
|
static const double safeBottom = 34; // Home Indicator
|
||||||
|
static const double tabHeight = 56; // 底部 Tab 栏
|
||||||
|
static const double touchMin = 44; // WCAG 最小触控目标
|
||||||
|
static const double containerMax = 390; // 移动端容器宽度
|
||||||
|
|
||||||
// ===== 动画时长 =====
|
// ===== 动画时长 =====
|
||||||
static const Duration animFast = Duration(milliseconds: 150);
|
static const Duration animFast = Duration(milliseconds: 150);
|
||||||
static const Duration animNormal = Duration(milliseconds: 300);
|
static const Duration animNormal = Duration(milliseconds: 300);
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
// 暖记路由表 — go_router 20 页面
|
// 暖记路由表 — go_router + 认证守卫
|
||||||
|
// 对齐 Open Design: TabBar = 首页/日历/发现/我的,中心 FAB = 写日记
|
||||||
|
//
|
||||||
|
// 路由守卫逻辑:
|
||||||
|
// - 未认证用户访问受保护路由 → 重定向到 /login
|
||||||
|
// - 已认证用户访问 /login → 重定向到 /home
|
||||||
|
// - 需要角色选择 → 重定向到 /role-selection
|
||||||
|
// - 需要班级码 → 重定向到 /class-code
|
||||||
|
|
||||||
export '../../widgets/responsive_scaffold.dart';
|
export '../../widgets/responsive_scaffold.dart';
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
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 '../../widgets/responsive_scaffold.dart';
|
import '../../widgets/responsive_scaffold.dart';
|
||||||
@@ -11,38 +21,133 @@ import '../../features/home/views/home_page.dart';
|
|||||||
import '../../features/calendar/views/calendar_page.dart';
|
import '../../features/calendar/views/calendar_page.dart';
|
||||||
import '../../features/mood/views/mood_page.dart';
|
import '../../features/mood/views/mood_page.dart';
|
||||||
import '../../features/search/views/search_page.dart';
|
import '../../features/search/views/search_page.dart';
|
||||||
|
import '../../features/discover/views/discover_page.dart';
|
||||||
|
import '../../features/calendar/views/weekly_page.dart';
|
||||||
|
import '../../features/calendar/views/monthly_page.dart';
|
||||||
import '../../features/profile/views/profile_page.dart';
|
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/parental_consent_page.dart';
|
||||||
|
import '../../features/auth/views/class_code_join_page.dart';
|
||||||
|
import '../../features/onboarding/views/splash_page.dart';
|
||||||
|
import '../../features/onboarding/views/onboarding_page.dart';
|
||||||
import '../../features/class_/views/class_page.dart';
|
import '../../features/class_/views/class_page.dart';
|
||||||
import '../../features/teacher/views/teacher_page.dart';
|
import '../../features/teacher/views/teacher_page.dart';
|
||||||
import '../../features/parent/views/parent_page.dart';
|
import '../../features/parent/views/parent_page.dart';
|
||||||
|
import '../../features/parent/bloc/parent_bloc.dart';
|
||||||
import '../../features/achievement/views/achievement_page.dart';
|
import '../../features/achievement/views/achievement_page.dart';
|
||||||
import '../../features/stickers/views/sticker_library_page.dart';
|
import '../../features/stickers/views/sticker_library_page.dart';
|
||||||
import '../../features/templates/views/template_gallery_page.dart';
|
import '../../features/templates/views/template_gallery_page.dart';
|
||||||
|
import '../../features/settings/views/settings_page.dart';
|
||||||
|
import '../../features/auth/bloc/auth_bloc.dart';
|
||||||
|
import '../../features/search/bloc/search_bloc.dart';
|
||||||
|
import '../../features/discover/bloc/discover_bloc.dart';
|
||||||
|
import '../../data/repositories/journal_repository.dart';
|
||||||
|
import '../../data/remote/api_client.dart';
|
||||||
|
|
||||||
// Shell 分支键
|
// Shell 分支键
|
||||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
/// 暖记路由配置
|
/// 不需要认证的白名单路径
|
||||||
final appRouter = GoRouter(
|
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/parental-consent', '/class-code'];
|
||||||
|
|
||||||
|
/// 创建路由配置 — 需要注入 AuthBloc
|
||||||
|
GoRouter createAppRouter(AuthBloc authBloc) {
|
||||||
|
return GoRouter(
|
||||||
navigatorKey: _rootNavigatorKey,
|
navigatorKey: _rootNavigatorKey,
|
||||||
initialLocation: '/home',
|
initialLocation: '/splash',
|
||||||
debugLogDiagnostics: true,
|
debugLogDiagnostics: true,
|
||||||
|
|
||||||
|
// ===== 认证路由守卫 =====
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authState = authBloc.state;
|
||||||
|
final currentPath = state.uri.path;
|
||||||
|
|
||||||
|
// 加载中 → 不做重定向
|
||||||
|
if (authState is AuthInitial || authState is AuthLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isAuthenticated = authState is Authenticated;
|
||||||
|
final isPublicPath = _publicPaths.contains(currentPath);
|
||||||
|
|
||||||
|
// 已认证 + 访问公开页面 → 根据状态重定向
|
||||||
|
if (isAuthenticated && isPublicPath) {
|
||||||
|
if (authState.needsRoleSelection) return '/role-selection';
|
||||||
|
if (authState.needsParentalConsent) return '/parental-consent';
|
||||||
|
if (authState.needsClassCode) return '/class-code';
|
||||||
|
return '/home';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已认证 + 访问受保护页面 → 检查是否需要额外步骤
|
||||||
|
if (isAuthenticated) {
|
||||||
|
if (authState.needsRoleSelection && currentPath != '/role-selection') {
|
||||||
|
return '/role-selection';
|
||||||
|
}
|
||||||
|
if (authState.needsParentalConsent &&
|
||||||
|
currentPath != '/parental-consent') {
|
||||||
|
return '/parental-consent';
|
||||||
|
}
|
||||||
|
if (authState.needsClassCode &&
|
||||||
|
currentPath != '/class-code' &&
|
||||||
|
currentPath != '/role-selection' &&
|
||||||
|
currentPath != '/parental-consent') {
|
||||||
|
return '/class-code';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未认证 + 访问公开页面 → 放行
|
||||||
|
if (isPublicPath) return null;
|
||||||
|
|
||||||
|
// 未认证 + 访问受保护页面 → 重定向到登录
|
||||||
|
return '/login';
|
||||||
|
},
|
||||||
|
|
||||||
|
// 监听认证状态变化,自动触发重定向
|
||||||
|
refreshListenable: _AuthListenable(authBloc),
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
|
// 启动页 & 引导页(无 Shell,无需认证)
|
||||||
|
GoRoute(
|
||||||
|
path: '/splash',
|
||||||
|
name: 'splash',
|
||||||
|
builder: (context, state) => const SplashPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/onboarding',
|
||||||
|
name: 'onboarding',
|
||||||
|
builder: (context, state) => const OnboardingPage(),
|
||||||
|
),
|
||||||
|
|
||||||
// 认证路由(无 Shell)
|
// 认证路由(无 Shell)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
builder: (context, state) => const LoginPage(),
|
builder: (context, state) => const LoginPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/role-selection',
|
||||||
|
name: 'roleSelection',
|
||||||
|
builder: (context, state) => const RoleSelectionPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/parental-consent',
|
||||||
|
name: 'parentalConsent',
|
||||||
|
builder: (context, state) => const ParentalConsentPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/class-code',
|
||||||
|
name: 'classCode',
|
||||||
|
builder: (context, state) => const ClassCodeJoinPage(),
|
||||||
|
),
|
||||||
|
|
||||||
// 主 Shell 路由(底部导航 + 侧边导航)
|
// 主 Shell 路由(底部导航: 首页/日历/发现/我的)
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
navigatorKey: _shellNavigatorKey,
|
navigatorKey: _shellNavigatorKey,
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
// 根据当前路径计算选中的 tab index
|
|
||||||
final index = _selectedIndexFromLocation(state.uri.path);
|
final index = _selectedIndexFromLocation(state.uri.path);
|
||||||
return _AppShell(
|
return _AppShell(
|
||||||
selectedIndex: index,
|
selectedIndex: index,
|
||||||
@@ -50,31 +155,29 @@ final appRouter = GoRouter(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
// Tab 0: 首页日记流
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
builder: (context, state) => const HomePage(),
|
builder: (context, state) => const HomePage(),
|
||||||
),
|
),
|
||||||
// Tab 1: 日历
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/calendar',
|
path: '/calendar',
|
||||||
name: 'calendar',
|
name: 'calendar',
|
||||||
builder: (context, state) => const CalendarPage(),
|
builder: (context, state) => const CalendarPage(),
|
||||||
),
|
),
|
||||||
// Tab 2: 心情
|
// 发现页 — 灵感、话题、达人日记(spec §3.12)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/mood',
|
path: '/discover',
|
||||||
name: 'mood',
|
name: 'discover',
|
||||||
builder: (context, state) => const MoodPage(),
|
builder: (context, state) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (_) => DiscoverBloc(api: context.read<ApiClient>())
|
||||||
|
..add(const DiscoverLoadData()),
|
||||||
|
child: const DiscoverPage(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
// Tab 3: 搜索
|
// 个人中心
|
||||||
GoRoute(
|
|
||||||
path: '/search',
|
|
||||||
name: 'search',
|
|
||||||
builder: (context, state) => const SearchPage(),
|
|
||||||
),
|
|
||||||
// Tab 4: 个人中心
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
name: 'profile',
|
name: 'profile',
|
||||||
@@ -83,6 +186,20 @@ final appRouter = GoRouter(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 搜索页 — 全屏无 Tab(spec §3.13)
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
name: 'search',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) {
|
||||||
|
final journalRepo = context.read<JournalRepository>();
|
||||||
|
return BlocProvider(
|
||||||
|
create: (_) => SearchBloc(journalRepository: journalRepo),
|
||||||
|
child: const SearchPage(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// 全屏页面(无底部导航)
|
// 全屏页面(无底部导航)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/editor',
|
path: '/editor',
|
||||||
@@ -90,9 +207,31 @@ final appRouter = GoRouter(
|
|||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final journalId = state.uri.queryParameters['id'];
|
final journalId = state.uri.queryParameters['id'];
|
||||||
return EditorPage(journalId: journalId);
|
final templateId = state.uri.queryParameters['template'];
|
||||||
|
return EditorPage(journalId: journalId, templateId: templateId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// 心情追踪(全屏,从首页心情卡片进入)
|
||||||
|
GoRoute(
|
||||||
|
path: '/mood',
|
||||||
|
name: 'mood',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const MoodPage(),
|
||||||
|
),
|
||||||
|
// 周概览(全屏,从日历页进入)
|
||||||
|
GoRoute(
|
||||||
|
path: '/weekly',
|
||||||
|
name: 'weekly',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const WeeklyPage(),
|
||||||
|
),
|
||||||
|
// 月度概览(全屏,从日历页进入)
|
||||||
|
GoRoute(
|
||||||
|
path: '/monthly',
|
||||||
|
name: 'monthly',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const MonthlyPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/class',
|
path: '/class',
|
||||||
name: 'class',
|
name: 'class',
|
||||||
@@ -109,7 +248,12 @@ final appRouter = GoRouter(
|
|||||||
path: '/parent',
|
path: '/parent',
|
||||||
name: 'parent',
|
name: 'parent',
|
||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
builder: (context, state) => const ParentPage(),
|
builder: (context, state) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (_) => ParentBloc(api: context.read<ApiClient>()),
|
||||||
|
child: const ParentPage(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/achievements',
|
path: '/achievements',
|
||||||
@@ -129,19 +273,44 @@ final appRouter = GoRouter(
|
|||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
builder: (context, state) => const TemplateGalleryPage(),
|
builder: (context, state) => const TemplateGalleryPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const SettingsPage(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 路径 → Tab index 映射
|
/// 路径 → Tab index 映射(4 项: 首页=0, 日历=1, 发现=2, 我的=3)
|
||||||
int _selectedIndexFromLocation(String location) {
|
int _selectedIndexFromLocation(String location) {
|
||||||
if (location.startsWith('/calendar')) return 1;
|
if (location.startsWith('/calendar')) return 1;
|
||||||
if (location.startsWith('/mood')) return 2;
|
if (location.startsWith('/discover')) return 2;
|
||||||
if (location.startsWith('/search')) return 3;
|
if (location.startsWith('/profile')) return 3;
|
||||||
if (location.startsWith('/profile')) return 4;
|
return 0; // /home 或未知路径
|
||||||
return 0; // 默认首页
|
}
|
||||||
|
|
||||||
|
/// AuthBloc 变化监听器 — 驱动 GoRouter refreshListenable
|
||||||
|
class _AuthListenable extends ChangeNotifier {
|
||||||
|
_AuthListenable(AuthBloc authBloc) {
|
||||||
|
_subscription = authBloc.stream.listen((_) {
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final StreamSubscription<AuthState> _subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App Shell — 包裹 ResponsiveScaffold
|
/// App Shell — 包裹 ResponsiveScaffold
|
||||||
|
/// TabBar: 首页(0) / 日历(1) / 发现(2) / 我的(3)
|
||||||
|
/// 中心 FAB: 写日记
|
||||||
class _AppShell extends StatelessWidget {
|
class _AppShell extends StatelessWidget {
|
||||||
const _AppShell({
|
const _AppShell({
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
@@ -162,20 +331,13 @@ class _AppShell extends StatelessWidget {
|
|||||||
case 1:
|
case 1:
|
||||||
context.go('/calendar');
|
context.go('/calendar');
|
||||||
case 2:
|
case 2:
|
||||||
context.go('/mood');
|
context.go('/discover');
|
||||||
case 3:
|
case 3:
|
||||||
context.go('/search');
|
|
||||||
case 4:
|
|
||||||
context.go('/profile');
|
context.go('/profile');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
body: child,
|
body: child,
|
||||||
floatingActionButton: selectedIndex == 0
|
onCenterButtonPressed: () => context.push('/editor'),
|
||||||
? FloatingActionButton(
|
|
||||||
onPressed: () => context.go('/editor'),
|
|
||||||
child: const Icon(Icons.edit_rounded),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// 暖记色彩系统 — 7 色 × 浅色/深色模式
|
// 暖记色彩系统 — 完整设计 Token
|
||||||
// 设计规格 v1.2
|
// 对齐 Open Design 原型稿 tokens.css
|
||||||
|
// 浅色(暖阳) + 深色 + 松风主题色值
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
class AppColors {
|
class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
// ===== 浅色模式 =====
|
// ===== 核心七色 · 浅色模式(暖阳 Warm Sun)=====
|
||||||
|
|
||||||
/// 奶油白背景 #FFF8F0
|
/// 奶油白背景 #FFF8F0
|
||||||
static const Color bgLight = Color(0xFFFFF8F0);
|
static const Color bgLight = Color(0xFFFFF8F0);
|
||||||
@@ -30,7 +31,45 @@ class AppColors {
|
|||||||
/// 玫瑰粉 #D4A5A5
|
/// 玫瑰粉 #D4A5A5
|
||||||
static const Color rose = Color(0xFFD4A5A5);
|
static const Color rose = Color(0xFFD4A5A5);
|
||||||
|
|
||||||
// ===== 深色模式 =====
|
// ===== 中间色 · 浅色模式 =====
|
||||||
|
|
||||||
|
/// 次要文字 #5C4F47
|
||||||
|
static const Color fg2Light = Color(0xFF5C4F47);
|
||||||
|
|
||||||
|
/// 柔和/禁用文字 #7A6D63
|
||||||
|
static const Color mutedLight = Color(0xFF7A6D63);
|
||||||
|
|
||||||
|
/// 辅助说明文字 #8B7E74
|
||||||
|
static const Color metaLight = Color(0xFF8B7E74);
|
||||||
|
|
||||||
|
/// 边框 #E8DDD4
|
||||||
|
static const Color borderLight = Color(0xFFE8DDD4);
|
||||||
|
|
||||||
|
/// 柔和边框 #F0E8DF
|
||||||
|
static const Color borderSoftLight = Color(0xFFF0E8DF);
|
||||||
|
|
||||||
|
/// 主色悬停 #D06A4F
|
||||||
|
static const Color accentHover = Color(0xFFD06A4F);
|
||||||
|
|
||||||
|
/// 主色按下 #C05A3F
|
||||||
|
static const Color accentActive = Color(0xFFC05A3F);
|
||||||
|
|
||||||
|
/// 主色辉光 rgba(224,122,95,0.25)
|
||||||
|
static const Color accentGlow = Color(0x40E07A5F);
|
||||||
|
|
||||||
|
/// 温暖表面 #FFF3E6
|
||||||
|
static const Color surfaceWarmLight = Color(0xFFFFF3E6);
|
||||||
|
|
||||||
|
/// 鼠尾草绿柔和 #D4E8DC
|
||||||
|
static const Color secondarySoftLight = Color(0xFFD4E8DC);
|
||||||
|
|
||||||
|
/// 暖金柔和 #FBE8C8
|
||||||
|
static const Color tertiarySoftLight = Color(0xFFFBE8C8);
|
||||||
|
|
||||||
|
/// 玫瑰柔和 #F0DADA
|
||||||
|
static const Color roseSoftLight = Color(0xFFF0DADA);
|
||||||
|
|
||||||
|
// ===== 核心七色 · 深色模式 =====
|
||||||
|
|
||||||
/// 深色背景 #1A1614
|
/// 深色背景 #1A1614
|
||||||
static const Color bgDark = Color(0xFF1A1614);
|
static const Color bgDark = Color(0xFF1A1614);
|
||||||
@@ -53,29 +92,98 @@ class AppColors {
|
|||||||
/// 深色玫瑰 #C4A0A0
|
/// 深色玫瑰 #C4A0A0
|
||||||
static const Color roseDark = Color(0xFFC4A0A0);
|
static const Color roseDark = Color(0xFFC4A0A0);
|
||||||
|
|
||||||
// ===== 功能色 =====
|
// ===== 中间色 · 深色模式 =====
|
||||||
|
|
||||||
/// 错误红
|
/// 深色次要文字 #C4B8AA
|
||||||
static const Color error = Color(0xFFD32F2F);
|
static const Color fg2Dark = Color(0xFFC4B8AA);
|
||||||
|
|
||||||
/// 成功绿
|
/// 深色柔和文字 #9B8E82
|
||||||
static const Color success = Color(0xFF4CAF50);
|
static const Color mutedDark = Color(0xFF9B8E82);
|
||||||
|
|
||||||
/// 警告黄
|
/// 深色辅助文字 #7A6D63
|
||||||
static const Color warning = Color(0xFFFFA726);
|
static const Color metaDark = Color(0xFF7A6D63);
|
||||||
|
|
||||||
/// 信息蓝
|
/// 深色边框 #3A3530
|
||||||
|
static const Color borderDark = Color(0xFF3A3530);
|
||||||
|
|
||||||
|
/// 深色柔和边框 #302B26
|
||||||
|
static const Color borderSoftDark = Color(0xFF302B26);
|
||||||
|
|
||||||
|
/// 深色主色悬停 #D07A64
|
||||||
|
static const Color accentHoverDark = Color(0xFFD07A64);
|
||||||
|
|
||||||
|
/// 深色主色按下 #C06A54
|
||||||
|
static const Color accentActiveDark = Color(0xFFC06A54);
|
||||||
|
|
||||||
|
/// 深色主色辉光
|
||||||
|
static const Color accentGlowDark = Color(0x40E8907A);
|
||||||
|
|
||||||
|
/// 深色温暖表面 #332D28
|
||||||
|
static const Color surfaceWarmDark = Color(0xFF332D28);
|
||||||
|
|
||||||
|
/// 深色鼠尾草绿柔和 #2A3A2E
|
||||||
|
static const Color secondarySoftDark = Color(0xFF2A3A2E);
|
||||||
|
|
||||||
|
/// 深色暖金柔和 #302A1E
|
||||||
|
static const Color tertiarySoftDark = Color(0xFF302A1E);
|
||||||
|
|
||||||
|
/// 深色玫瑰柔和 #3A2A2A
|
||||||
|
static const Color roseSoftDark = Color(0xFF3A2A2A);
|
||||||
|
|
||||||
|
// ===== 功能色(对齐设计稿)=====
|
||||||
|
|
||||||
|
/// 错误/危险 #C93D3D
|
||||||
|
static const Color error = Color(0xFFC93D3D);
|
||||||
|
|
||||||
|
/// 成功 #5A9E7E
|
||||||
|
static const Color success = Color(0xFF5A9E7E);
|
||||||
|
|
||||||
|
/// 警告 #D4A843
|
||||||
|
static const Color warning = Color(0xFFD4A843);
|
||||||
|
|
||||||
|
/// 信息 #42A5F5
|
||||||
static const Color info = Color(0xFF42A5F5);
|
static const Color info = Color(0xFF42A5F5);
|
||||||
|
|
||||||
// ===== 心情颜色映射 =====
|
// ===== 功能色 · 深色模式 =====
|
||||||
|
|
||||||
/// 心情 → 颜色
|
/// 深色错误 #D94A4A
|
||||||
|
static const Color errorDark = Color(0xFFD94A4A);
|
||||||
|
|
||||||
|
/// 深色成功 #6AAF8E
|
||||||
|
static const Color successDark = Color(0xFF6AAF8E);
|
||||||
|
|
||||||
|
/// 深色警告 #C4A843
|
||||||
|
static const Color warningDark = Color(0xFFC4A843);
|
||||||
|
|
||||||
|
// ===== 阴影色调 =====
|
||||||
|
|
||||||
|
/// 浅色阴影 rgba(45,36,32,...)
|
||||||
|
static const Color shadowLight = Color(0xFF2D2420);
|
||||||
|
|
||||||
|
/// 深色阴影 rgba(0,0,0,...)
|
||||||
|
static const Color shadowDark = Color(0xFF000000);
|
||||||
|
|
||||||
|
// ===== 心情颜色映射 =====
|
||||||
|
// 对齐 spec §2.8 mood-selector: happy/calm/sad/angry/thinking
|
||||||
|
// 对齐 spec §3.6 calendar mood-dot 颜色(开心=secondary, 平静=tertiary, 难过=#5B7DB1)
|
||||||
|
|
||||||
|
/// 心情 → 颜色(主色,用于心情选择器圆圈/标签)
|
||||||
static const Map<String, Color> moodColors = {
|
static const Map<String, Color> moodColors = {
|
||||||
'happy': Color(0xFFFFD93D), // 😊 开心 — 暖黄
|
'happy': secondary, // 😊 开心 — 鼠尾草绿 #81B29A
|
||||||
'calm': Color(0xFF81B29A), // 😌 平静 — 鼠尾草绿
|
'calm': tertiary, // 😌 平静 — 暖金 #F2CC8F
|
||||||
'sad': Color(0xFF7B9CC4), // 😢 难过 — 灰蓝
|
'sad': Color(0xFF5B7DB1), // 😢 难过 — 灰蓝
|
||||||
'angry': Color(0xFFE07A5F), // 😠 生气 — 珊瑚
|
'angry': accent, // 😠 生气 — 珊瑚 #E07A5F
|
||||||
'thinking': Color(0xFFB8A9C9),// 🤔 思考 — 淡紫
|
'thinking': metaLight, // 🤔 思考 — 灰棕 #8B7E74(替代原先的淡紫,spec 无淡紫)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 心情 → 日历单元格背景色
|
||||||
|
/// key 必须与 Mood 枚举值一致: happy/calm/sad/angry/thinking
|
||||||
|
static const Map<String, Color> moodCellColors = {
|
||||||
|
'happy': secondarySoftLight, // 😊 开心 — 鼠尾草绿 #D4E8DC
|
||||||
|
'calm': tertiarySoftLight, // 😌 平静 — 暖金 #FBE8C8
|
||||||
|
'sad': Color(0xFFD4DDE8), // 😢 难过 — 灰蓝
|
||||||
|
'angry': Color(0xFFFFE0D6), // 😠 生气 — 暖珊瑚 (与 primaryContainer 一致)
|
||||||
|
'thinking': Color(0xFFE8E4E0), // 🤔 思考 — 灰棕
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== 浅色主题色彩方案 =====
|
// ===== 浅色主题色彩方案 =====
|
||||||
@@ -83,12 +191,12 @@ class AppColors {
|
|||||||
static const _light = ColorScheme(
|
static const _light = ColorScheme(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: accent,
|
primary: accent,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Color(0xFFFFF8F0), // accent-on
|
||||||
primaryContainer: Color(0xFFFFE0D6),
|
primaryContainer: Color(0xFFFFE0D6),
|
||||||
onPrimaryContainer: fgLight,
|
onPrimaryContainer: fgLight,
|
||||||
secondary: secondary,
|
secondary: secondary,
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
secondaryContainer: Color(0xFFD4E8DC),
|
secondaryContainer: secondarySoftLight,
|
||||||
onSecondaryContainer: fgLight,
|
onSecondaryContainer: fgLight,
|
||||||
tertiary: tertiary,
|
tertiary: tertiary,
|
||||||
onTertiary: fgLight,
|
onTertiary: fgLight,
|
||||||
@@ -96,6 +204,9 @@ class AppColors {
|
|||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
surface: surfaceLight,
|
surface: surfaceLight,
|
||||||
onSurface: fgLight,
|
onSurface: fgLight,
|
||||||
|
onSurfaceVariant: mutedLight, // 次要文字
|
||||||
|
outline: borderLight, // 边框
|
||||||
|
outlineVariant: borderSoftLight, // 柔和边框
|
||||||
surfaceContainerHighest: bgLight,
|
surfaceContainerHighest: bgLight,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,19 +215,22 @@ class AppColors {
|
|||||||
static const _dark = ColorScheme(
|
static const _dark = ColorScheme(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: accentDark,
|
primary: accentDark,
|
||||||
onPrimary: fgDark,
|
onPrimary: Color(0xFF1A1614), // accent-on dark
|
||||||
primaryContainer: Color(0xFF5A2E22),
|
primaryContainer: Color(0xFF5A2E22),
|
||||||
onPrimaryContainer: Color(0xFFFFD6CC),
|
onPrimaryContainer: Color(0xFFFFD6CC),
|
||||||
secondary: secondaryDark,
|
secondary: secondaryDark,
|
||||||
onSecondary: fgDark,
|
onSecondary: fgDark,
|
||||||
secondaryContainer: Color(0xFF2A4A38),
|
secondaryContainer: secondarySoftDark,
|
||||||
onSecondaryContainer: Color(0xFFD4E8DC),
|
onSecondaryContainer: Color(0xFFD4E8DC),
|
||||||
tertiary: tertiaryDark,
|
tertiary: tertiaryDark,
|
||||||
onTertiary: fgDark,
|
onTertiary: fgDark,
|
||||||
error: Color(0xFFEF5350),
|
error: errorDark,
|
||||||
onError: fgDark,
|
onError: fgDark,
|
||||||
surface: surfaceDark,
|
surface: surfaceDark,
|
||||||
onSurface: fgDark,
|
onSurface: fgDark,
|
||||||
|
onSurfaceVariant: mutedDark,
|
||||||
|
outline: borderDark,
|
||||||
|
outlineVariant: borderSoftDark,
|
||||||
surfaceContainerHighest: bgDark,
|
surfaceContainerHighest: bgDark,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// 暖记圆角系统
|
// 暖记圆角系统
|
||||||
// 设计规格: 10 / 16 / 22 / 28 / pill
|
// 对齐 Open Design 原型稿 tokens.css: sm(10) / md(16) / lg(22) / xl(28) / pill
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AppRadius {
|
class AppRadius {
|
||||||
AppRadius._();
|
AppRadius._();
|
||||||
|
|
||||||
/// 小圆角 10px — 按钮、输入框
|
/// 小圆角 10px — 按钮、输入框、小元素
|
||||||
static const double sm = 10;
|
static const double sm = 10;
|
||||||
static BorderRadius get smBorder => BorderRadius.circular(sm);
|
static BorderRadius get smBorder => BorderRadius.circular(sm);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
// 暖记阴影系统 — soft / medium / float
|
// 暖记阴影系统 — soft / medium / float
|
||||||
|
// 对齐 spec §1 阴影 token:
|
||||||
|
// --elev-soft: 0 2px 12px rgba(45,36,32,0.06)
|
||||||
|
// --elev-medium: 0 4px 20px rgba(45,36,32,0.08)
|
||||||
|
// --elev-float: 0 8px 32px rgba(45,36,32,0.12)
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -12,9 +16,9 @@ class AppShadows {
|
|||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: isDark
|
color: isDark
|
||||||
? Colors.black.withValues(alpha: 0.3)
|
? Colors.black.withValues(alpha: 0.3)
|
||||||
: const Color(0xFF2D2420).withValues(alpha: 0.08),
|
: const Color(0xFF2D2420).withValues(alpha: 0.06),
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
blurRadius: 8,
|
blurRadius: 12,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -26,9 +30,9 @@ class AppShadows {
|
|||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: isDark
|
color: isDark
|
||||||
? Colors.black.withValues(alpha: 0.4)
|
? Colors.black.withValues(alpha: 0.4)
|
||||||
: const Color(0xFF2D2420).withValues(alpha: 0.12),
|
: const Color(0xFF2D2420).withValues(alpha: 0.08),
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
blurRadius: 16,
|
blurRadius: 20,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -40,9 +44,9 @@ class AppShadows {
|
|||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: isDark
|
color: isDark
|
||||||
? Colors.black.withValues(alpha: 0.5)
|
? Colors.black.withValues(alpha: 0.5)
|
||||||
: const Color(0xFF2D2420).withValues(alpha: 0.16),
|
: const Color(0xFF2D2420).withValues(alpha: 0.12),
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
blurRadius: 24,
|
blurRadius: 32,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// 暖记主题入口 — 浅色/深色 ThemeData
|
// 暖记主题入口 — 浅色/深色 ThemeData
|
||||||
|
// 对齐 Open Design 原型稿设计系统
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
@@ -44,7 +45,7 @@ class AppTheme {
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: AppRadius.mdBorder,
|
borderRadius: AppRadius.mdBorder,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
color: colorScheme.outlineVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
@@ -102,7 +103,7 @@ class AppTheme {
|
|||||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
selectedItemColor: colorScheme.primary,
|
selectedItemColor: colorScheme.primary,
|
||||||
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.5),
|
unselectedItemColor: colorScheme.onSurfaceVariant,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
),
|
),
|
||||||
@@ -115,15 +116,15 @@ class AppTheme {
|
|||||||
side: BorderSide.none,
|
side: BorderSide.none,
|
||||||
),
|
),
|
||||||
|
|
||||||
// FloatingActionButton
|
// FloatingActionButton — 珊瑚色圆形凸起
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
backgroundColor: isLight ? AppColors.accent : AppColors.accentDark,
|
||||||
borderRadius: AppRadius.mdBorder,
|
foregroundColor: isLight ? const Color(0xFFFFF8F0) : const Color(0xFF1A1614),
|
||||||
),
|
shape: const CircleBorder(),
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Page transitions — 弹性曲线
|
// Page transitions — 弹性曲线 cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||||
pageTransitionsTheme: PageTransitionsTheme(
|
pageTransitionsTheme: PageTransitionsTheme(
|
||||||
builders: {
|
builders: {
|
||||||
TargetPlatform.android: _WarmCurveBuilder(),
|
TargetPlatform.android: _WarmCurveBuilder(),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// 暖记字体系统 — Noto Sans SC + Caveat
|
// 暖记字体系统 — Quicksand(标题) + Nunito(正文) + Caveat(手写装饰)
|
||||||
|
// 对齐 Open Design 原型稿 tokens.css
|
||||||
|
// 字体文件待下载,当前系统字体回退
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -6,99 +8,102 @@ class AppTypography {
|
|||||||
AppTypography._();
|
AppTypography._();
|
||||||
|
|
||||||
/// 字体族
|
/// 字体族
|
||||||
static const String displayFont = 'Caveat'; // 手写风格(标题装饰)
|
static const String displayFont = 'Quicksand'; // 标题显示(待下载,系统回退 sans-serif)
|
||||||
static const String bodyFont = 'NotoSansSC'; // 正文(中文优先)
|
static const String bodyFont = 'Nunito'; // 正文(待下载,系统回退 sans-serif)
|
||||||
|
static const String handwrittenFont = 'Caveat'; // 手写装饰(已有字体文件)
|
||||||
|
static const String cjkFont = 'NotoSansSC'; // CJK 回退(已有字体文件)
|
||||||
static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退)
|
static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退)
|
||||||
|
|
||||||
/// 浅色主题文字主题
|
/// 浅色主题文字主题
|
||||||
|
/// 字号对齐设计稿: xs=11, sm=13, base=15, md=17, lg=20, xl=24, 2xl=30, 3xl=38, 4xl=48
|
||||||
static TextTheme lightTextTheme() => TextTheme(
|
static TextTheme lightTextTheme() => TextTheme(
|
||||||
// 大标题 — 手写风格
|
// Display — Quicksand 标题(对应 text-3xl / text-4xl)
|
||||||
displayLarge: TextStyle(
|
displayLarge: TextStyle(
|
||||||
fontFamily: displayFont,
|
fontFamily: displayFont,
|
||||||
fontSize: 57,
|
fontSize: 48,
|
||||||
height: 1.12,
|
height: 1.12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
displayMedium: TextStyle(
|
displayMedium: TextStyle(
|
||||||
fontFamily: displayFont,
|
fontFamily: displayFont,
|
||||||
fontSize: 45,
|
fontSize: 38,
|
||||||
height: 1.16,
|
height: 1.16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
displaySmall: TextStyle(
|
displaySmall: TextStyle(
|
||||||
fontFamily: displayFont,
|
fontFamily: displayFont,
|
||||||
fontSize: 36,
|
fontSize: 30,
|
||||||
height: 1.22,
|
height: 1.2,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
// 标题 — 正文衬线
|
// Headline — Nunito 标题(对应 text-xl / text-2xl)
|
||||||
headlineLarge: TextStyle(
|
headlineLarge: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 32,
|
fontSize: 30,
|
||||||
height: 1.25,
|
height: 1.25,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
headlineMedium: TextStyle(
|
headlineMedium: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 28,
|
fontSize: 24,
|
||||||
height: 1.29,
|
height: 1.29,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
headlineSmall: TextStyle(
|
headlineSmall: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 24,
|
fontSize: 20,
|
||||||
height: 1.33,
|
height: 1.3,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
// 副标题
|
// Title — Nunito 副标题(对应 text-md / text-base)
|
||||||
titleLarge: TextStyle(
|
titleLarge: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 22,
|
fontSize: 17,
|
||||||
height: 1.27,
|
height: 1.3,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
titleMedium: TextStyle(
|
titleMedium: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
height: 1.5,
|
height: 1.6,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
titleSmall: TextStyle(
|
titleSmall: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
height: 1.43,
|
height: 1.4,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
// 正文
|
// Body — Nunito 正文(对应 text-base / text-sm)
|
||||||
bodyLarge: TextStyle(
|
bodyLarge: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
height: 1.5,
|
height: 1.6,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
bodyMedium: TextStyle(
|
bodyMedium: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
height: 1.43,
|
height: 1.6,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
bodySmall: TextStyle(
|
bodySmall: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
height: 1.33,
|
height: 1.6,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
// 标签
|
// Label — Nunito 标签(对应 text-base / text-sm / text-xs)
|
||||||
labelLarge: TextStyle(
|
labelLarge: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
height: 1.43,
|
height: 1.6,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
labelMedium: TextStyle(
|
labelMedium: TextStyle(
|
||||||
fontFamily: bodyFont,
|
fontFamily: bodyFont,
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
height: 1.33,
|
height: 1.6,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
labelSmall: TextStyle(
|
labelSmall: TextStyle(
|
||||||
@@ -109,6 +114,6 @@ class AppTypography {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 深色主题文字主题
|
/// 深色主题文字主题(与浅色共享字号,颜色由 ColorScheme 控制)
|
||||||
static TextTheme darkTextTheme() => lightTextTheme();
|
static TextTheme darkTextTheme() => lightTextTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/lib/core/utils/download_impl.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// 文件下载 — 非 Web 平台 stub
|
||||||
|
//
|
||||||
|
// 非 Web 平台暂不支持文件下载,返回 false。
|
||||||
|
// Phase 2 扩展:使用 path_provider + File 实现。
|
||||||
|
|
||||||
|
/// 下载文件(stub 实现)
|
||||||
|
Future<bool> downloadFile(String content, String filename, String mimeType) async {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
21
app/lib/core/utils/download_impl_web.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 文件下载 — Web 平台实现
|
||||||
|
//
|
||||||
|
// 使用 dart:html 的 AnchorElement + Blob 触发浏览器下载。
|
||||||
|
// 通过 conditional import 自动选择此实现。
|
||||||
|
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
/// 下载文件(Web 实现)
|
||||||
|
Future<bool> downloadFile(String content, String filename, String mimeType) async {
|
||||||
|
try {
|
||||||
|
final blob = html.Blob([content], mimeType);
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
html.AnchorElement(href: url)
|
||||||
|
..setAttribute('download', filename)
|
||||||
|
..click();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/lib/core/utils/file_download.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// 文件下载工具 — 跨平台接口
|
||||||
|
//
|
||||||
|
// Web: 通过 html.AnchorElement + Blob 触发浏览器下载
|
||||||
|
// 非 Web: 返回 false(Phase 2 扩展 path_provider)
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'download_impl.dart'
|
||||||
|
if (dart.library.html) 'download_impl_web.dart';
|
||||||
|
|
||||||
|
/// 下载 JSON 数据为文件
|
||||||
|
///
|
||||||
|
/// [data] — 要导出的 JSON 数据
|
||||||
|
/// [filename] — 下载文件名(如 "export_2026-06-02.json")
|
||||||
|
///
|
||||||
|
/// 返回 true 表示下载成功。
|
||||||
|
Future<bool> downloadJsonFile(
|
||||||
|
Map<String, dynamic> data,
|
||||||
|
String filename,
|
||||||
|
) async {
|
||||||
|
final jsonStr = const JsonEncoder.withIndent(' ').convert(data);
|
||||||
|
return downloadFile(jsonStr, filename, 'application/json');
|
||||||
|
}
|
||||||
22
app/lib/core/utils/mood_utils.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// 心情公共工具 — 统一 Mood 枚举的 emoji/标签映射
|
||||||
|
// 消除 calendar_page / mood_page / search_page / monthly_page 中的重复定义
|
||||||
|
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
|
||||||
|
/// 心情 → emoji
|
||||||
|
String moodToEmoji(Mood mood) => switch (mood) {
|
||||||
|
Mood.happy => '😊',
|
||||||
|
Mood.calm => '😌',
|
||||||
|
Mood.sad => '😢',
|
||||||
|
Mood.angry => '😠',
|
||||||
|
Mood.thinking => '🤔',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 心情 → 中文标签
|
||||||
|
String moodToLabel(Mood mood) => switch (mood) {
|
||||||
|
Mood.happy => '开心',
|
||||||
|
Mood.calm => '平静',
|
||||||
|
Mood.sad => '难过',
|
||||||
|
Mood.angry => '生气',
|
||||||
|
Mood.thinking => '思考',
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 日记元素 Isar Collection — 本地持久化存储
|
||||||
|
//
|
||||||
|
// 与纯 Dart 模型 JournalElement 分离,通过转换函数桥接。
|
||||||
|
// journalId 索引支持按日记查询所有元素。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'journal_element_collection.g.dart';
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class JournalElementCollection {
|
||||||
|
/// Isar 自增主键
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
/// 业务 UUID(索引)
|
||||||
|
@Index()
|
||||||
|
String id = '';
|
||||||
|
|
||||||
|
/// 所属日记 ID(索引,用于外键查询)
|
||||||
|
@Index()
|
||||||
|
String journalId = '';
|
||||||
|
|
||||||
|
/// 元素类型(enum → string: text/image/sticker/handwriting_ref/tape)
|
||||||
|
String elementType = 'text';
|
||||||
|
|
||||||
|
/// X 坐标
|
||||||
|
double positionX = 0;
|
||||||
|
|
||||||
|
/// Y 坐标
|
||||||
|
double positionY = 0;
|
||||||
|
|
||||||
|
/// 宽度
|
||||||
|
double width = 100;
|
||||||
|
|
||||||
|
/// 高度
|
||||||
|
double height = 100;
|
||||||
|
|
||||||
|
/// 旋转角度
|
||||||
|
double rotation = 0;
|
||||||
|
|
||||||
|
/// 层级
|
||||||
|
int zIndex = 0;
|
||||||
|
|
||||||
|
/// 结构化内容(JSON String)
|
||||||
|
/// text: {'text':'...','fontSize':16.0}
|
||||||
|
/// image: {'filePath':'...'}
|
||||||
|
/// sticker: {'stickerPackId':'...','stickerId':'...'}
|
||||||
|
/// handwriting_ref: {'strokesJson':'...','strokeCount':42}
|
||||||
|
/// tape: {'tapeStyle':'washi_dots'}
|
||||||
|
String contentJson = '{}';
|
||||||
|
|
||||||
|
/// 版本号(乐观锁)
|
||||||
|
int version = 1;
|
||||||
|
|
||||||
|
/// 创建时间(epoch milliseconds)
|
||||||
|
int createdAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 更新时间(epoch milliseconds)
|
||||||
|
int updatedAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 软删除标记
|
||||||
|
bool isDeleted = false;
|
||||||
|
}
|
||||||
2218
app/lib/data/local/collections/journal_element_collection.g.dart
Normal file
65
app/lib/data/local/collections/journal_entry_collection.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// 日记条目 Isar Collection — 本地持久化存储
|
||||||
|
//
|
||||||
|
// 与纯 Dart 模型 JournalEntry 分离,通过转换函数桥接。
|
||||||
|
// 业务 ID (String UUID) 作为索引字段,Isar 主键用 autoIncrement。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'journal_entry_collection.g.dart';
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class JournalEntryCollection {
|
||||||
|
/// Isar 自增主键
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
/// 业务 UUID(索引,用于查找)
|
||||||
|
@Index()
|
||||||
|
String id = '';
|
||||||
|
|
||||||
|
/// 作者 ID(索引 + 组合索引 authorId+dateEpoch,覆盖按作者查询并按日期排序的场景)
|
||||||
|
@Index(composite: [CompositeIndex('dateEpoch')])
|
||||||
|
String authorId = '';
|
||||||
|
|
||||||
|
/// 班级 ID(可选)
|
||||||
|
String? classId;
|
||||||
|
|
||||||
|
/// 日记标题
|
||||||
|
String title = '';
|
||||||
|
|
||||||
|
/// 日记日期(epoch milliseconds)— 单独索引支持日期范围查询
|
||||||
|
@Index()
|
||||||
|
int dateEpoch = 0;
|
||||||
|
|
||||||
|
/// 心情(enum → string)
|
||||||
|
String mood = 'calm';
|
||||||
|
|
||||||
|
/// 天气(enum → string)
|
||||||
|
String weather = 'sunny';
|
||||||
|
|
||||||
|
/// 标签列表(JSON String)
|
||||||
|
String tagsJson = '[]';
|
||||||
|
|
||||||
|
/// 是否私密
|
||||||
|
bool isPrivate = true;
|
||||||
|
|
||||||
|
/// 是否分享到班级
|
||||||
|
bool sharedToClass = false;
|
||||||
|
|
||||||
|
/// 关联主题 ID(可选)
|
||||||
|
String? assignedTopicId;
|
||||||
|
|
||||||
|
/// 内容摘要(自动从文本元素提取)
|
||||||
|
String? contentExcerpt;
|
||||||
|
|
||||||
|
/// 版本号(乐观锁)
|
||||||
|
int version = 1;
|
||||||
|
|
||||||
|
/// 创建时间(epoch milliseconds)
|
||||||
|
int createdAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 更新时间(epoch milliseconds)
|
||||||
|
int updatedAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 软删除标记
|
||||||
|
bool isDeleted = false;
|
||||||
|
}
|
||||||
2992
app/lib/data/local/collections/journal_entry_collection.g.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// 待同步操作 Isar Collection — SyncEngine 队列持久化
|
||||||
|
//
|
||||||
|
// 应用退出时将内存队列写入 Isar,下次启动时恢复。
|
||||||
|
// 保证离线操作不会因进程终止而丢失。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'pending_operation_collection.g.dart';
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class PendingOperationCollection {
|
||||||
|
/// Isar 自增主键
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
/// 业务 UUID(索引)
|
||||||
|
@Index()
|
||||||
|
String id = '';
|
||||||
|
|
||||||
|
/// 操作类型:create / update / delete
|
||||||
|
String operationType = 'create';
|
||||||
|
|
||||||
|
/// API 端点(如 '/diary/journals')
|
||||||
|
String endpoint = '';
|
||||||
|
|
||||||
|
/// 请求负载(JSON String)
|
||||||
|
String dataJson = '{}';
|
||||||
|
|
||||||
|
/// 资源版本号(乐观锁)
|
||||||
|
int version = 1;
|
||||||
|
|
||||||
|
/// 创建时间(epoch milliseconds)
|
||||||
|
int createdAtEpoch = 0;
|
||||||
|
|
||||||
|
/// 重试次数(最大 5 次)
|
||||||
|
int retryCount = 0;
|
||||||
|
}
|
||||||
1408
app/lib/data/local/collections/pending_operation_collection.g.dart
Normal file
@@ -1,82 +1,14 @@
|
|||||||
// Isar 数据库初始化 — 本地持久化存储
|
// Isar 数据库条件导出
|
||||||
//
|
//
|
||||||
// Isar 3.x 要求 open() 时传入 List<CollectionSchema> 位置参数。
|
// 根据平台自动选择实现:
|
||||||
// 由于我们使用手写不可变类而非 isar_generator 代码生成,
|
// - 原生平台 (Android/iOS/Desktop) → isar_database_native.dart
|
||||||
// 需要在调用 [init] 时传入 schema 列表。
|
// - Web 平台 → isar_database_web.dart (空 stub)
|
||||||
// 当前阶段使用 [ensureInitialized] 占位,待后续添加 Isar Collection 后正式注册。
|
//
|
||||||
|
// 条件导出逻辑:
|
||||||
|
// dart.library.io 存在 → 原生平台,使用 native 实现
|
||||||
|
// 否则(Web)→ 使用 web stub
|
||||||
|
//
|
||||||
|
// 使用方式不变:import 'isar_database.dart';
|
||||||
|
// 用 IsarDatabase.isAvailable 判断平台可用性。
|
||||||
|
|
||||||
import 'package:isar/isar.dart';
|
export 'isar_database_web.dart' if (dart.library.io) 'isar_database_native.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
/// Isar 数据库单例管理
|
|
||||||
///
|
|
||||||
/// 使用方式(Phase 1 — 无 schema 时):
|
|
||||||
/// ```dart
|
|
||||||
/// // 直接使用,不初始化 Isar(内存仓库模式)
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// 使用方式(Phase 2 — 有 schema 后):
|
|
||||||
/// ```dart
|
|
||||||
/// final isar = await IsarDatabase.init(schemas: [JournalEntrySchema]);
|
|
||||||
/// ```
|
|
||||||
class IsarDatabase {
|
|
||||||
IsarDatabase._();
|
|
||||||
|
|
||||||
static Isar? _instance;
|
|
||||||
static bool _initialized = false;
|
|
||||||
|
|
||||||
/// 是否已初始化
|
|
||||||
static bool get isInitialized => _initialized;
|
|
||||||
|
|
||||||
/// 初始化数据库(需在 app 启动时调用,传入所有 CollectionSchema)
|
|
||||||
///
|
|
||||||
/// - [schemas]: Isar Collection Schema 列表(由 isar_generator 生成)
|
|
||||||
/// - 在应用文档目录下创建 isar 数据库文件
|
|
||||||
/// - 开发模式开启 inspector(flutter pub global run isar_inspector)
|
|
||||||
static Future<Isar> init({
|
|
||||||
required List<CollectionSchema<dynamic>> schemas,
|
|
||||||
}) async {
|
|
||||||
if (_instance != null && _instance!.isOpen) return _instance!;
|
|
||||||
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
|
||||||
|
|
||||||
_instance = await Isar.open(
|
|
||||||
schemas,
|
|
||||||
directory: dir.path,
|
|
||||||
inspector: true, // 开发模式,发布时关闭
|
|
||||||
);
|
|
||||||
_initialized = true;
|
|
||||||
return _instance!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Isar 实例(必须先调用 [init])
|
|
||||||
///
|
|
||||||
/// 如果未初始化会抛出 [StateError]。
|
|
||||||
static Isar get instance {
|
|
||||||
if (_instance == null || !_instance!.isOpen) {
|
|
||||||
throw StateError(
|
|
||||||
'IsarDatabase 未初始化,请先调用 IsarDatabase.init(schemas: [...])',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
// TODO: 清空所有 collection
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
70
app/lib/data/local/isar_database_native.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Isar 数据库初始化 — 原生平台实现 (Android/iOS/Desktop)
|
||||||
|
//
|
||||||
|
// 在原生平台上使用 Isar 3.x 本地数据库。
|
||||||
|
// Web 平台使用 isar_database_web.dart stub。
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'collections/journal_entry_collection.dart';
|
||||||
|
import 'collections/journal_element_collection.dart';
|
||||||
|
import 'collections/pending_operation_collection.dart';
|
||||||
|
|
||||||
|
/// Isar 数据库单例管理(原生平台实现)
|
||||||
|
class IsarDatabase {
|
||||||
|
IsarDatabase._();
|
||||||
|
|
||||||
|
static Isar? _instance;
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
/// 所有 Collection Schema(由 build_runner 生成)
|
||||||
|
static final List<CollectionSchema<dynamic>> schemas = [
|
||||||
|
JournalEntryCollectionSchema,
|
||||||
|
JournalElementCollectionSchema,
|
||||||
|
PendingOperationCollectionSchema,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 是否已初始化
|
||||||
|
static bool get isInitialized => _initialized;
|
||||||
|
|
||||||
|
/// 原生平台 Isar 可用
|
||||||
|
static bool get isAvailable => true;
|
||||||
|
|
||||||
|
/// 初始化数据库
|
||||||
|
static Future<void> init() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
_instance = await Isar.open(
|
||||||
|
schemas,
|
||||||
|
directory: dir.path,
|
||||||
|
inspector: true, // 开发模式,发布时关闭
|
||||||
|
);
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Isar 实例(必须先调用 [init])
|
||||||
|
static Isar get instance {
|
||||||
|
if (_instance == null || !_instance!.isOpen) {
|
||||||
|
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
|
||||||
|
}
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭数据库连接
|
||||||
|
static Future<void> close() async {
|
||||||
|
if (_instance != null && _instance!.isOpen) {
|
||||||
|
await _instance!.close();
|
||||||
|
_instance = null;
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有数据(仅用于测试)
|
||||||
|
static Future<void> clearAll() async {
|
||||||
|
if (_instance == null || !_instance!.isOpen) return;
|
||||||
|
await _instance!.writeTxn(() async {
|
||||||
|
await _instance!.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/lib/data/local/isar_database_web.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Isar 数据库初始化 — Web 平台 stub
|
||||||
|
//
|
||||||
|
// Isar 3.x 不支持 Web,此文件提供空实现。
|
||||||
|
// 原生平台使用 isar_database_native.dart。
|
||||||
|
|
||||||
|
/// Isar 数据库单例管理(Web 平台空实现)
|
||||||
|
class IsarDatabase {
|
||||||
|
IsarDatabase._();
|
||||||
|
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
/// 是否已初始化
|
||||||
|
static bool get isInitialized => _initialized;
|
||||||
|
|
||||||
|
/// Web 平台 Isar 不可用
|
||||||
|
static bool get isAvailable => false;
|
||||||
|
|
||||||
|
/// Web 平台:跳过初始化
|
||||||
|
static Future<void> init() async {
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web 平台:返回 null
|
||||||
|
static Type? get instance => null;
|
||||||
|
|
||||||
|
/// Web 平台:无操作
|
||||||
|
static Future<void> close() async {}
|
||||||
|
|
||||||
|
/// Web 平台:无操作
|
||||||
|
static Future<void> clearAll() async {}
|
||||||
|
}
|
||||||
18
app/lib/data/local/secure_token_store.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// 安全令牌存储接口 — 平台条件导出
|
||||||
|
//
|
||||||
|
// 原生平台使用 flutter_secure_storage(加密存储,PIPL 合规)
|
||||||
|
// Web 平台使用 shared_preferences(浏览器本地存储)
|
||||||
|
//
|
||||||
|
// 统一接口:read / write / delete
|
||||||
|
|
||||||
|
/// 安全令牌存储接口
|
||||||
|
abstract class SecureTokenStore {
|
||||||
|
/// 读取值
|
||||||
|
Future<String?> read(String key);
|
||||||
|
|
||||||
|
/// 写入值
|
||||||
|
Future<void> write(String key, String value);
|
||||||
|
|
||||||
|
/// 删除值
|
||||||
|
Future<void> delete(String key);
|
||||||
|
}
|
||||||
17
app/lib/data/local/secure_token_store_factory.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// 安全令牌存储 — 工厂函数
|
||||||
|
//
|
||||||
|
// 根据平台创建对应的 SecureTokenStore 实现。
|
||||||
|
// 运行时判断 kIsWeb,避免 Web 编译时加载 flutter_secure_storage。
|
||||||
|
|
||||||
|
import 'secure_token_store.dart';
|
||||||
|
import 'secure_token_store_web.dart';
|
||||||
|
|
||||||
|
/// 创建平台对应的 SecureTokenStore 实例
|
||||||
|
///
|
||||||
|
/// Web 平台 → WebSecureTokenStore (shared_preferences)
|
||||||
|
/// 原生平台 → WebSecureTokenStore (shared_preferences,临时方案)
|
||||||
|
///
|
||||||
|
/// TODO: flutter_secure_storage 升级到 v10+ 后恢复 NativeSecureTokenStore
|
||||||
|
SecureTokenStore createSecureTokenStore() {
|
||||||
|
return WebSecureTokenStore();
|
||||||
|
}
|
||||||
37
app/lib/data/local/secure_token_store_native.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// 安全令牌存储 — 原生平台实现(shared_preferences)
|
||||||
|
//
|
||||||
|
// 临时使用 shared_preferences 替代 flutter_secure_storage。
|
||||||
|
// flutter_secure_storage v9 的 web 插件不兼容 Flutter 3.44,
|
||||||
|
// 待其升级到 v10+ 后恢复加密存储。
|
||||||
|
// TODO: 恢复 flutter_secure_storage 加密存储
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'secure_token_store.dart';
|
||||||
|
|
||||||
|
/// 原生平台安全令牌存储(临时使用 shared_preferences)
|
||||||
|
class NativeSecureTokenStore implements SecureTokenStore {
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
Future<SharedPreferences> get _instance async {
|
||||||
|
return _prefs ??= await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async {
|
||||||
|
final prefs = await _instance;
|
||||||
|
return prefs.getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async {
|
||||||
|
final prefs = await _instance;
|
||||||
|
await prefs.setString(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async {
|
||||||
|
final prefs = await _instance;
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/lib/data/local/secure_token_store_web.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// 安全令牌存储 — Web 平台实现(shared_preferences)
|
||||||
|
//
|
||||||
|
// Web 平台上 flutter_secure_storage 不可用(dart:html 已弃用),
|
||||||
|
// 使用 shared_preferences 作为替代。
|
||||||
|
// 注意:Web 端存储不加密,但浏览器本身提供 HTTPS 传输安全。
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'secure_token_store.dart';
|
||||||
|
|
||||||
|
/// Web 平台安全令牌存储(shared_preferences)
|
||||||
|
class WebSecureTokenStore implements SecureTokenStore {
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
Future<SharedPreferences> get _instance async {
|
||||||
|
return _prefs ??= await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async {
|
||||||
|
final prefs = await _instance;
|
||||||
|
return prefs.getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async {
|
||||||
|
final prefs = await _instance;
|
||||||
|
await prefs.setString(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async {
|
||||||
|
final prefs = await _instance;
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/lib/data/models/auth_token.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// 认证令牌模型 — 匹配后端 LoginResp
|
||||||
|
//
|
||||||
|
// 管理访问令牌和刷新令牌,支持自动计算过期时间。
|
||||||
|
// 令牌通过 flutter_secure_storage 安全持久化(PIPL 合规要求)。
|
||||||
|
|
||||||
|
/// 认证令牌 — 包含访问令牌、刷新令牌和过期信息
|
||||||
|
class AuthToken {
|
||||||
|
final String accessToken;
|
||||||
|
final String refreshToken;
|
||||||
|
final int expiresIn;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
const AuthToken({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.expiresIn,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 令牌是否已过期
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||||
|
|
||||||
|
/// 令牌是否即将过期(5 分钟内)
|
||||||
|
bool get isExpiringSoon =>
|
||||||
|
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 5)));
|
||||||
|
|
||||||
|
/// 从后端 LoginResp JSON 创建
|
||||||
|
factory AuthToken.fromJson(Map<String, dynamic> json) {
|
||||||
|
final expiresIn = (json['expires_in'] as int?) ?? 3600;
|
||||||
|
return AuthToken(
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
refreshToken: json['refresh_token'] as String,
|
||||||
|
expiresIn: expiresIn,
|
||||||
|
expiresAt: DateTime.now().add(Duration(seconds: expiresIn)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'access_token': accessToken,
|
||||||
|
'refresh_token': refreshToken,
|
||||||
|
'expires_in': expiresIn,
|
||||||
|
'expires_at': expiresAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 从持久化存储恢复(使用保存的过期时间)
|
||||||
|
factory AuthToken.fromStorage(Map<String, dynamic> json) => AuthToken(
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
refreshToken: json['refresh_token'] as String,
|
||||||
|
expiresIn: (json['expires_in'] as int?) ?? 3600,
|
||||||
|
expiresAt: json['expires_at'] != null
|
||||||
|
? DateTime.parse(json['expires_at'] as String)
|
||||||
|
: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// 日记元素数据模型 — 手写不可变类(避免 build_runner 依赖)
|
// 日记元素数据模型 — 手写不可变类(避免 build_runner 依赖)
|
||||||
|
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
@@ -153,4 +154,65 @@ class JournalElement {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 文字元素便利工厂
|
||||||
|
factory JournalElement.createText({
|
||||||
|
required String journalId,
|
||||||
|
required String text,
|
||||||
|
required Offset position,
|
||||||
|
double fontSize = 18.0,
|
||||||
|
String fontColor = '#2D2420',
|
||||||
|
}) {
|
||||||
|
return JournalElement.create(
|
||||||
|
journalId: journalId,
|
||||||
|
elementType: ElementType.text,
|
||||||
|
positionX: position.dx,
|
||||||
|
positionY: position.dy,
|
||||||
|
content: {
|
||||||
|
'text': text,
|
||||||
|
'fontSize': fontSize,
|
||||||
|
'fontColor': fontColor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片元素便利工厂
|
||||||
|
factory JournalElement.createImage({
|
||||||
|
required String journalId,
|
||||||
|
required String filePath,
|
||||||
|
required Offset position,
|
||||||
|
String? thumbnailPath,
|
||||||
|
}) {
|
||||||
|
return JournalElement.create(
|
||||||
|
journalId: journalId,
|
||||||
|
elementType: ElementType.image,
|
||||||
|
positionX: position.dx,
|
||||||
|
positionY: position.dy,
|
||||||
|
content: {
|
||||||
|
'filePath': filePath,
|
||||||
|
if (thumbnailPath != null) 'thumbnailPath': thumbnailPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 贴纸元素便利工厂
|
||||||
|
factory JournalElement.createSticker({
|
||||||
|
required String journalId,
|
||||||
|
required String emoji,
|
||||||
|
required Offset position,
|
||||||
|
String? stickerPackId,
|
||||||
|
String? stickerId,
|
||||||
|
}) {
|
||||||
|
return JournalElement.create(
|
||||||
|
journalId: journalId,
|
||||||
|
elementType: ElementType.sticker,
|
||||||
|
positionX: position.dx,
|
||||||
|
positionY: position.dy,
|
||||||
|
content: {
|
||||||
|
'emoji': emoji,
|
||||||
|
if (stickerPackId != null) 'stickerPackId': stickerPackId,
|
||||||
|
if (stickerId != null) 'stickerId': stickerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class JournalEntry {
|
|||||||
final bool isPrivate;
|
final bool isPrivate;
|
||||||
final bool sharedToClass;
|
final bool sharedToClass;
|
||||||
final String? assignedTopicId;
|
final String? assignedTopicId;
|
||||||
|
|
||||||
|
/// 内容摘要 — 自动从文本元素提取,用于列表预览
|
||||||
|
final String? contentExcerpt;
|
||||||
|
|
||||||
final int version;
|
final int version;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
@@ -58,6 +62,7 @@ class JournalEntry {
|
|||||||
this.isPrivate = true,
|
this.isPrivate = true,
|
||||||
this.sharedToClass = false,
|
this.sharedToClass = false,
|
||||||
this.assignedTopicId,
|
this.assignedTopicId,
|
||||||
|
this.contentExcerpt,
|
||||||
this.version = 1,
|
this.version = 1,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
@@ -77,6 +82,8 @@ class JournalEntry {
|
|||||||
bool? sharedToClass,
|
bool? sharedToClass,
|
||||||
String? assignedTopicId,
|
String? assignedTopicId,
|
||||||
bool clearAssignedTopicId = false,
|
bool clearAssignedTopicId = false,
|
||||||
|
String? contentExcerpt,
|
||||||
|
bool clearContentExcerpt = false,
|
||||||
int? version,
|
int? version,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
@@ -94,6 +101,9 @@ class JournalEntry {
|
|||||||
sharedToClass: sharedToClass ?? this.sharedToClass,
|
sharedToClass: sharedToClass ?? this.sharedToClass,
|
||||||
assignedTopicId:
|
assignedTopicId:
|
||||||
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
|
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
|
||||||
|
contentExcerpt: clearContentExcerpt
|
||||||
|
? null
|
||||||
|
: (contentExcerpt ?? this.contentExcerpt),
|
||||||
version: version ?? this.version,
|
version: version ?? this.version,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
@@ -111,6 +121,7 @@ class JournalEntry {
|
|||||||
'is_private': isPrivate,
|
'is_private': isPrivate,
|
||||||
'shared_to_class': sharedToClass,
|
'shared_to_class': sharedToClass,
|
||||||
'assigned_topic_id': assignedTopicId,
|
'assigned_topic_id': assignedTopicId,
|
||||||
|
'content_excerpt': contentExcerpt,
|
||||||
'version': version,
|
'version': version,
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
@@ -134,6 +145,7 @@ class JournalEntry {
|
|||||||
isPrivate: (json['is_private'] as bool?) ?? true,
|
isPrivate: (json['is_private'] as bool?) ?? true,
|
||||||
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
|
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
|
||||||
assignedTopicId: json['assigned_topic_id'] as String?,
|
assignedTopicId: json['assigned_topic_id'] as String?,
|
||||||
|
contentExcerpt: json['content_excerpt'] as String?,
|
||||||
version: (json['version'] as int?) ?? 1,
|
version: (json['version'] as int?) ?? 1,
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
|||||||
176
app/lib/data/models/sync_models.dart
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// 同步协议模型 — 与 Rust 端 SyncReq/SyncResp/SyncChange/ConflictInfo 一一对应
|
||||||
|
//
|
||||||
|
// 端点: POST /api/v1/diary/sync
|
||||||
|
// Rust DTO: crates/erp-diary/src/dto.rs (SyncReq, SyncResp, SyncChange, ConflictInfo)
|
||||||
|
|
||||||
|
/// 同步请求 — 与 Rust SyncReq 对应
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// pub struct SyncReq {
|
||||||
|
/// pub last_sync_time: Option<DateTime<Utc>>,
|
||||||
|
/// pub changes: Vec<SyncChange>,
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
class SyncReq {
|
||||||
|
final DateTime? lastSyncTime;
|
||||||
|
final List<SyncChange> changes;
|
||||||
|
|
||||||
|
const SyncReq({this.lastSyncTime, this.changes = const []});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
if (lastSyncTime != null)
|
||||||
|
'last_sync_time': lastSyncTime!.toUtc().toIso8601String(),
|
||||||
|
'changes': changes.map((c) => c.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步变更条目 — 与 Rust SyncChange 枚举对应
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// pub enum SyncChange {
|
||||||
|
/// CreateJournal { data: serde_json::Value },
|
||||||
|
/// UpdateJournal { id: Uuid, version: i32, data: serde_json::Value },
|
||||||
|
/// DeleteJournal { id: Uuid, version: i32 },
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
sealed class SyncChange {
|
||||||
|
const SyncChange();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
/// 从 JSON 反序列化
|
||||||
|
factory SyncChange.fromJson(Map<String, dynamic> json) {
|
||||||
|
if (json.containsKey('CreateJournal')) {
|
||||||
|
return SyncChangeCreateJournal(
|
||||||
|
data: json['CreateJournal']['data'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (json.containsKey('UpdateJournal')) {
|
||||||
|
final inner = json['UpdateJournal'] as Map<String, dynamic>;
|
||||||
|
return SyncChangeUpdateJournal(
|
||||||
|
id: inner['id'] as String,
|
||||||
|
version: inner['version'] as int,
|
||||||
|
data: inner['data'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (json.containsKey('DeleteJournal')) {
|
||||||
|
final inner = json['DeleteJournal'] as Map<String, dynamic>;
|
||||||
|
return SyncChangeDeleteJournal(
|
||||||
|
id: inner['id'] as String,
|
||||||
|
version: inner['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw FormatException('Unknown SyncChange variant: $json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建日记变更
|
||||||
|
class SyncChangeCreateJournal extends SyncChange {
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
|
const SyncChangeCreateJournal({required this.data});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'CreateJournal': {'data': data},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新日记变更
|
||||||
|
class SyncChangeUpdateJournal extends SyncChange {
|
||||||
|
final String id;
|
||||||
|
final int version;
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
|
const SyncChangeUpdateJournal({
|
||||||
|
required this.id,
|
||||||
|
required this.version,
|
||||||
|
required this.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'UpdateJournal': {
|
||||||
|
'id': id,
|
||||||
|
'version': version,
|
||||||
|
'data': data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除日记变更
|
||||||
|
class SyncChangeDeleteJournal extends SyncChange {
|
||||||
|
final String id;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
const SyncChangeDeleteJournal({
|
||||||
|
required this.id,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'DeleteJournal': {
|
||||||
|
'id': id,
|
||||||
|
'version': version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步响应 — 与 Rust SyncResp 对应
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// pub struct SyncResp {
|
||||||
|
/// pub server_changes: Vec<serde_json::Value>,
|
||||||
|
/// pub conflicts: Vec<ConflictInfo>,
|
||||||
|
/// pub sync_time: DateTime<Utc>,
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
class SyncResp {
|
||||||
|
final List<Map<String, dynamic>> serverChanges;
|
||||||
|
final List<ConflictInfo> conflicts;
|
||||||
|
final DateTime syncTime;
|
||||||
|
|
||||||
|
const SyncResp({
|
||||||
|
required this.serverChanges,
|
||||||
|
required this.conflicts,
|
||||||
|
required this.syncTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SyncResp.fromJson(Map<String, dynamic> json) => SyncResp(
|
||||||
|
serverChanges: (json['server_changes'] as List)
|
||||||
|
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||||
|
.toList(),
|
||||||
|
conflicts: (json['conflicts'] as List)
|
||||||
|
.map((e) => ConflictInfo.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList(),
|
||||||
|
syncTime: DateTime.parse(json['sync_time'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冲突信息 — 与 Rust ConflictInfo 对应
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// pub struct ConflictInfo {
|
||||||
|
/// pub journal_id: Uuid,
|
||||||
|
/// pub local_version: i32,
|
||||||
|
/// pub server_version: i32,
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
class ConflictInfo {
|
||||||
|
final String journalId;
|
||||||
|
final int localVersion;
|
||||||
|
final int serverVersion;
|
||||||
|
|
||||||
|
const ConflictInfo({
|
||||||
|
required this.journalId,
|
||||||
|
required this.localVersion,
|
||||||
|
required this.serverVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ConflictInfo.fromJson(Map<String, dynamic> json) => ConflictInfo(
|
||||||
|
journalId: json['journal_id'] as String,
|
||||||
|
localVersion: json['local_version'] as int,
|
||||||
|
serverVersion: json['server_version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/lib/data/models/user.dart
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
// 用户数据模型 — 匹配后端 UserResp / RoleResp
|
||||||
|
//
|
||||||
|
// 暖记用户包含四种角色:老师、学生、家长、独立用户。
|
||||||
|
// 角色决定用户可访问的功能模块和页面。
|
||||||
|
|
||||||
|
/// 用户角色枚举 — 对应后端 role code
|
||||||
|
enum UserRoleType {
|
||||||
|
teacher('teacher'),
|
||||||
|
student('student'),
|
||||||
|
parent('parent'),
|
||||||
|
independent('independent');
|
||||||
|
|
||||||
|
const UserRoleType(this.code);
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
/// 从后端角色代码解析,未知代码默认为独立用户
|
||||||
|
static UserRoleType fromCode(String code) => UserRoleType.values.firstWhere(
|
||||||
|
(r) => r.code == code,
|
||||||
|
orElse: () => UserRoleType.independent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色信息 — 匹配后端 RoleResp
|
||||||
|
class UserRole {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String code;
|
||||||
|
final String? description;
|
||||||
|
final bool isSystem;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
const UserRole({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.code,
|
||||||
|
this.description,
|
||||||
|
this.isSystem = false,
|
||||||
|
this.version = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 获取标准化的角色类型
|
||||||
|
UserRoleType get type => UserRoleType.fromCode(code);
|
||||||
|
|
||||||
|
factory UserRole.fromJson(Map<String, dynamic> json) => UserRole(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
code: json['code'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
isSystem: (json['is_system'] as bool?) ?? false,
|
||||||
|
version: (json['version'] as int?) ?? 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'code': code,
|
||||||
|
'description': description,
|
||||||
|
'is_system': isSystem,
|
||||||
|
'version': version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户信息 — 匹配后端 UserResp
|
||||||
|
///
|
||||||
|
/// 包含用户基本信息和角色列表。
|
||||||
|
/// 角色由后端 RBAC 系统管理,前端据此控制页面可见性和功能访问。
|
||||||
|
class User {
|
||||||
|
final String id;
|
||||||
|
final String username;
|
||||||
|
final String? email;
|
||||||
|
final String? phone;
|
||||||
|
final String? displayName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final String status;
|
||||||
|
final List<UserRole> roles;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
const User({
|
||||||
|
required this.id,
|
||||||
|
required this.username,
|
||||||
|
this.email,
|
||||||
|
this.phone,
|
||||||
|
this.displayName,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.status = 'active',
|
||||||
|
this.roles = const [],
|
||||||
|
this.version = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
User copyWith({
|
||||||
|
String? displayName,
|
||||||
|
String? avatarUrl,
|
||||||
|
List<UserRole>? roles,
|
||||||
|
int? version,
|
||||||
|
}) =>
|
||||||
|
User(
|
||||||
|
id: id,
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||||
|
status: status,
|
||||||
|
roles: roles ?? this.roles,
|
||||||
|
version: version ?? this.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 获取用户的主角色类型(第一个角色的类型)
|
||||||
|
UserRoleType get primaryRoleType =>
|
||||||
|
roles.isNotEmpty ? roles.first.type : UserRoleType.independent;
|
||||||
|
|
||||||
|
/// 用户是否为老师
|
||||||
|
bool get isTeacher => roles.any((r) => r.type == UserRoleType.teacher);
|
||||||
|
|
||||||
|
/// 用户是否为学生
|
||||||
|
bool get isStudent => roles.any((r) => r.type == UserRoleType.student);
|
||||||
|
|
||||||
|
/// 用户是否为家长
|
||||||
|
bool get isParent => roles.any((r) => r.type == UserRoleType.parent);
|
||||||
|
|
||||||
|
/// 用户是否已完成角色选择
|
||||||
|
bool get hasRole => roles.isNotEmpty;
|
||||||
|
|
||||||
|
/// 显示名称:优先使用 displayName,回退到 username
|
||||||
|
String get displayLabel => displayName ?? username;
|
||||||
|
|
||||||
|
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||||
|
id: json['id'] as String,
|
||||||
|
username: json['username'] as String,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
displayName: json['display_name'] as String?,
|
||||||
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
|
status: (json['status'] as String?) ?? 'active',
|
||||||
|
roles: (json['roles'] as List?)
|
||||||
|
?.map((r) => UserRole.fromJson(r as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
version: (json['version'] as int?) ?? 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
'display_name': displayName,
|
||||||
|
'avatar_url': avatarUrl,
|
||||||
|
'status': status,
|
||||||
|
'roles': roles.map((r) => r.toJson()).toList(),
|
||||||
|
'version': version,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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:8080/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!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
290
app/lib/data/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
// 认证仓库 — 登录/注册/令牌管理的统一入口
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
|
||||||
|
// - 通过 SecureTokenStore 安全持久化 JWT 令牌(PIPL 合规)
|
||||||
|
// - 为 AuthBloc 提供干净的认证数据访问接口
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
import '../local/secure_token_store.dart';
|
||||||
|
import '../models/auth_token.dart';
|
||||||
|
import '../models/user.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
|
||||||
|
/// 安全存储键名
|
||||||
|
const _keyAccessToken = 'auth_access_token';
|
||||||
|
const _keyRefreshToken = 'auth_refresh_token';
|
||||||
|
const _keyExpiresAt = 'auth_expires_at';
|
||||||
|
const _keyUserJson = 'auth_user_json';
|
||||||
|
|
||||||
|
/// 认证异常 — 认证流程中出现的错误
|
||||||
|
class AuthException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
const AuthException(this.message, {this.statusCode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证仓库 — 管理用户登录状态和令牌
|
||||||
|
///
|
||||||
|
/// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
|
||||||
|
/// 原生平台使用加密存储,Web 平台使用 shared_preferences。
|
||||||
|
class AuthRepository {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final SecureTokenStore _tokenStore;
|
||||||
|
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||||
|
|
||||||
|
AuthToken? _currentToken;
|
||||||
|
User? _currentUser;
|
||||||
|
|
||||||
|
AuthRepository({
|
||||||
|
required ApiClient apiClient,
|
||||||
|
required SecureTokenStore tokenStore,
|
||||||
|
}) : _apiClient = apiClient,
|
||||||
|
_tokenStore = tokenStore {
|
||||||
|
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
|
||||||
|
_apiClient.onRefreshToken = _handleAutoRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前用户(可能为 null)
|
||||||
|
User? get currentUser => _currentUser;
|
||||||
|
|
||||||
|
/// 当前令牌(可能为 null)
|
||||||
|
AuthToken? get currentToken => _currentToken;
|
||||||
|
|
||||||
|
/// 是否已登录
|
||||||
|
bool get isAuthenticated => _currentToken != null && !_currentToken!.isExpired;
|
||||||
|
|
||||||
|
// ===== 登录 =====
|
||||||
|
|
||||||
|
/// 用户名密码登录
|
||||||
|
///
|
||||||
|
/// 调用后端 `POST /auth/login`,成功后保存令牌和用户信息。
|
||||||
|
Future<User> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
_logger.i('登录请求: $username');
|
||||||
|
|
||||||
|
final response = await _apiClient.post('/auth/login', data: {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'client_type': 'mobile',
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = _extractData(response.data);
|
||||||
|
final token = AuthToken.fromJson(data);
|
||||||
|
final user = User.fromJson(data['user'] as Map<String, dynamic>);
|
||||||
|
|
||||||
|
await _saveAuth(token, user);
|
||||||
|
_apiClient.setToken(token.accessToken);
|
||||||
|
|
||||||
|
_logger.i('登录成功: ${user.displayLabel}');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 注册 =====
|
||||||
|
|
||||||
|
/// 注册新用户
|
||||||
|
///
|
||||||
|
/// 调用后端 `POST /users`,成功后自动登录。
|
||||||
|
Future<User> register({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
String? displayName,
|
||||||
|
}) async {
|
||||||
|
_logger.i('注册请求: $username');
|
||||||
|
|
||||||
|
await _apiClient.post('/users', data: {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (displayName != null) 'display_name': displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册成功后自动登录
|
||||||
|
return await login(username: username, password: password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 刷新令牌 =====
|
||||||
|
|
||||||
|
/// 刷新访问令牌
|
||||||
|
///
|
||||||
|
/// 调用后端 `POST /auth/refresh`,使用 refresh_token 获取新的 access_token。
|
||||||
|
Future<AuthToken> refreshToken() async {
|
||||||
|
if (_currentToken == null) {
|
||||||
|
throw const AuthException('无可用令牌,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.d('刷新令牌');
|
||||||
|
|
||||||
|
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);
|
||||||
|
_apiClient.setToken(token.accessToken);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 登出 =====
|
||||||
|
|
||||||
|
/// 登出
|
||||||
|
///
|
||||||
|
/// 调用后端 `POST /auth/logout` 并清除本地存储。
|
||||||
|
Future<void> logout() async {
|
||||||
|
_logger.i('登出');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
await _apiClient.post('/auth/logout');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.w('登出 API 调用失败(忽略): $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _clearAuth();
|
||||||
|
_apiClient.clearToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 本地恢复 =====
|
||||||
|
|
||||||
|
/// 从安全存储恢复认证状态
|
||||||
|
///
|
||||||
|
/// App 启动时调用,检查是否有有效的持久化令牌。
|
||||||
|
/// 如果令牌即将过期,自动刷新。
|
||||||
|
Future<User?> restoreAuth() async {
|
||||||
|
_logger.d('恢复认证状态');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final accessToken = await _tokenStore.read(_keyAccessToken);
|
||||||
|
final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
|
||||||
|
final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
|
||||||
|
final userJsonStr = await _tokenStore.read(_keyUserJson);
|
||||||
|
|
||||||
|
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
|
||||||
|
_logger.d('无存储的认证信息');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiresAt = DateTime.parse(expiresAtStr ?? DateTime.now().subtract(const Duration(hours: 1)).toIso8601String());
|
||||||
|
|
||||||
|
_currentToken = AuthToken(
|
||||||
|
accessToken: accessToken,
|
||||||
|
refreshToken: refreshTokenStr,
|
||||||
|
expiresIn: 0,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
_currentUser = User.fromJson(
|
||||||
|
jsonDecode(userJsonStr) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 令牌已过期 → 尝试刷新
|
||||||
|
if (_currentToken!.isExpired) {
|
||||||
|
_logger.d('令牌已过期,尝试刷新');
|
||||||
|
try {
|
||||||
|
await refreshToken();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.w('令牌刷新失败,需要重新登录: $e');
|
||||||
|
await _clearAuth();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (_currentToken!.isExpiringSoon) {
|
||||||
|
// 即将过期 → 后台刷新(不阻塞)
|
||||||
|
_logger.d('令牌即将过期,后台刷新');
|
||||||
|
refreshToken().catchError((e) {
|
||||||
|
_logger.w('后台刷新失败: $e');
|
||||||
|
return _currentToken!; // 返回当前令牌作为 fallback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiClient.setToken(_currentToken!.accessToken);
|
||||||
|
_logger.i('认证恢复成功: ${_currentUser!.displayLabel}');
|
||||||
|
return _currentUser;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('认证恢复失败: $e');
|
||||||
|
await _clearAuth();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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 字段
|
||||||
|
Map<String, dynamic> _extractData(dynamic responseData) {
|
||||||
|
if (responseData is Map<String, dynamic>) {
|
||||||
|
// 后端 ApiResponse 格式: { success: bool, data: T, message: String? }
|
||||||
|
if (responseData.containsKey('data')) {
|
||||||
|
return responseData['data'] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
return responseData;
|
||||||
|
}
|
||||||
|
throw const AuthException('服务器响应格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存令牌和用户到安全存储
|
||||||
|
Future<void> _saveAuth(AuthToken token, User user) async {
|
||||||
|
_currentToken = token;
|
||||||
|
_currentUser = user;
|
||||||
|
await _saveToken(token);
|
||||||
|
await _tokenStore.write(
|
||||||
|
_keyUserJson,
|
||||||
|
jsonEncode(user.toJson()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅保存令牌到安全存储
|
||||||
|
Future<void> _saveToken(AuthToken token) async {
|
||||||
|
_currentToken = token;
|
||||||
|
await _tokenStore.write(_keyAccessToken, token.accessToken);
|
||||||
|
await _tokenStore.write(_keyRefreshToken, token.refreshToken);
|
||||||
|
await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除所有认证数据
|
||||||
|
Future<void> _clearAuth() async {
|
||||||
|
_currentToken = null;
|
||||||
|
_currentUser = null;
|
||||||
|
await _tokenStore.delete(_keyAccessToken);
|
||||||
|
await _tokenStore.delete(_keyRefreshToken);
|
||||||
|
await _tokenStore.delete(_keyExpiresAt);
|
||||||
|
await _tokenStore.delete(_keyUserJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/lib/data/repositories/class_repository.dart
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// 班级仓库 — 通过 API 客户端管理班级、主题、评语
|
||||||
|
|
||||||
|
import '../models/school_class.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
|
||||||
|
/// 班级成员数据
|
||||||
|
class ClassMemberDto {
|
||||||
|
final String userId;
|
||||||
|
final String role;
|
||||||
|
final String? nickname;
|
||||||
|
final DateTime joinedAt;
|
||||||
|
|
||||||
|
const ClassMemberDto({
|
||||||
|
required this.userId,
|
||||||
|
required this.role,
|
||||||
|
this.nickname,
|
||||||
|
required this.joinedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ClassMemberDto.fromJson(Map<String, dynamic> json) => ClassMemberDto(
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
role: json['role'] as String,
|
||||||
|
nickname: json['nickname'] as String?,
|
||||||
|
joinedAt: DateTime.parse(json['joined_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主题布置数据
|
||||||
|
class TopicDto {
|
||||||
|
final String id;
|
||||||
|
final String classId;
|
||||||
|
final String teacherId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
const TopicDto({
|
||||||
|
required this.id,
|
||||||
|
required this.classId,
|
||||||
|
required this.teacherId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.dueDate,
|
||||||
|
this.isActive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TopicDto.fromJson(Map<String, dynamic> json) => TopicDto(
|
||||||
|
id: json['id'] as String,
|
||||||
|
classId: json['class_id'] as String,
|
||||||
|
teacherId: json['teacher_id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
dueDate: json['due_date'] != null
|
||||||
|
? DateTime.parse(json['due_date'] as String)
|
||||||
|
: null,
|
||||||
|
isActive: (json['is_active'] as bool?) ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评语数据
|
||||||
|
class CommentDto {
|
||||||
|
final String id;
|
||||||
|
final String journalId;
|
||||||
|
final String authorId;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const CommentDto({
|
||||||
|
required this.id,
|
||||||
|
required this.journalId,
|
||||||
|
required this.authorId,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CommentDto.fromJson(Map<String, dynamic> json) => CommentDto(
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 班级仓库 — 班级/主题/评语的 API 操作
|
||||||
|
class ClassRepository {
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
ClassRepository({required ApiClient api}) : _api = api;
|
||||||
|
|
||||||
|
// ===== 班级 =====
|
||||||
|
|
||||||
|
/// 获取我加入的班级列表
|
||||||
|
Future<List<SchoolClass>> getMyClasses() async {
|
||||||
|
final response = await _api.get('/diary/classes');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => SchoolClass.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建班级(老师)
|
||||||
|
Future<SchoolClass> createClass({
|
||||||
|
required String name,
|
||||||
|
String? schoolName,
|
||||||
|
}) async {
|
||||||
|
final response = await _api.post('/diary/classes', data: {
|
||||||
|
'name': name,
|
||||||
|
if (schoolName != null) 'school_name': schoolName,
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加入班级(通过班级码)
|
||||||
|
Future<SchoolClass> joinClass(String classCode, {String? nickname}) async {
|
||||||
|
final response = await _api.post('/diary/classes/join', data: {
|
||||||
|
'class_code': classCode,
|
||||||
|
if (nickname != null) 'nickname': nickname,
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取班级详情
|
||||||
|
Future<SchoolClass> getClass(String classId) async {
|
||||||
|
final response = await _api.get('/diary/classes/$classId');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取班级成员列表
|
||||||
|
Future<List<ClassMemberDto>> getMembers(String classId) async {
|
||||||
|
final response = await _api.get('/diary/classes/$classId/members');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => ClassMemberDto.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主题 =====
|
||||||
|
|
||||||
|
/// 获取班级主题列表
|
||||||
|
Future<List<TopicDto>> getTopics(String classId) async {
|
||||||
|
final response = await _api.get('/diary/classes/$classId/topics');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => TopicDto.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 布置主题(老师)
|
||||||
|
Future<TopicDto> assignTopic({
|
||||||
|
required String classId,
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
DateTime? dueDate,
|
||||||
|
}) async {
|
||||||
|
final response = await _api.post('/diary/classes/$classId/topics', data: {
|
||||||
|
'title': title,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
if (dueDate != null) 'due_date': dueDate.toIso8601String(),
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return TopicDto.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 评语 =====
|
||||||
|
|
||||||
|
/// 获取日记评语列表
|
||||||
|
Future<List<CommentDto>> getComments(String journalId) async {
|
||||||
|
final response = await _api.get('/diary/journals/$journalId/comments');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => CommentDto.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加评语(老师点评学生日记)
|
||||||
|
Future<CommentDto> createComment({
|
||||||
|
required String journalId,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
final response = await _api.post(
|
||||||
|
'/diary/journals/$journalId/comments',
|
||||||
|
data: {'content': content},
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return CommentDto.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除评语
|
||||||
|
Future<void> deleteComment(String commentId) async {
|
||||||
|
await _api.delete('/diary/comments/$commentId');
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/lib/data/repositories/isar_journal_repository.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Isar 本地日记仓库 — 条件导出
|
||||||
|
//
|
||||||
|
// 根据平台选择实现:
|
||||||
|
// - 原生平台 → isar_journal_repository_native.dart(Isar 本地数据库)
|
||||||
|
// - Web 平台 → isar_journal_repository_web.dart(空 stub,应使用 RemoteJournalRepository)
|
||||||
|
|
||||||
|
export 'isar_journal_repository_web.dart' if (dart.library.io) 'isar_journal_repository_native.dart';
|
||||||
370
app/lib/data/repositories/isar_journal_repository_native.dart
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
// Isar 本地日记仓库 — 本地优先数据存储
|
||||||
|
//
|
||||||
|
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||||||
|
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||||||
|
//
|
||||||
|
// 转换层:
|
||||||
|
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||||
|
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
import '../local/isar_database_native.dart';
|
||||||
|
import '../local/collections/journal_entry_collection.dart';
|
||||||
|
import '../local/collections/journal_element_collection.dart';
|
||||||
|
import '../models/journal_entry.dart';
|
||||||
|
import '../models/journal_element.dart';
|
||||||
|
import 'journal_repository.dart';
|
||||||
|
|
||||||
|
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||||||
|
class IsarJournalRepository implements JournalRepository {
|
||||||
|
Isar get _isar => IsarDatabase.instance;
|
||||||
|
|
||||||
|
final StreamController<void> _changeController = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get onJournalChanged => _changeController.stream;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 日记 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalEntry>> getJournals({
|
||||||
|
DateTime? dateFrom,
|
||||||
|
DateTime? dateTo,
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
String? mood,
|
||||||
|
String? tag,
|
||||||
|
String? classId,
|
||||||
|
}) async {
|
||||||
|
var query = _isar.journalEntryCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.isDeletedEqualTo(false);
|
||||||
|
|
||||||
|
// 日期范围过滤
|
||||||
|
if (dateFrom != null) {
|
||||||
|
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
if (dateTo != null) {
|
||||||
|
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心情过滤
|
||||||
|
if (mood != null) {
|
||||||
|
query = query.and().moodEqualTo(mood);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||||||
|
if (tag != null) {
|
||||||
|
query = query.and().tagsJsonContains(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级过滤
|
||||||
|
if (classId != null) {
|
||||||
|
query = query.and().classIdEqualTo(classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist)
|
||||||
|
if (page != null && pageSize != null) {
|
||||||
|
final offset = (page - 1) * pageSize;
|
||||||
|
final results = await query
|
||||||
|
.sortByDateEpochDesc()
|
||||||
|
.offset(offset)
|
||||||
|
.limit(pageSize)
|
||||||
|
.findAll();
|
||||||
|
return results.map(_fromCollection).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final results = await query.sortByDateEpochDesc().findAll();
|
||||||
|
return results.map(_fromCollection).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async {
|
||||||
|
return _isar.journalEntryCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
|
final col = await _isar.journalEntryCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.idEqualTo(id)
|
||||||
|
.and()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.findFirst();
|
||||||
|
if (col == null) return null;
|
||||||
|
return _fromCollection(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||||
|
final col = _toEntryCollection(entry);
|
||||||
|
await _isar.writeTxn(() async {
|
||||||
|
await _isar.journalEntryCollections.put(col);
|
||||||
|
});
|
||||||
|
_changeController.add(null);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||||
|
final existing = await _isar.journalEntryCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.idEqualTo(entry.id)
|
||||||
|
.and()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.findFirst();
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
throw StateError('日记不存在: ${entry.id}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观锁冲突检测
|
||||||
|
if (existing.version != entry.version) {
|
||||||
|
throw StateError(
|
||||||
|
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final updated = entry.copyWith(
|
||||||
|
version: entry.version + 1,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final col = _toEntryCollection(updated);
|
||||||
|
col.isarId = existing.isarId; // 保留 Isar 主键
|
||||||
|
|
||||||
|
await _isar.writeTxn(() async {
|
||||||
|
await _isar.journalEntryCollections.put(col);
|
||||||
|
});
|
||||||
|
|
||||||
|
_changeController.add(null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteJournal(String id) async {
|
||||||
|
final existing = await _isar.journalEntryCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.idEqualTo(id)
|
||||||
|
.findFirst();
|
||||||
|
if (existing == null) return;
|
||||||
|
|
||||||
|
// 软删除日记
|
||||||
|
existing.isDeleted = true;
|
||||||
|
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
// 软删除关联元素
|
||||||
|
final elements = await _isar.journalElementCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.journalIdEqualTo(id)
|
||||||
|
.and()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.findAll();
|
||||||
|
|
||||||
|
await _isar.writeTxn(() async {
|
||||||
|
await _isar.journalEntryCollections.put(existing);
|
||||||
|
for (final el in elements) {
|
||||||
|
el.isDeleted = true;
|
||||||
|
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
await _isar.journalElementCollections.put(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_changeController.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 元素 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalElement>> getElements(String journalId) async {
|
||||||
|
final results = await _isar.journalElementCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.journalIdEqualTo(journalId)
|
||||||
|
.and()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.sortByZIndex()
|
||||||
|
.findAll();
|
||||||
|
return results.map(_fromElementCollection).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> addElement(JournalElement element) async {
|
||||||
|
final col = _toElementCollection(element);
|
||||||
|
await _isar.writeTxn(() async {
|
||||||
|
await _isar.journalElementCollections.put(col);
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> updateElement(JournalElement element) async {
|
||||||
|
final existing = await _isar.journalElementCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.idEqualTo(element.id)
|
||||||
|
.and()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.findFirst();
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
throw StateError('元素不存在: ${element.id}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观锁冲突检测
|
||||||
|
if (existing.version != element.version) {
|
||||||
|
throw StateError(
|
||||||
|
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final updated = element.copyWith(
|
||||||
|
version: element.version + 1,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final col = _toElementCollection(updated);
|
||||||
|
col.isarId = existing.isarId;
|
||||||
|
|
||||||
|
await _isar.writeTxn(() async {
|
||||||
|
await _isar.journalElementCollections.put(col);
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeElement(String elementId) async {
|
||||||
|
final existing = await _isar.journalElementCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.idEqualTo(elementId)
|
||||||
|
.findFirst();
|
||||||
|
if (existing == null) return;
|
||||||
|
|
||||||
|
// 软删除
|
||||||
|
existing.isDeleted = true;
|
||||||
|
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
await _isar.writeTxn(() async {
|
||||||
|
await _isar.journalElementCollections.put(existing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 转换函数:JournalEntry ↔ JournalEntryCollection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// JournalEntry → JournalEntryCollection
|
||||||
|
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
|
||||||
|
return JournalEntryCollection()
|
||||||
|
..id = entry.id
|
||||||
|
..authorId = entry.authorId
|
||||||
|
..classId = entry.classId
|
||||||
|
..title = entry.title
|
||||||
|
..dateEpoch = entry.date.millisecondsSinceEpoch
|
||||||
|
..mood = entry.mood.value
|
||||||
|
..weather = entry.weather.value
|
||||||
|
..tagsJson = jsonEncode(entry.tags)
|
||||||
|
..isPrivate = entry.isPrivate
|
||||||
|
..sharedToClass = entry.sharedToClass
|
||||||
|
..assignedTopicId = entry.assignedTopicId
|
||||||
|
..contentExcerpt = entry.contentExcerpt
|
||||||
|
..version = entry.version
|
||||||
|
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||||||
|
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||||||
|
..isDeleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JournalEntryCollection → JournalEntry
|
||||||
|
JournalEntry _fromCollection(JournalEntryCollection col) {
|
||||||
|
return JournalEntry(
|
||||||
|
id: col.id,
|
||||||
|
authorId: col.authorId,
|
||||||
|
classId: col.classId,
|
||||||
|
title: col.title,
|
||||||
|
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
|
||||||
|
mood: Mood.values.firstWhere(
|
||||||
|
(m) => m.value == col.mood,
|
||||||
|
orElse: () => Mood.calm,
|
||||||
|
),
|
||||||
|
weather: Weather.values.firstWhere(
|
||||||
|
(w) => w.value == col.weather,
|
||||||
|
orElse: () => Weather.sunny,
|
||||||
|
),
|
||||||
|
tags: List<String>.from(
|
||||||
|
jsonDecode(col.tagsJson) as List? ?? [],
|
||||||
|
),
|
||||||
|
isPrivate: col.isPrivate,
|
||||||
|
sharedToClass: col.sharedToClass,
|
||||||
|
assignedTopicId: col.assignedTopicId,
|
||||||
|
contentExcerpt: col.contentExcerpt,
|
||||||
|
version: col.version,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 转换函数:JournalElement ↔ JournalElementCollection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// JournalElement → JournalElementCollection
|
||||||
|
JournalElementCollection _toElementCollection(JournalElement element) {
|
||||||
|
return JournalElementCollection()
|
||||||
|
..id = element.id
|
||||||
|
..journalId = element.journalId
|
||||||
|
..elementType = element.elementType.value
|
||||||
|
..positionX = element.positionX
|
||||||
|
..positionY = element.positionY
|
||||||
|
..width = element.width
|
||||||
|
..height = element.height
|
||||||
|
..rotation = element.rotation
|
||||||
|
..zIndex = element.zIndex
|
||||||
|
..contentJson = jsonEncode(element.content)
|
||||||
|
..version = element.version
|
||||||
|
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
|
||||||
|
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
|
||||||
|
..isDeleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JournalElementCollection → JournalElement
|
||||||
|
JournalElement _fromElementCollection(JournalElementCollection col) {
|
||||||
|
return JournalElement(
|
||||||
|
id: col.id,
|
||||||
|
journalId: col.journalId,
|
||||||
|
elementType: ElementType.values.firstWhere(
|
||||||
|
(e) => e.value == col.elementType,
|
||||||
|
orElse: () => ElementType.text,
|
||||||
|
),
|
||||||
|
positionX: col.positionX,
|
||||||
|
positionY: col.positionY,
|
||||||
|
width: col.width,
|
||||||
|
height: col.height,
|
||||||
|
rotation: col.rotation,
|
||||||
|
zIndex: col.zIndex,
|
||||||
|
content: Map<String, dynamic>.from(
|
||||||
|
jsonDecode(col.contentJson) as Map? ?? {},
|
||||||
|
),
|
||||||
|
version: col.version,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/lib/data/repositories/isar_journal_repository_web.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Isar 本地日记仓库 — Web 平台 stub(不可用)
|
||||||
|
//
|
||||||
|
// Isar 3.x 不支持 Web,此文件提供空实现。
|
||||||
|
// Web 平台应使用 RemoteJournalRepository。
|
||||||
|
|
||||||
|
import '../models/journal_entry.dart';
|
||||||
|
import '../models/journal_element.dart';
|
||||||
|
import 'journal_repository.dart';
|
||||||
|
|
||||||
|
/// 空的变更通知流 — Web 平台 stub
|
||||||
|
const _emptyStream = Stream<void>.empty();
|
||||||
|
|
||||||
|
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError)
|
||||||
|
class IsarJournalRepository implements JournalRepository {
|
||||||
|
@override
|
||||||
|
Future<List<JournalEntry>> getJournals({
|
||||||
|
DateTime? dateFrom,
|
||||||
|
DateTime? dateTo,
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
String? mood,
|
||||||
|
String? tag,
|
||||||
|
String? classId,
|
||||||
|
}) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry?> getJournal(String id) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> createJournal(JournalEntry entry) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> updateJournal(JournalEntry entry) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteJournal(String id) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalElement>> getElements(String journalId) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> addElement(JournalElement element) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> updateElement(JournalElement element) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeElement(String elementId) =>
|
||||||
|
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get onJournalChanged => _emptyStream;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
// - SyncEngine 负责协调本地和远程仓库之间的数据同步
|
// - 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';
|
||||||
|
|
||||||
@@ -15,14 +17,20 @@ import '../models/journal_element.dart';
|
|||||||
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
|
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
|
||||||
/// - [page]/[pageSize]: 分页参数,从 1 开始
|
/// - [page]/[pageSize]: 分页参数,从 1 开始
|
||||||
abstract class JournalRepository {
|
abstract class JournalRepository {
|
||||||
/// 获取日记列表(支持日期范围过滤和分页)
|
/// 获取日记列表(支持日期范围、心情、标签、班级过滤和分页)
|
||||||
Future<List<JournalEntry>> getJournals({
|
Future<List<JournalEntry>> getJournals({
|
||||||
DateTime? dateFrom,
|
DateTime? dateFrom,
|
||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
int? page,
|
int? page,
|
||||||
int? pageSize,
|
int? pageSize,
|
||||||
|
String? mood,
|
||||||
|
String? tag,
|
||||||
|
String? classId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 获取日记总数
|
||||||
|
Future<int> getJournalCount();
|
||||||
|
|
||||||
/// 获取单篇日记(返回 null 表示不存在)
|
/// 获取单篇日记(返回 null 表示不存在)
|
||||||
Future<JournalEntry?> getJournal(String id);
|
Future<JournalEntry?> getJournal(String id);
|
||||||
|
|
||||||
@@ -46,6 +54,9 @@ abstract class JournalRepository {
|
|||||||
|
|
||||||
/// 从日记中移除元素
|
/// 从日记中移除元素
|
||||||
Future<void> removeElement(String elementId);
|
Future<void> removeElement(String elementId);
|
||||||
|
|
||||||
|
/// 日记变更通知流 — create/update/delete 时发出信号
|
||||||
|
Stream<void> get onJournalChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 内存实现 — 用于开发阶段快速迭代和单元测试
|
/// 内存实现 — 用于开发阶段快速迭代和单元测试
|
||||||
@@ -55,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({
|
||||||
@@ -62,6 +77,9 @@ class InMemoryJournalRepository implements JournalRepository {
|
|||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
int? page,
|
int? page,
|
||||||
int? pageSize,
|
int? pageSize,
|
||||||
|
String? mood,
|
||||||
|
String? tag,
|
||||||
|
String? classId,
|
||||||
}) async {
|
}) async {
|
||||||
var results = _journals.values.toList();
|
var results = _journals.values.toList();
|
||||||
|
|
||||||
@@ -73,6 +91,21 @@ class InMemoryJournalRepository implements JournalRepository {
|
|||||||
results = results.where((j) => j.date.isBefore(dateTo)).toList();
|
results = results.where((j) => j.date.isBefore(dateTo)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 心情过滤
|
||||||
|
if (mood != null) {
|
||||||
|
results = results.where((j) => j.mood.value == mood).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签过滤(日记 tags 列表包含指定标签)
|
||||||
|
if (tag != null) {
|
||||||
|
results = results.where((j) => j.tags.contains(tag)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级过滤
|
||||||
|
if (classId != null) {
|
||||||
|
results = results.where((j) => j.classId == classId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// 按日期降序排列(最新在前)
|
// 按日期降序排列(最新在前)
|
||||||
results.sort((a, b) => b.date.compareTo(a.date));
|
results.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
@@ -87,6 +120,9 @@ class InMemoryJournalRepository implements JournalRepository {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async => _journals.length;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JournalEntry?> getJournal(String id) async {
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
return _journals[id];
|
return _journals[id];
|
||||||
@@ -95,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,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
|
||||||
|
|||||||
171
app/lib/data/repositories/remote_journal_repository.dart
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// 远程日记仓库 — 通过 API 客户端连接后端
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../models/journal_element.dart';
|
||||||
|
import '../models/journal_entry.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import 'journal_repository.dart';
|
||||||
|
|
||||||
|
/// 远程日记仓库 — 通过 HTTP API 操作后端数据
|
||||||
|
///
|
||||||
|
/// 所有操作需要网络连接。离线场景由 SyncEngine 协调 Isar 本地仓库处理。
|
||||||
|
class RemoteJournalRepository implements JournalRepository {
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
|
||||||
|
final StreamController<void> _changeController =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
RemoteJournalRepository({required ApiClient api}) : _api = api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalEntry>> getJournals({
|
||||||
|
DateTime? dateFrom,
|
||||||
|
DateTime? dateTo,
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
String? mood,
|
||||||
|
String? tag,
|
||||||
|
String? classId,
|
||||||
|
}) async {
|
||||||
|
final queryParams = <String, dynamic>{};
|
||||||
|
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
|
||||||
|
if (dateFrom != null) {
|
||||||
|
queryParams['date_from'] = dateFrom.toIso8601String().replaceFirst(RegExp(r'\.\d+'), '');
|
||||||
|
}
|
||||||
|
if (dateTo != null) {
|
||||||
|
queryParams['date_to'] = dateTo.toIso8601String().replaceFirst(RegExp(r'\.\d+'), '');
|
||||||
|
}
|
||||||
|
if (page != null) queryParams['page'] = page;
|
||||||
|
if (pageSize != null) queryParams['page_size'] = pageSize;
|
||||||
|
if (mood != null) queryParams['mood'] = mood;
|
||||||
|
if (tag != null) queryParams['tag'] = tag;
|
||||||
|
if (classId != null) queryParams['class_id'] = classId;
|
||||||
|
|
||||||
|
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
// 后端信封格式: { success, data: { data: [...], total, page, ... }, message }
|
||||||
|
final envelope = body['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
final items = envelope['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => JournalEntry.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async {
|
||||||
|
final response = await _api.get('/diary/journals', queryParams: {
|
||||||
|
'page': 1,
|
||||||
|
'page_size': 1,
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
// 后端信封格式: { success, data: { data: [...], total, ... }, message }
|
||||||
|
final envelope = body['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
return (envelope['total'] as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _api.get('/diary/journals/$id');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||||
|
// 后端 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 created = JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
_changeController.add(null); // 通知 UI 刷新列表
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||||||
|
final response = await _api.put(
|
||||||
|
'/diary/journals/${entry.id}',
|
||||||
|
data: {
|
||||||
|
'title': entry.title,
|
||||||
|
'mood': entry.mood.value,
|
||||||
|
'weather': entry.weather.value,
|
||||||
|
'tags': entry.tags,
|
||||||
|
'is_private': entry.isPrivate,
|
||||||
|
'shared_to_class': entry.sharedToClass,
|
||||||
|
'version': entry.version,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
_changeController.add(null); // 通知 UI 刷新列表
|
||||||
|
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteJournal(String id) async {
|
||||||
|
await _api.delete('/diary/journals/$id');
|
||||||
|
_changeController.add(null); // 通知 UI 刷新列表
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalElement>> getElements(String journalId) async {
|
||||||
|
final response = await _api.get('/diary/journals/$journalId/elements');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final items = body['data'] as List? ?? [];
|
||||||
|
return items
|
||||||
|
.map((json) => JournalElement.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> addElement(JournalElement element) async {
|
||||||
|
final response = await _api.post(
|
||||||
|
'/diary/journals/${element.journalId}/elements',
|
||||||
|
data: element.toJson(),
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalElement.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> updateElement(JournalElement element) async {
|
||||||
|
final response = await _api.put(
|
||||||
|
'/diary/journals/${element.journalId}/elements/${element.id}',
|
||||||
|
data: element.toJson(),
|
||||||
|
);
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return JournalElement.fromJson(body['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeElement(String elementId) async {
|
||||||
|
await _api.delete('/diary/elements/$elementId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
|
||||||
|
@override
|
||||||
|
Stream<void> get onJournalChanged => _changeController.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 异常封装 — 后端返回非 2xx 状态码时抛出
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int statusCode;
|
||||||
|
final dynamic responseBody;
|
||||||
|
|
||||||
|
const ApiException({
|
||||||
|
required this.message,
|
||||||
|
required this.statusCode,
|
||||||
|
this.responseBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException($statusCode): $message';
|
||||||
|
}
|
||||||
184
app/lib/data/services/content_filter_service.dart
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// 内容安全过滤服务 — 本地敏感词检测
|
||||||
|
//
|
||||||
|
// 提供 checkText() 纯函数,用于在分享日记前检查文本内容是否包含敏感词。
|
||||||
|
// 检测策略:精确匹配 + 谐音/形近/数字变体匹配。
|
||||||
|
// 返回匹配列表,空列表表示内容安全。不自动屏蔽,由 UI 层决定提示方式。
|
||||||
|
|
||||||
|
import 'sensitive_words.dart';
|
||||||
|
|
||||||
|
/// 敏感词匹配结果
|
||||||
|
class SensitiveWordMatch {
|
||||||
|
/// 匹配到的敏感词原文
|
||||||
|
final String word;
|
||||||
|
|
||||||
|
/// 所属分类
|
||||||
|
final SensitiveCategory category;
|
||||||
|
|
||||||
|
/// 在预处理后文本中的起始位置
|
||||||
|
final int position;
|
||||||
|
|
||||||
|
const SensitiveWordMatch({
|
||||||
|
required this.word,
|
||||||
|
required this.category,
|
||||||
|
required this.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SensitiveWordMatch("$word", ${category.label}, @$position)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内容安全过滤服务
|
||||||
|
///
|
||||||
|
/// 纯静态方法,无状态,可安全在任何地方调用。
|
||||||
|
/// 性能:~200 条词 × contains() 检查,<1ms 完成。
|
||||||
|
class ContentFilterService {
|
||||||
|
ContentFilterService._();
|
||||||
|
|
||||||
|
/// 检查文本内容,返回所有匹配到的敏感词。
|
||||||
|
///
|
||||||
|
/// 对输入文本进行预处理(去空格/特殊符号/零宽字符/小写化),
|
||||||
|
/// 然后遍历全量词库做精确匹配和谐音变体匹配。
|
||||||
|
/// 返回空列表表示内容安全。
|
||||||
|
static List<SensitiveWordMatch> checkText(String text) {
|
||||||
|
if (text.isEmpty) return const [];
|
||||||
|
|
||||||
|
final normalized = _normalize(text);
|
||||||
|
if (normalized.isEmpty) return const [];
|
||||||
|
|
||||||
|
final matches = <SensitiveWordMatch>[];
|
||||||
|
final seen = <String>{}; // 去重:同一词不重复报告
|
||||||
|
|
||||||
|
for (final entry in kSensitiveWords.entries) {
|
||||||
|
final category = entry.key;
|
||||||
|
|
||||||
|
for (final word in entry.value) {
|
||||||
|
// 精确匹配
|
||||||
|
final pos = normalized.indexOf(word);
|
||||||
|
if (pos >= 0 && seen.add(word)) {
|
||||||
|
matches.add(SensitiveWordMatch(
|
||||||
|
word: word,
|
||||||
|
category: category,
|
||||||
|
position: pos,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 谐音/变体匹配 — 将词中每个有变体映射的字替换为变体,检查是否命中
|
||||||
|
if (_matchesWithVariants(normalized, word)) {
|
||||||
|
if (seen.add('variant:$word')) {
|
||||||
|
matches.add(SensitiveWordMatch(
|
||||||
|
word: word,
|
||||||
|
category: category,
|
||||||
|
position: -1, // 变体匹配无法精确定位
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整词变体匹配 — kHomophoneVariants 中的多字 key(如 "卧槽")
|
||||||
|
for (final variantEntry in kHomophoneVariants.entries) {
|
||||||
|
final originalKey = variantEntry.key;
|
||||||
|
if (originalKey.length <= 1) continue; // 单字已在上面处理
|
||||||
|
|
||||||
|
// 找到这个变体 key 对应的分类
|
||||||
|
SensitiveCategory? foundCategory;
|
||||||
|
for (final entry in kSensitiveWords.entries) {
|
||||||
|
if (entry.value.contains(originalKey)) {
|
||||||
|
foundCategory = entry.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCategory == null) continue;
|
||||||
|
|
||||||
|
for (final variant in variantEntry.value) {
|
||||||
|
if (variant.isEmpty) continue;
|
||||||
|
final vPos = normalized.indexOf(variant.toLowerCase());
|
||||||
|
if (vPos >= 0) {
|
||||||
|
final key = 'wvariant:$originalKey:$variant';
|
||||||
|
if (seen.add(key)) {
|
||||||
|
matches.add(SensitiveWordMatch(
|
||||||
|
word: originalKey,
|
||||||
|
category: foundCategory,
|
||||||
|
position: vPos,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查文本是否包含敏感词(快捷方法)
|
||||||
|
static bool hasSensitiveContent(String text) => checkText(text).isNotEmpty;
|
||||||
|
|
||||||
|
/// 获取匹配到的分类标签集合(用于 UI 展示)
|
||||||
|
static Set<String> getMatchedCategories(List<SensitiveWordMatch> matches) {
|
||||||
|
return matches.map((m) => m.category.label).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 变体匹配:检查文本中是否出现了词的谐音/形近/数字变体版本
|
||||||
|
///
|
||||||
|
/// 将敏感词中每个有变体映射的字符逐一替换为变体,检查替换后的
|
||||||
|
/// 字符串是否出现在文本中。例如 "去死" → 检查 "去4" 是否在文本中。
|
||||||
|
static bool _matchesWithVariants(String normalizedText, String word) {
|
||||||
|
final chars = word.split('');
|
||||||
|
final variantChars = <List<String>>[];
|
||||||
|
|
||||||
|
for (final char in chars) {
|
||||||
|
final variants = kHomophoneVariants[char];
|
||||||
|
if (variants != null && variants.isNotEmpty) {
|
||||||
|
// 原字符 + 所有变体
|
||||||
|
variantChars.add([char, ...variants]);
|
||||||
|
} else {
|
||||||
|
variantChars.add([char]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成所有变体组合并检查
|
||||||
|
return _checkCombinations(normalizedText, variantChars, 0, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归生成变体组合并检查文本
|
||||||
|
static bool _checkCombinations(
|
||||||
|
String text,
|
||||||
|
List<List<String>> variantChars,
|
||||||
|
int index,
|
||||||
|
String current,
|
||||||
|
) {
|
||||||
|
if (index == variantChars.length) {
|
||||||
|
return text.contains(current);
|
||||||
|
}
|
||||||
|
for (final char in variantChars[index]) {
|
||||||
|
if (_checkCombinations(text, variantChars, index + 1, current + char)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文本预处理:去除干扰字符,统一为小写
|
||||||
|
///
|
||||||
|
/// 1. 去除零宽字符(U+200B~U+200F, U+FEFF)
|
||||||
|
/// 2. 去除空格、制表符、换行
|
||||||
|
/// 3. 去除常见特殊符号(用于绕过的 @#$%^&* 等)
|
||||||
|
/// 4. 转小写(对英文词有效)
|
||||||
|
static String _normalize(String text) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final rune in text.runes) {
|
||||||
|
// 跳过零宽字符
|
||||||
|
if (rune >= 0x200B && rune <= 0x200F) continue;
|
||||||
|
if (rune == 0xFEFF) continue;
|
||||||
|
// 跳过空白
|
||||||
|
if (rune == 0x20 || rune == 0x09 || rune == 0x0A || rune == 0x0D) continue;
|
||||||
|
// 跳过常见绕过符号
|
||||||
|
if (rune == 0x2E || rune == 0x2C || rune == 0x2D || rune == 0x5F) continue; // . , - _
|
||||||
|
if (rune == 0x21 || rune == 0x40 || rune == 0x23 || rune == 0x24) continue; // ! @ # $
|
||||||
|
if (rune == 0x25 || rune == 0x5E || rune == 0x26 || rune == 0x2A) continue; // % ^ & *
|
||||||
|
if (rune == 0x7E || rune == 0x60) continue; // ~ `
|
||||||
|
|
||||||
|
buffer.writeCharCode(rune);
|
||||||
|
}
|
||||||
|
return buffer.toString().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/lib/data/services/sensitive_words.dart
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// 敏感词库 — 本地静态词库常量,面向小学生场景
|
||||||
|
//
|
||||||
|
// 分类:暴力、色情、欺凌、毒品、赌博、政治、诈骗、粗口
|
||||||
|
// 每个分类包含基础词 + 谐音/形近/数字变体
|
||||||
|
// 词库为 const 编译期常量,零运行时开销
|
||||||
|
//
|
||||||
|
// 注意:本词库仅为 Phase 1 基础覆盖,Phase 2 将接入服务端 AI + 可更新词库。
|
||||||
|
|
||||||
|
/// 敏感词分类
|
||||||
|
enum SensitiveCategory {
|
||||||
|
violence('暴力'),
|
||||||
|
sexual('色情'),
|
||||||
|
bullying('欺凌'),
|
||||||
|
drugs('毒品'),
|
||||||
|
gambling('赌博'),
|
||||||
|
politics('政治敏感'),
|
||||||
|
fraud('诈骗'),
|
||||||
|
profanity('粗口');
|
||||||
|
|
||||||
|
const SensitiveCategory(this.label);
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 各分类敏感词
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 暴力类
|
||||||
|
const _violenceWords = [
|
||||||
|
// 直接暴力
|
||||||
|
'杀人', '砍人', '捅人', '打死', '打死你', '弄死', '弄死你',
|
||||||
|
'揍你', '揍死', '打死他', '砍死', '捅死',
|
||||||
|
'杀了他', '打死他', '砍了他', '捅了他',
|
||||||
|
'去死', '你去死', '怎么不去死',
|
||||||
|
'割腕', '割脖子', '跳楼', '上吊',
|
||||||
|
// 武器
|
||||||
|
'炸弹', '手枪', '步枪', '子弹', '刀杀',
|
||||||
|
// 自残/伤害暗示
|
||||||
|
'自杀', '自残', '不想活',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 色情类
|
||||||
|
const _sexualWords = [
|
||||||
|
'色情', '裸体', '裸照', '黄色', '黄片',
|
||||||
|
'做爱', '性行为', '性交', '强奸', '强暴',
|
||||||
|
'猥亵', '性骚扰', '偷拍',
|
||||||
|
'发情', '骚货', '贱人',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 欺凌类
|
||||||
|
const _bullyingWords = [
|
||||||
|
'废物', '垃圾', '蠢货', '白痴', '弱智',
|
||||||
|
'傻子', '笨蛋', '猪头', '丑八怪',
|
||||||
|
'滚开', '滚蛋', '闭嘴', '别烦我',
|
||||||
|
'讨厌鬼', '没人要', '没朋友',
|
||||||
|
'不和你玩', '不要和你玩',
|
||||||
|
'大家不要理', '孤立',
|
||||||
|
'偷东西', '小偷',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 毒品类
|
||||||
|
const _drugsWords = [
|
||||||
|
'毒品', '吸毒', '贩毒', '大麻', '海洛因',
|
||||||
|
'冰毒', '摇头丸', '可卡因', '吗啡',
|
||||||
|
'鸦片', 'K粉', '安非他命',
|
||||||
|
'上瘾', '毒瘾',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 赌博类
|
||||||
|
const _gamblingWords = [
|
||||||
|
'赌博', '赌钱', '下注', '押注', '赌场',
|
||||||
|
'买彩票', '时时彩', '六合彩',
|
||||||
|
'百家乐', '老虎机', '扑克赌',
|
||||||
|
'赌债', '借钱赌',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 政治敏感类
|
||||||
|
const _politicsWords = [
|
||||||
|
'反动', '颠覆', '分裂', '暴动', '造反',
|
||||||
|
'推翻', '政变', '游行示威',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 诈骗类
|
||||||
|
const _fraudWords = [
|
||||||
|
'诈骗', '骗钱', '骗密码', '骗账号',
|
||||||
|
'中奖了', '恭喜中奖', '免费领取',
|
||||||
|
'点击链接领奖', '转账给我',
|
||||||
|
'刷单', '兼职刷单', '高薪兼职',
|
||||||
|
'传销', '拉人头',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 粗口类
|
||||||
|
const _profanityWords = [
|
||||||
|
'操你', '妈的', '他妈', '去你的', '狗屎',
|
||||||
|
'滚', '屁', '放屁', '扯淡', '王八蛋',
|
||||||
|
'混蛋', '靠', '我去', '卧槽',
|
||||||
|
'我靠', '我擦',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 全量词库:分类 → 词列表
|
||||||
|
const Map<SensitiveCategory, List<String>> kSensitiveWords = {
|
||||||
|
SensitiveCategory.violence: _violenceWords,
|
||||||
|
SensitiveCategory.sexual: _sexualWords,
|
||||||
|
SensitiveCategory.bullying: _bullyingWords,
|
||||||
|
SensitiveCategory.drugs: _drugsWords,
|
||||||
|
SensitiveCategory.gambling: _gamblingWords,
|
||||||
|
SensitiveCategory.politics: _politicsWords,
|
||||||
|
SensitiveCategory.fraud: _fraudWords,
|
||||||
|
SensitiveCategory.profanity: _profanityWords,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 谐音/形近/数字变体映射
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 原词 → 变体列表
|
||||||
|
///
|
||||||
|
/// 变体检测在预处理后的文本上运行,可以捕获常见的绕过手法:
|
||||||
|
/// - 数字谐音: "死" → "4"
|
||||||
|
/// - 形近替换: "傻" → "纱"
|
||||||
|
/// - 拼音缩写: "牛逼" → "nb"
|
||||||
|
const Map<String, List<String>> kHomophoneVariants = {
|
||||||
|
// 暴力相关
|
||||||
|
'死': ['4', '④', '亖', '☠'],
|
||||||
|
'杀': ['莎', '纱', '沙'],
|
||||||
|
'砍': ['砍人'],
|
||||||
|
'捅': ['捅人'],
|
||||||
|
// 欺凌相关
|
||||||
|
'傻': ['纱', '沙', '啥'],
|
||||||
|
'笨': [], // 无实际变体
|
||||||
|
'蠢': ['春'],
|
||||||
|
'废物': ['费物', '废无'],
|
||||||
|
'垃圾': ['拉吉', '垃 圾'],
|
||||||
|
// 粗口相关
|
||||||
|
'操': ['草', '艹', '槽'],
|
||||||
|
'卧槽': ['我槽', '我草', 'wc', 'WC', 'Wc'],
|
||||||
|
'我靠': ['我 k', '我K'],
|
||||||
|
// 欺凌
|
||||||
|
'滚': ['衮'],
|
||||||
|
'屁': ['辟'],
|
||||||
|
};
|
||||||
146
app/lib/data/services/sse_notification_service.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// SSE 通知服务 — 监听服务端推送事件
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// SSE 通知事件
|
||||||
|
class NotificationEvent {
|
||||||
|
final String type;
|
||||||
|
final Map<String, dynamic> payload;
|
||||||
|
final DateTime receivedAt;
|
||||||
|
|
||||||
|
const NotificationEvent({
|
||||||
|
required this.type,
|
||||||
|
required this.payload,
|
||||||
|
required this.receivedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSE 通知服务 — 监听后端 Server-Sent Events 推送
|
||||||
|
///
|
||||||
|
/// 使用方式:
|
||||||
|
/// ```dart
|
||||||
|
/// final service = SseNotificationService(token: 'jwt-token');
|
||||||
|
/// service.events.listen((event) {
|
||||||
|
/// // 处理通知
|
||||||
|
/// });
|
||||||
|
/// await service.connect();
|
||||||
|
/// ```
|
||||||
|
class SseNotificationService {
|
||||||
|
final String _baseUrl;
|
||||||
|
final String? _token;
|
||||||
|
|
||||||
|
Dio? _dio;
|
||||||
|
Response<ResponseBody>? _response;
|
||||||
|
StreamController<NotificationEvent>? _controller;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
SseNotificationService({
|
||||||
|
required String token,
|
||||||
|
required String baseUrl,
|
||||||
|
}) : _token = token,
|
||||||
|
_baseUrl = baseUrl;
|
||||||
|
|
||||||
|
/// 通知事件流
|
||||||
|
Stream<NotificationEvent> get events {
|
||||||
|
_controller ??= StreamController<NotificationEvent>.broadcast();
|
||||||
|
return _controller!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接到 SSE 端点
|
||||||
|
Future<void> connect() async {
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
_dio = Dio(BaseOptions(
|
||||||
|
baseUrl: _baseUrl,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
if (_token != null) 'Authorization': 'Bearer $_token',
|
||||||
|
},
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
_response = await _dio!.get<ResponseBody>('/message/stream');
|
||||||
|
|
||||||
|
if (_response?.data == null) return;
|
||||||
|
|
||||||
|
_response!.data!.stream.listen(
|
||||||
|
(data) {
|
||||||
|
_parseSseData(data);
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
if (!_disposed) {
|
||||||
|
// 自动重连逻辑(3秒延迟)
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (!_disposed) connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
if (!_disposed) {
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (!_disposed) connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 连接失败,延迟重连
|
||||||
|
if (!_disposed) {
|
||||||
|
Future.delayed(const Duration(seconds: 5), () {
|
||||||
|
if (!_disposed) connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 SSE 数据帧
|
||||||
|
void _parseSseData(List<int> data) {
|
||||||
|
final text = utf8.decode(data);
|
||||||
|
final lines = text.split('\n');
|
||||||
|
|
||||||
|
String? eventType;
|
||||||
|
String? eventData;
|
||||||
|
|
||||||
|
for (final line in lines) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventType = line.substring(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
eventData = line.substring(5).trim();
|
||||||
|
} else if (line.isEmpty && eventType != null && eventData != null) {
|
||||||
|
// 空行 = 事件结束
|
||||||
|
_emitEvent(eventType, eventData);
|
||||||
|
eventType = null;
|
||||||
|
eventData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发射通知事件到流
|
||||||
|
void _emitEvent(String type, String data) {
|
||||||
|
if (_disposed || _controller == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final payload = jsonDecode(data) as Map<String, dynamic>;
|
||||||
|
_controller!.add(NotificationEvent(
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 断开连接并释放资源
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_response?.data?.stream.listen((_) {});
|
||||||
|
_controller?.close();
|
||||||
|
_dio?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,232 +1,7 @@
|
|||||||
// 同步引擎 — WiFi 增量同步 + 操作队列
|
// 同步引擎 — 条件导出
|
||||||
//
|
//
|
||||||
// 设计思路:
|
// 根据平台选择实现:
|
||||||
// - 所有本地修改先入队 [PendingOperation]
|
// - 原生平台 → sync_engine_native.dart(Isar 持久化队列)
|
||||||
// - 网络恢复时自动批量同步
|
// - Web 平台 → sync_engine_web.dart(纯内存队列)
|
||||||
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
|
||||||
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
|
||||||
//
|
|
||||||
// Phase 1 策略:本地优先
|
|
||||||
// - 离线时正常使用,操作入队等待
|
|
||||||
// - 联网后自动推送待同步操作
|
|
||||||
// - 版本冲突时本地版本覆盖远端(简单策略)
|
|
||||||
|
|
||||||
import 'dart:collection';
|
export 'sync_engine_web.dart' if (dart.library.io) 'sync_engine_native.dart';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.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);
|
|
||||||
///
|
|
||||||
/// // 本地修改后入队
|
|
||||||
/// engine.enqueue(PendingOperation(
|
|
||||||
/// id: 'op-1',
|
|
||||||
/// type: SyncOperationType.create,
|
|
||||||
/// endpoint: '/diary/entries',
|
|
||||||
/// data: entry.toJson(),
|
|
||||||
/// version: 1,
|
|
||||||
/// createdAt: DateTime.now(),
|
|
||||||
/// ));
|
|
||||||
///
|
|
||||||
/// // 网络恢复时触发同步
|
|
||||||
/// await engine.trySync();
|
|
||||||
/// ```
|
|
||||||
class SyncEngine {
|
|
||||||
final ApiClient _apiClient;
|
|
||||||
final Queue<PendingOperation> _pendingQueue = Queue();
|
|
||||||
|
|
||||||
SyncStatus _status = SyncStatus.idle;
|
|
||||||
String? _lastError;
|
|
||||||
|
|
||||||
SyncEngine({required this._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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行单个同步操作
|
|
||||||
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,
|
|
||||||
/// 下次启动时通过 [enqueueAll] 恢复。
|
|
||||||
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
|
||||||
}
|
|
||||||
|
|||||||
504
app/lib/data/services/sync_engine_native.dart
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
|
||||||
|
//
|
||||||
|
// 设计思路:
|
||||||
|
// - 所有本地修改先入队 [PendingOperation]
|
||||||
|
// - 网络恢复时自动批量同步
|
||||||
|
// - 版本号冲突检测,Phase 1 使用"本地优先"策略
|
||||||
|
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
|
||||||
|
// - 队列持久化到 Isar,应用退出后不丢失
|
||||||
|
//
|
||||||
|
// Phase 1 策略:本地优先
|
||||||
|
// - 离线时正常使用,操作入队等待
|
||||||
|
// - 联网后自动推送待同步操作
|
||||||
|
// - 版本冲突时本地版本覆盖远端(简单策略)
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
import '../local/isar_database_native.dart';
|
||||||
|
import '../local/collections/pending_operation_collection.dart';
|
||||||
|
import '../models/sync_models.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
|
||||||
|
/// 同步操作类型
|
||||||
|
enum SyncOperationType {
|
||||||
|
create('POST'),
|
||||||
|
update('PUT'),
|
||||||
|
delete('DELETE');
|
||||||
|
|
||||||
|
const SyncOperationType(this.httpMethod);
|
||||||
|
final String httpMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步状态
|
||||||
|
enum SyncStatus {
|
||||||
|
idle, // 空闲,无待同步操作
|
||||||
|
syncing, // 正在同步
|
||||||
|
paused, // 暂停(网络不可用)
|
||||||
|
error, // 出错,需要重试
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待同步操作 — 记录一次本地修改
|
||||||
|
class PendingOperation {
|
||||||
|
final String id;
|
||||||
|
final SyncOperationType type;
|
||||||
|
final String endpoint;
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
final int version;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final int retryCount;
|
||||||
|
|
||||||
|
/// 最大重试次数
|
||||||
|
static const int maxRetryCount = 5;
|
||||||
|
|
||||||
|
const PendingOperation({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.endpoint,
|
||||||
|
required this.data,
|
||||||
|
required this.version,
|
||||||
|
required this.createdAt,
|
||||||
|
this.retryCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
PendingOperation copyWith({
|
||||||
|
String? id,
|
||||||
|
SyncOperationType? type,
|
||||||
|
String? endpoint,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
int? version,
|
||||||
|
DateTime? createdAt,
|
||||||
|
int? retryCount,
|
||||||
|
}) =>
|
||||||
|
PendingOperation(
|
||||||
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
endpoint: endpoint ?? this.endpoint,
|
||||||
|
data: data ?? this.data,
|
||||||
|
version: version ?? this.version,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
retryCount: retryCount ?? this.retryCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 是否已超过最大重试次数
|
||||||
|
bool get isExhausted => retryCount >= maxRetryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
|
||||||
|
///
|
||||||
|
/// 使用方式:
|
||||||
|
/// ```dart
|
||||||
|
/// final engine = SyncEngine(apiClient: apiClient);
|
||||||
|
///
|
||||||
|
/// // 启动时恢复持久化队列
|
||||||
|
/// await engine.restorePendingQueue();
|
||||||
|
///
|
||||||
|
/// // 本地修改后入队
|
||||||
|
/// engine.enqueue(PendingOperation(
|
||||||
|
/// id: 'op-1',
|
||||||
|
/// type: SyncOperationType.create,
|
||||||
|
/// endpoint: '/diary/entries',
|
||||||
|
/// data: entry.toJson(),
|
||||||
|
/// version: 1,
|
||||||
|
/// createdAt: DateTime.now(),
|
||||||
|
/// ));
|
||||||
|
///
|
||||||
|
/// // 网络恢复时触发同步
|
||||||
|
/// await engine.trySync();
|
||||||
|
///
|
||||||
|
/// // 应用退出时持久化
|
||||||
|
/// await engine.persistPendingQueue();
|
||||||
|
/// ```
|
||||||
|
class SyncEngine {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final Queue<PendingOperation> _pendingQueue = Queue();
|
||||||
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||||
|
|
||||||
|
SyncStatus _status = SyncStatus.idle;
|
||||||
|
String? _lastError;
|
||||||
|
|
||||||
|
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||||
|
|
||||||
|
/// 当前同步状态
|
||||||
|
SyncStatus get status => _status;
|
||||||
|
|
||||||
|
/// 最近一次错误信息
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
|
||||||
|
/// 待同步操作数量
|
||||||
|
int get pendingCount => _pendingQueue.length;
|
||||||
|
|
||||||
|
/// 是否有操作正在同步
|
||||||
|
bool get isSyncing => _status == SyncStatus.syncing;
|
||||||
|
|
||||||
|
/// 添加待同步操作到队列尾部
|
||||||
|
///
|
||||||
|
/// 合并策略(8b-N01):同一资源(endpoint 相同)的连续操作只保留最新一条。
|
||||||
|
/// create+update → create(使用最新数据)
|
||||||
|
/// update+update → update(使用最新数据)
|
||||||
|
/// update+delete → delete(资源最终被删除)
|
||||||
|
/// create+delete → 取消(资源从未存在)
|
||||||
|
///
|
||||||
|
/// 私密日记(is_private=true)不入队 — 仅保存在本地,不上传后端。
|
||||||
|
void enqueue(PendingOperation operation) {
|
||||||
|
// 防御性检查:私密日记不入队
|
||||||
|
final isPrivate = operation.data['is_private'] as bool? ?? false;
|
||||||
|
if (isPrivate) {
|
||||||
|
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找队列中同一资源的最后一个操作
|
||||||
|
PendingOperation? existing;
|
||||||
|
for (final op in _pendingQueue) {
|
||||||
|
if (op.endpoint == operation.endpoint) {
|
||||||
|
existing = op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
final merged = _mergeOperations(existing, operation);
|
||||||
|
_pendingQueue.remove(existing);
|
||||||
|
if (merged != null) {
|
||||||
|
_pendingQueue.add(merged);
|
||||||
|
}
|
||||||
|
// merged == null → create+delete 取消,不添加
|
||||||
|
} else {
|
||||||
|
_pendingQueue.add(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_status == SyncStatus.idle) {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量添加待同步操作(每个操作独立走合并逻辑)
|
||||||
|
void enqueueAll(List<PendingOperation> operations) {
|
||||||
|
for (final op in operations) {
|
||||||
|
enqueue(op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并同一资源的两个操作
|
||||||
|
///
|
||||||
|
/// 返回合并后的操作,或 null 表示应取消(create+delete)。
|
||||||
|
PendingOperation? _mergeOperations(
|
||||||
|
PendingOperation existing,
|
||||||
|
PendingOperation incoming,
|
||||||
|
) {
|
||||||
|
// create + delete → 取消(资源从未同步到服务端)
|
||||||
|
if (existing.type == SyncOperationType.create &&
|
||||||
|
incoming.type == SyncOperationType.delete) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create + update → create(使用最新数据)
|
||||||
|
if (existing.type == SyncOperationType.create &&
|
||||||
|
incoming.type == SyncOperationType.update) {
|
||||||
|
return existing.copyWith(data: incoming.data, version: incoming.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update + update → update(使用最新数据)
|
||||||
|
if (existing.type == SyncOperationType.update &&
|
||||||
|
incoming.type == SyncOperationType.update) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update + delete → delete
|
||||||
|
if (existing.type == SyncOperationType.update &&
|
||||||
|
incoming.type == SyncOperationType.delete) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他组合(delete+create, create+create 等)不合并
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查网络状态并尝试同步全部待处理操作
|
||||||
|
///
|
||||||
|
/// 同步策略:
|
||||||
|
/// 1. 检查网络是否可用
|
||||||
|
/// 2. 按先进先出顺序处理队列
|
||||||
|
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
|
||||||
|
/// 4. 超过重试次数的操作标记为冲突,移出队列
|
||||||
|
/// 5. 网络中断时暂停同步,保留剩余操作
|
||||||
|
Future<void> trySync() async {
|
||||||
|
if (_status == SyncStatus.syncing) return; // 防止重入
|
||||||
|
if (_pendingQueue.isEmpty) {
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查网络
|
||||||
|
final connectivity = Connectivity();
|
||||||
|
final result = await connectivity.checkConnectivity();
|
||||||
|
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||||
|
if (!isOnline) {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
_lastError = '网络不可用';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi 优先策略:仅在 WiFi 下自动同步(Phase 1 简化)
|
||||||
|
// TODO: 添加用户设置允许蜂窝数据同步
|
||||||
|
|
||||||
|
_status = SyncStatus.syncing;
|
||||||
|
_lastError = null;
|
||||||
|
|
||||||
|
while (_pendingQueue.isNotEmpty) {
|
||||||
|
final operation = _pendingQueue.removeFirst();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _executeOperation(operation);
|
||||||
|
} on OfflineException {
|
||||||
|
// 网络中断,操作放回队列头部
|
||||||
|
_pendingQueue.addFirst(operation);
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
_lastError = '同步中断:网络不可用';
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('SyncEngine.trySync 操作失败: $e');
|
||||||
|
// 操作失败,增加重试计数
|
||||||
|
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||||||
|
|
||||||
|
if (retried.isExhausted) {
|
||||||
|
// 超过最大重试次数,标记为冲突(Phase 1 简化:丢弃)
|
||||||
|
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
|
||||||
|
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放回队列头部,下次重试
|
||||||
|
_pendingQueue.addFirst(retried);
|
||||||
|
_status = SyncStatus.error;
|
||||||
|
_lastError = '同步失败: $e';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部同步完成,更新持久化
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
_lastError = null;
|
||||||
|
await persistPendingQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量同步 — 使用 POST /diary/sync 端点一次性提交所有变更
|
||||||
|
///
|
||||||
|
/// 将队列中的 PendingOperation 转换为 SyncChange 列表,
|
||||||
|
/// 调用 Rust sync_handler 批量处理,获取服务端变更和冲突。
|
||||||
|
/// 成功后清空队列;失败时保留队列供重试。
|
||||||
|
Future<SyncResp?> tryBatchSync({DateTime? lastSyncTime}) async {
|
||||||
|
if (_status == SyncStatus.syncing) return null;
|
||||||
|
if (_pendingQueue.isEmpty) {
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = SyncStatus.syncing;
|
||||||
|
_lastError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 转换: PendingOperation → SyncChange
|
||||||
|
final changes = _pendingQueue.map(_operationToSyncChange).toList();
|
||||||
|
|
||||||
|
final req = SyncReq(
|
||||||
|
lastSyncTime: lastSyncTime,
|
||||||
|
changes: changes,
|
||||||
|
);
|
||||||
|
|
||||||
|
final resp = await _apiClient.sync(req);
|
||||||
|
|
||||||
|
// 处理冲突 — 将冲突的操作保留在队列中
|
||||||
|
if (resp.conflicts.isNotEmpty) {
|
||||||
|
final conflictIds = resp.conflicts.map((c) => c.journalId).toSet();
|
||||||
|
// 移除已成功同步的非冲突操作,保留冲突操作
|
||||||
|
_pendingQueue.removeWhere(
|
||||||
|
(op) => !conflictIds.contains(op.id),
|
||||||
|
);
|
||||||
|
_lastError = '${resp.conflicts.length} 个操作存在版本冲突';
|
||||||
|
} else {
|
||||||
|
// 全部成功,清空队列
|
||||||
|
_pendingQueue.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = _pendingQueue.isEmpty ? SyncStatus.idle : SyncStatus.paused;
|
||||||
|
await persistPendingQueue();
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
} on OfflineException {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
_lastError = '同步中断:网络不可用';
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
_status = SyncStatus.error;
|
||||||
|
_lastError = '批量同步失败: $e';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PendingOperation → SyncChange 转换
|
||||||
|
SyncChange _operationToSyncChange(PendingOperation op) {
|
||||||
|
switch (op.type) {
|
||||||
|
case SyncOperationType.create:
|
||||||
|
return SyncChangeCreateJournal(data: op.data);
|
||||||
|
case SyncOperationType.update:
|
||||||
|
return SyncChangeUpdateJournal(
|
||||||
|
id: op.id,
|
||||||
|
version: op.version,
|
||||||
|
data: op.data,
|
||||||
|
);
|
||||||
|
case SyncOperationType.delete:
|
||||||
|
return SyncChangeDeleteJournal(
|
||||||
|
id: op.id,
|
||||||
|
version: op.version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行单个同步操作
|
||||||
|
Future<void> _executeOperation(PendingOperation operation) async {
|
||||||
|
switch (operation.type) {
|
||||||
|
case SyncOperationType.create:
|
||||||
|
await _apiClient.post(operation.endpoint, data: operation.data);
|
||||||
|
case SyncOperationType.update:
|
||||||
|
await _apiClient.put(operation.endpoint, data: operation.data);
|
||||||
|
case SyncOperationType.delete:
|
||||||
|
await _apiClient.delete(operation.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
|
||||||
|
void clear() {
|
||||||
|
_pendingQueue.clear();
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
_lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
|
||||||
|
///
|
||||||
|
/// 应用退出时调用此方法,将待同步操作保存到 Isar,
|
||||||
|
/// 下次启动时通过 [restorePendingQueue] 恢复。
|
||||||
|
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Isar 持久化
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 将当前内存队列持久化到 Isar
|
||||||
|
///
|
||||||
|
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
|
||||||
|
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||||
|
Future<void> persistPendingQueue() async {
|
||||||
|
if (!IsarDatabase.isAvailable) return;
|
||||||
|
final isar = IsarDatabase.instance;
|
||||||
|
final ops = snapshot;
|
||||||
|
|
||||||
|
await isar.writeTxn(() async {
|
||||||
|
// 清空旧数据
|
||||||
|
await isar.pendingOperationCollections.clear();
|
||||||
|
|
||||||
|
// 写入当前队列
|
||||||
|
for (final op in ops) {
|
||||||
|
final col = _operationToCollection(op);
|
||||||
|
await isar.pendingOperationCollections.put(col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Isar 恢复持久化队列到内存
|
||||||
|
///
|
||||||
|
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
|
||||||
|
/// Web 平台上 Isar 不可用,跳过恢复。
|
||||||
|
Future<void> restorePendingQueue() async {
|
||||||
|
if (!IsarDatabase.isAvailable) return;
|
||||||
|
final isar = IsarDatabase.instance;
|
||||||
|
final persisted = await isar.pendingOperationCollections
|
||||||
|
.where()
|
||||||
|
.anyIsarId()
|
||||||
|
.findAll();
|
||||||
|
|
||||||
|
for (final col in persisted) {
|
||||||
|
final op = _collectionToOperation(col);
|
||||||
|
_pendingQueue.add(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动网络监听 — 网络恢复时自动触发同步
|
||||||
|
///
|
||||||
|
/// 在 app.dart 中创建 SyncEngine 后调用一次。
|
||||||
|
/// 调用 [dispose] 停止监听。
|
||||||
|
void startAutoSync() {
|
||||||
|
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
|
||||||
|
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||||
|
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
|
||||||
|
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
|
||||||
|
trySync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止网络监听并清理资源
|
||||||
|
void dispose() {
|
||||||
|
_connectivitySub?.cancel();
|
||||||
|
_connectivitySub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 转换函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// PendingOperation → PendingOperationCollection
|
||||||
|
PendingOperationCollection _operationToCollection(PendingOperation op) {
|
||||||
|
return PendingOperationCollection()
|
||||||
|
..id = op.id
|
||||||
|
..operationType = op.type.httpMethod
|
||||||
|
..endpoint = op.endpoint
|
||||||
|
..dataJson = _encodeJson(op.data)
|
||||||
|
..version = op.version
|
||||||
|
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
|
||||||
|
..retryCount = op.retryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PendingOperationCollection → PendingOperation
|
||||||
|
PendingOperation _collectionToOperation(PendingOperationCollection col) {
|
||||||
|
return PendingOperation(
|
||||||
|
id: col.id,
|
||||||
|
type: SyncOperationType.values.firstWhere(
|
||||||
|
(t) => t.httpMethod == col.operationType,
|
||||||
|
orElse: () => SyncOperationType.create,
|
||||||
|
),
|
||||||
|
endpoint: col.endpoint,
|
||||||
|
data: _decodeJson(col.dataJson),
|
||||||
|
version: col.version,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||||
|
retryCount: col.retryCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安全编码 JSON
|
||||||
|
String _encodeJson(Map<String, dynamic> data) {
|
||||||
|
try {
|
||||||
|
return jsonEncode(data);
|
||||||
|
} catch (_) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安全解码 JSON
|
||||||
|
Map<String, dynamic> _decodeJson(String json) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
app/lib/data/services/sync_engine_web.dart
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
// 同步引擎 — Web 平台实现(无 Isar 持久化)
|
||||||
|
//
|
||||||
|
// Web 平台上 Isar 不可用,操作队列仅保存在内存中。
|
||||||
|
// 核心同步逻辑与原生版一致,仅持久化部分为空实现。
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
|
||||||
|
/// 同步操作类型
|
||||||
|
enum SyncOperationType {
|
||||||
|
create('POST'),
|
||||||
|
update('PUT'),
|
||||||
|
delete('DELETE');
|
||||||
|
|
||||||
|
const SyncOperationType(this.httpMethod);
|
||||||
|
final String httpMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步状态
|
||||||
|
enum SyncStatus {
|
||||||
|
idle,
|
||||||
|
syncing,
|
||||||
|
paused,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待同步操作
|
||||||
|
class PendingOperation {
|
||||||
|
final String id;
|
||||||
|
final SyncOperationType type;
|
||||||
|
final String endpoint;
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
final int version;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final int retryCount;
|
||||||
|
|
||||||
|
static const int maxRetryCount = 5;
|
||||||
|
|
||||||
|
const PendingOperation({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.endpoint,
|
||||||
|
required this.data,
|
||||||
|
required this.version,
|
||||||
|
required this.createdAt,
|
||||||
|
this.retryCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
PendingOperation copyWith({
|
||||||
|
String? id,
|
||||||
|
SyncOperationType? type,
|
||||||
|
String? endpoint,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
int? version,
|
||||||
|
DateTime? createdAt,
|
||||||
|
int? retryCount,
|
||||||
|
}) =>
|
||||||
|
PendingOperation(
|
||||||
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
endpoint: endpoint ?? this.endpoint,
|
||||||
|
data: data ?? this.data,
|
||||||
|
version: version ?? this.version,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
retryCount: retryCount ?? this.retryCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isExhausted => retryCount >= maxRetryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步引擎 — Web 版(内存队列,无持久化)
|
||||||
|
class SyncEngine {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final Queue<PendingOperation> _pendingQueue = Queue();
|
||||||
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||||
|
|
||||||
|
SyncStatus _status = SyncStatus.idle;
|
||||||
|
String? _lastError;
|
||||||
|
|
||||||
|
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||||
|
|
||||||
|
SyncStatus get status => _status;
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
int get pendingCount => _pendingQueue.length;
|
||||||
|
bool get isSyncing => _status == SyncStatus.syncing;
|
||||||
|
|
||||||
|
/// 入队待同步操作 — 私密日记(is_private=true)不入队
|
||||||
|
void enqueue(PendingOperation operation) {
|
||||||
|
// 防御性检查:私密日记仅保存在本地,不上传后端
|
||||||
|
final isPrivate = operation.data['is_private'] as bool? ?? false;
|
||||||
|
if (isPrivate) {
|
||||||
|
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingQueue.add(operation);
|
||||||
|
if (_status == SyncStatus.idle) {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void enqueueAll(List<PendingOperation> operations) {
|
||||||
|
for (final op in operations) {
|
||||||
|
_pendingQueue.add(op);
|
||||||
|
}
|
||||||
|
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> trySync() async {
|
||||||
|
if (_status == SyncStatus.syncing) return;
|
||||||
|
if (_pendingQueue.isEmpty) {
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectivity = Connectivity();
|
||||||
|
final result = await connectivity.checkConnectivity();
|
||||||
|
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||||
|
if (!isOnline) {
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
_lastError = '网络不可用';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = SyncStatus.syncing;
|
||||||
|
_lastError = null;
|
||||||
|
|
||||||
|
while (_pendingQueue.isNotEmpty) {
|
||||||
|
final operation = _pendingQueue.removeFirst();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _executeOperation(operation);
|
||||||
|
} on OfflineException {
|
||||||
|
_pendingQueue.addFirst(operation);
|
||||||
|
_status = SyncStatus.paused;
|
||||||
|
_lastError = '同步中断:网络不可用';
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('SyncEngine.trySync 操作失败: $e');
|
||||||
|
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||||||
|
|
||||||
|
if (retried.isExhausted) {
|
||||||
|
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingQueue.addFirst(retried);
|
||||||
|
_status = SyncStatus.error;
|
||||||
|
_lastError = '同步失败: $e';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
_lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _executeOperation(PendingOperation operation) async {
|
||||||
|
switch (operation.type) {
|
||||||
|
case SyncOperationType.create:
|
||||||
|
await _apiClient.post(operation.endpoint, data: operation.data);
|
||||||
|
case SyncOperationType.update:
|
||||||
|
await _apiClient.put(operation.endpoint, data: operation.data);
|
||||||
|
case SyncOperationType.delete:
|
||||||
|
await _apiClient.delete(operation.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_pendingQueue.clear();
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
_lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PendingOperation> get snapshot => _pendingQueue.toList();
|
||||||
|
|
||||||
|
/// Web 平台:持久化为空操作(队列仅保存在内存中)
|
||||||
|
Future<void> persistPendingQueue() async {}
|
||||||
|
|
||||||
|
/// Web 平台:恢复队列为空操作(无持久化数据)
|
||||||
|
Future<void> restorePendingQueue() async {}
|
||||||
|
|
||||||
|
void startAutoSync() {
|
||||||
|
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
|
||||||
|
final isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||||
|
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
|
||||||
|
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
|
||||||
|
trySync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_connectivitySub?.cancel();
|
||||||
|
_connectivitySub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodeJson(Map<String, dynamic> data) {
|
||||||
|
try {
|
||||||
|
return jsonEncode(data);
|
||||||
|
} catch (_) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _decodeJson(String json) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/lib/features/achievement/bloc/achievement_bloc.dart
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// 成就 BLoC — 通过 API 加载成就列表
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||||
|
|
||||||
|
// ===== 模型 =====
|
||||||
|
|
||||||
|
/// 成就数据
|
||||||
|
class Achievement {
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
|
final String? icon;
|
||||||
|
final String category;
|
||||||
|
final bool isUnlocked;
|
||||||
|
final DateTime? unlockedAt;
|
||||||
|
|
||||||
|
const Achievement({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.icon,
|
||||||
|
required this.category,
|
||||||
|
this.isUnlocked = false,
|
||||||
|
this.unlockedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
|
||||||
|
/// 成就页面状态
|
||||||
|
class AchievementState {
|
||||||
|
final List<Achievement> achievements;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const AchievementState({
|
||||||
|
this.achievements = const [],
|
||||||
|
this.isLoading = false,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get unlockedCount =>
|
||||||
|
achievements.where((a) => a.isUnlocked).length;
|
||||||
|
|
||||||
|
AchievementState copyWith({
|
||||||
|
List<Achievement>? achievements,
|
||||||
|
bool? isLoading,
|
||||||
|
String? errorMessage,
|
||||||
|
}) =>
|
||||||
|
AchievementState(
|
||||||
|
achievements: achievements ?? this.achievements,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== BLoC =====
|
||||||
|
|
||||||
|
/// 成就 BLoC — ChangeNotifier 模式
|
||||||
|
class AchievementBloc extends ChangeNotifier {
|
||||||
|
final ApiClient _api;
|
||||||
|
AchievementState _state = const AchievementState();
|
||||||
|
AchievementState get state => _state;
|
||||||
|
|
||||||
|
AchievementBloc({required ApiClient api}) : _api = api;
|
||||||
|
|
||||||
|
/// 加载成就列表
|
||||||
|
void load() {
|
||||||
|
_state = _state.copyWith(isLoading: true);
|
||||||
|
notifyListeners();
|
||||||
|
_fetchAchievements();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchAchievements() async {
|
||||||
|
try {
|
||||||
|
final response = await _api.get('/diary/achievements');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final list = body['data'] as List? ?? [];
|
||||||
|
|
||||||
|
final achievements = list.map((item) {
|
||||||
|
final m = item as Map<String, dynamic>;
|
||||||
|
return Achievement(
|
||||||
|
id: m['id'] as String,
|
||||||
|
code: m['code'] as String,
|
||||||
|
name: m['name'] as String,
|
||||||
|
description: m['description'] as String?,
|
||||||
|
icon: m['icon'] as String?,
|
||||||
|
category: m['category'] as String,
|
||||||
|
isUnlocked: m['is_unlocked'] as bool? ?? false,
|
||||||
|
unlockedAt: m['unlocked_at'] != null
|
||||||
|
? DateTime.tryParse(m['unlocked_at'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_state = _state.copyWith(isLoading: false, achievements: achievements);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AchievementBloc._fetchAchievements 失败: $e');
|
||||||
|
_state = _state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: '加载成就列表失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,252 @@
|
|||||||
import 'package:flutter/material.dart';
|
// 成就页面 — 徽章收集展示
|
||||||
|
|
||||||
class AchievementPage extends StatelessWidget {
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||||
|
import '../bloc/achievement_bloc.dart';
|
||||||
|
|
||||||
|
/// 成就页面 — 徽章收集和展示
|
||||||
|
class AchievementPage extends StatefulWidget {
|
||||||
const AchievementPage({super.key});
|
const AchievementPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AchievementPage> createState() => _AchievementPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AchievementPageState extends State<AchievementPage> {
|
||||||
|
late final AchievementBloc _bloc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_bloc = AchievementBloc(api: context.read<ApiClient>());
|
||||||
|
_bloc.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bloc.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('成就 - 占位页面'),
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('成就')),
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: _bloc,
|
||||||
|
builder: (context, _) {
|
||||||
|
final state = _bloc.state;
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.errorMessage != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(state.errorMessage!, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _bloc.load,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 进度概览
|
||||||
|
_AchievementProgressCard(
|
||||||
|
unlocked: state.unlockedCount,
|
||||||
|
total: state.achievements.length,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'全部成就',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
),
|
||||||
|
itemCount: state.achievements.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _AchievementCard(
|
||||||
|
achievement: state.achievements[index],
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成就进度卡片
|
||||||
|
class _AchievementProgressCard extends StatelessWidget {
|
||||||
|
const _AchievementProgressCard({
|
||||||
|
required this.unlocked,
|
||||||
|
required this.total,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int unlocked;
|
||||||
|
final int total;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final progress = total > 0 ? unlocked / total : 0.0;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'收集进度',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$unlocked / $total',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
minHeight: 10,
|
||||||
|
backgroundColor: colorScheme.primary.withValues(alpha: 0.15),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成就卡片
|
||||||
|
class _AchievementCard extends StatelessWidget {
|
||||||
|
const _AchievementCard({
|
||||||
|
required this.achievement,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Achievement achievement;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
side: BorderSide(
|
||||||
|
color: achievement.isUnlocked
|
||||||
|
? AppColors.accent.withValues(alpha: 0.4)
|
||||||
|
: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: achievement.isUnlocked
|
||||||
|
? AppColors.accent.withValues(alpha: 0.15)
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: achievement.isUnlocked
|
||||||
|
? Text(
|
||||||
|
achievement.icon ?? '🏆',
|
||||||
|
style: const TextStyle(fontSize: 28),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
achievement.name,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: achievement.isUnlocked
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (achievement.description != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
achievement.description!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(
|
||||||
|
alpha: achievement.isUnlocked ? 0.6 : 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
260
app/lib/features/auth/bloc/auth_bloc.dart
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// 认证 BLoC — 管理用户登录状态和认证流程
|
||||||
|
//
|
||||||
|
// 状态机: AuthInitial → AuthLoading → Unauthenticated/Authenticated
|
||||||
|
// ↕
|
||||||
|
// Authenticating → Authenticated/AuthError
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
import '../../../data/models/auth_token.dart';
|
||||||
|
import '../../../data/models/user.dart';
|
||||||
|
import '../../../data/remote/api_client.dart';
|
||||||
|
import '../../../data/repositories/auth_repository.dart';
|
||||||
|
import '../../../data/repositories/class_repository.dart';
|
||||||
|
|
||||||
|
part 'auth_event.dart';
|
||||||
|
part 'auth_state.dart';
|
||||||
|
|
||||||
|
/// 认证 BLoC — 处理所有认证相关的状态转换
|
||||||
|
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
|
final AuthRepository _authRepository;
|
||||||
|
final ClassRepository? _classRepository;
|
||||||
|
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||||
|
|
||||||
|
AuthBloc({
|
||||||
|
required AuthRepository authRepository,
|
||||||
|
ClassRepository? classRepository,
|
||||||
|
}) : _authRepository = authRepository,
|
||||||
|
_classRepository = classRepository,
|
||||||
|
super(const AuthInitial()) {
|
||||||
|
// 注册事件处理器
|
||||||
|
on<AppStarted>(_onAppStarted);
|
||||||
|
on<LoginRequested>(_onLoginRequested);
|
||||||
|
on<RegisterRequested>(_onRegisterRequested);
|
||||||
|
on<RoleSelected>(_onRoleSelected);
|
||||||
|
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
|
||||||
|
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
|
||||||
|
on<LogoutRequested>(_onLogoutRequested);
|
||||||
|
on<TokenRefreshed>(_onTokenRefreshed);
|
||||||
|
on<AuthExpired>(_onAuthExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App 启动 — 从本地存储恢复认证状态
|
||||||
|
Future<void> _onAppStarted(
|
||||||
|
AppStarted event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const AuthLoading());
|
||||||
|
try {
|
||||||
|
final user = await _authRepository.restoreAuth();
|
||||||
|
if (user != null) {
|
||||||
|
emit(Authenticated(
|
||||||
|
user: user,
|
||||||
|
needsRoleSelection: !user.hasRole,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
emit(const Unauthenticated());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('恢复认证状态失败: $e');
|
||||||
|
emit(const Unauthenticated());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户登录
|
||||||
|
Future<void> _onLoginRequested(
|
||||||
|
LoginRequested event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const Authenticating());
|
||||||
|
try {
|
||||||
|
final user = await _authRepository.login(
|
||||||
|
username: event.username,
|
||||||
|
password: event.password,
|
||||||
|
);
|
||||||
|
emit(Authenticated(
|
||||||
|
user: user,
|
||||||
|
needsRoleSelection: !user.hasRole,
|
||||||
|
));
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
_logger.w('登录失败: ${e.message}');
|
||||||
|
emit(AuthError(e.message));
|
||||||
|
} on OfflineException {
|
||||||
|
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('登录异常: $e');
|
||||||
|
emit(const AuthError('登录失败,请稍后重试'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户注册
|
||||||
|
Future<void> _onRegisterRequested(
|
||||||
|
RegisterRequested event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const Authenticating(isRegister: true));
|
||||||
|
try {
|
||||||
|
final user = await _authRepository.register(
|
||||||
|
username: event.username,
|
||||||
|
password: event.password,
|
||||||
|
displayName: event.displayName,
|
||||||
|
);
|
||||||
|
// 注册成功后需要选择角色
|
||||||
|
emit(Authenticated(
|
||||||
|
user: user,
|
||||||
|
needsRoleSelection: true,
|
||||||
|
));
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
_logger.w('注册失败: ${e.message}');
|
||||||
|
emit(AuthError(e.message));
|
||||||
|
} on OfflineException {
|
||||||
|
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('注册异常: $e');
|
||||||
|
emit(const AuthError('注册失败,请稍后重试'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户选择角色
|
||||||
|
Future<void> _onRoleSelected(
|
||||||
|
RoleSelected event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! Authenticated) return;
|
||||||
|
|
||||||
|
// 学生角色需要先经过家长同意确认(PIPL 第28条)
|
||||||
|
final needsParentalConsent = event.role == UserRoleType.student;
|
||||||
|
|
||||||
|
// 根据角色决定下一步
|
||||||
|
final needsClassCode =
|
||||||
|
event.role == UserRoleType.student || event.role == UserRoleType.parent;
|
||||||
|
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
needsRoleSelection: false,
|
||||||
|
needsParentalConsent: needsParentalConsent,
|
||||||
|
needsClassCode: needsClassCode && !needsParentalConsent,
|
||||||
|
selectedRole: event.role,
|
||||||
|
));
|
||||||
|
|
||||||
|
_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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 班级码加入
|
||||||
|
Future<void> _onClassCodeSubmitted(
|
||||||
|
ClassCodeSubmitted event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! Authenticated) return;
|
||||||
|
|
||||||
|
// 如果没有 ClassRepository(离线模式),直接跳过
|
||||||
|
final classRepo = _classRepository;
|
||||||
|
if (classRepo == null) {
|
||||||
|
_logger.w('ClassRepository 不可用,跳过班级码验证');
|
||||||
|
emit(currentState.copyWith(needsClassCode: false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(currentState.copyWith(isLoading: true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用后端 API 验证班级码并加入班级
|
||||||
|
await classRepo.joinClass(
|
||||||
|
event.classCode,
|
||||||
|
nickname: currentState.user.displayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 成功 — 清除班级码需求
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
needsClassCode: false,
|
||||||
|
isLoading: false,
|
||||||
|
));
|
||||||
|
|
||||||
|
_logger.i('班级码加入成功: ${event.classCode}');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final statusCode = e.response?.statusCode;
|
||||||
|
String errorMessage = '加入班级失败,请重试';
|
||||||
|
|
||||||
|
if (statusCode == 400) {
|
||||||
|
// 班级码无效或已过期
|
||||||
|
final body = e.response?.data;
|
||||||
|
if (body is Map && body['message'] is String) {
|
||||||
|
errorMessage = body['message'] as String;
|
||||||
|
} else {
|
||||||
|
errorMessage = '班级码无效,请检查后重新输入';
|
||||||
|
}
|
||||||
|
} else if (statusCode == 429) {
|
||||||
|
// 尝试次数过多 — 锁定
|
||||||
|
errorMessage = '尝试次数过多,请等待 30 分钟后再试';
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.w('班级码验证失败 ($statusCode): $errorMessage');
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
classCodeError: errorMessage,
|
||||||
|
));
|
||||||
|
} on OfflineException {
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
classCodeError: '网络不可用,请检查网络后重试',
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('班级码验证异常: $e');
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
classCodeError: '加入班级失败,请稍后重试',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户登出
|
||||||
|
Future<void> _onLogoutRequested(
|
||||||
|
LogoutRequested event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await _authRepository.logout();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.w('登出失败(忽略): $e');
|
||||||
|
}
|
||||||
|
emit(const Unauthenticated());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌刷新成功
|
||||||
|
Future<void> _onTokenRefreshed(
|
||||||
|
TokenRefreshed event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
_logger.d('令牌已刷新');
|
||||||
|
// 不改变当前状态,仅更新令牌
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证过期(401 拦截器触发)
|
||||||
|
Future<void> _onAuthExpired(
|
||||||
|
AuthExpired event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
_logger.w('认证过期,需要重新登录');
|
||||||
|
emit(const Unauthenticated());
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/lib/features/auth/bloc/auth_event.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// 认证事件 — AuthBloc 接收的用户操作和系统事件
|
||||||
|
|
||||||
|
part of 'auth_bloc.dart';
|
||||||
|
|
||||||
|
/// 认证事件基类 — 使用 sealed class 实现穷尽匹配
|
||||||
|
sealed class AuthEvent {
|
||||||
|
const AuthEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App 启动 — 检查本地存储的认证状态
|
||||||
|
final class AppStarted extends AuthEvent {
|
||||||
|
const AppStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户请求登录
|
||||||
|
final class LoginRequested extends AuthEvent {
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
const LoginRequested({
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户请求注册
|
||||||
|
final class RegisterRequested extends AuthEvent {
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
const RegisterRequested({
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
this.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户选择角色(注册后的角色选择步骤)
|
||||||
|
final class RoleSelected extends AuthEvent {
|
||||||
|
final UserRoleType 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 String classCode;
|
||||||
|
|
||||||
|
const ClassCodeSubmitted(this.classCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户请求登出
|
||||||
|
final class LogoutRequested extends AuthEvent {
|
||||||
|
const LogoutRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌刷新成功(由拦截器触发)
|
||||||
|
final class TokenRefreshed extends AuthEvent {
|
||||||
|
final AuthToken token;
|
||||||
|
|
||||||
|
const TokenRefreshed(this.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证失败(由 401 拦截器触发)
|
||||||
|
final class AuthExpired extends AuthEvent {
|
||||||
|
const AuthExpired();
|
||||||
|
}
|
||||||
107
app/lib/features/auth/bloc/auth_state.dart
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// 认证状态 — AuthBloc 输出的 UI 状态
|
||||||
|
|
||||||
|
part of 'auth_bloc.dart';
|
||||||
|
|
||||||
|
/// 认证状态基类 — 使用 sealed class 实现穷尽匹配
|
||||||
|
sealed class AuthState {
|
||||||
|
const AuthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始状态 — App 刚启动
|
||||||
|
final class AuthInitial extends AuthState {
|
||||||
|
const AuthInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载中 — 正在检查本地存储的认证状态
|
||||||
|
final class AuthLoading extends AuthState {
|
||||||
|
const AuthLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未认证 — 需要登录
|
||||||
|
final class Unauthenticated extends AuthState {
|
||||||
|
const Unauthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证中 — 正在登录或注册
|
||||||
|
final class Authenticating extends AuthState {
|
||||||
|
/// 是否为注册模式(显示不同的 UI 提示)
|
||||||
|
final bool isRegister;
|
||||||
|
|
||||||
|
const Authenticating({this.isRegister = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已认证 — 用户已登录
|
||||||
|
final class Authenticated extends AuthState {
|
||||||
|
final User user;
|
||||||
|
|
||||||
|
/// 是否需要角色选择(新注册用户还没有角色)
|
||||||
|
final bool needsRoleSelection;
|
||||||
|
|
||||||
|
/// 是否需要家长/监护人同意(PIPL 第28条 — 学生角色)
|
||||||
|
final bool needsParentalConsent;
|
||||||
|
|
||||||
|
/// 是否需要班级码加入(学生/家长角色)
|
||||||
|
final bool needsClassCode;
|
||||||
|
|
||||||
|
/// 是否正在加载(班级码验证中)
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
/// 班级码验证错误信息
|
||||||
|
final String? classCodeError;
|
||||||
|
|
||||||
|
/// 已选择的角色(角色选择后暂存)
|
||||||
|
final UserRoleType? selectedRole;
|
||||||
|
|
||||||
|
/// 家长同意时间戳
|
||||||
|
final DateTime? parentalConsentAt;
|
||||||
|
|
||||||
|
const Authenticated({
|
||||||
|
required this.user,
|
||||||
|
this.needsRoleSelection = false,
|
||||||
|
this.needsParentalConsent = false,
|
||||||
|
this.needsClassCode = false,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.classCodeError,
|
||||||
|
this.selectedRole,
|
||||||
|
this.parentalConsentAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Authenticated copyWith({
|
||||||
|
User? user,
|
||||||
|
bool? needsRoleSelection,
|
||||||
|
bool? needsParentalConsent,
|
||||||
|
bool? needsClassCode,
|
||||||
|
bool? isLoading,
|
||||||
|
String? classCodeError,
|
||||||
|
UserRoleType? selectedRole,
|
||||||
|
DateTime? parentalConsentAt,
|
||||||
|
}) =>
|
||||||
|
Authenticated(
|
||||||
|
user: user ?? this.user,
|
||||||
|
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
|
||||||
|
needsParentalConsent:
|
||||||
|
needsParentalConsent ?? this.needsParentalConsent,
|
||||||
|
needsClassCode: needsClassCode ?? this.needsClassCode,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
classCodeError: classCodeError,
|
||||||
|
selectedRole: selectedRole ?? this.selectedRole,
|
||||||
|
parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证错误 — 登录/注册失败
|
||||||
|
final class AuthError extends AuthState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// 是否可以重试
|
||||||
|
final bool retryable;
|
||||||
|
|
||||||
|
const AuthError(this.message, {this.retryable = true});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 需要家长授权 — 未满 14 岁用户需要家长确认
|
||||||
|
final class ParentAuthRequired extends AuthState {
|
||||||
|
final User user;
|
||||||
|
|
||||||
|
const ParentAuthRequired(this.user);
|
||||||
|
}
|
||||||
306
app/lib/features/auth/views/class_code_join_page.dart
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// 班级码加入页面 — 学生/家长通过 6 位码加入班级
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// - 6 位独立输入框,自动聚焦下一位
|
||||||
|
// - 输入完成后自动提交验证
|
||||||
|
// - 安全限制:5 次错误后锁定 30 分钟
|
||||||
|
// - 友好的状态反馈(验证中/成功/失败)
|
||||||
|
|
||||||
|
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 '../bloc/auth_bloc.dart';
|
||||||
|
|
||||||
|
/// 班级码加入页面
|
||||||
|
class ClassCodeJoinPage extends StatefulWidget {
|
||||||
|
const ClassCodeJoinPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClassCodeJoinPage> createState() => _ClassCodeJoinPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
|
||||||
|
final List<TextEditingController> _controllers = List.generate(
|
||||||
|
DesignTokens.classCodeLength,
|
||||||
|
(_) => TextEditingController(),
|
||||||
|
);
|
||||||
|
final List<FocusNode> _focusNodes = List.generate(
|
||||||
|
DesignTokens.classCodeLength,
|
||||||
|
(_) => FocusNode(),
|
||||||
|
);
|
||||||
|
|
||||||
|
int _failedAttempts = 0;
|
||||||
|
DateTime? _lockoutEndTime;
|
||||||
|
|
||||||
|
/// 当前是否被锁定
|
||||||
|
bool get _isCurrentlyLocked {
|
||||||
|
if (_lockoutEndTime == null) return false;
|
||||||
|
if (DateTime.now().isAfter(_lockoutEndTime!)) {
|
||||||
|
_lockoutEndTime = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// 自动聚焦第一个输入框
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_focusNodes[0].requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in _controllers) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
for (final f in _focusNodes) {
|
||||||
|
f.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前输入的班级码
|
||||||
|
String get _classCode => _controllers.map((c) => c.text).join();
|
||||||
|
|
||||||
|
/// 是否所有位都已输入
|
||||||
|
bool get _isComplete =>
|
||||||
|
_controllers.every((c) => c.text.isNotEmpty);
|
||||||
|
|
||||||
|
/// 清空所有输入框
|
||||||
|
void _clearInputs() {
|
||||||
|
for (final c in _controllers) {
|
||||||
|
c.clear();
|
||||||
|
}
|
||||||
|
_focusNodes[0].requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChanged(int index, String value) {
|
||||||
|
if (value.isEmpty && index > 0) {
|
||||||
|
// 退格清空 → 跳到前一位
|
||||||
|
_focusNodes[index - 1].requestFocus();
|
||||||
|
} else if (value.isNotEmpty && index < DesignTokens.classCodeLength - 1) {
|
||||||
|
// 输入字符 → 跳到下一位
|
||||||
|
_focusNodes[index + 1].requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部输入完成 → 自动提交
|
||||||
|
if (_isComplete) {
|
||||||
|
_submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (!_isComplete || _isCurrentlyLocked) return;
|
||||||
|
context.read<AuthBloc>().add(ClassCodeSubmitted(_classCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理 AuthBloc 状态变化
|
||||||
|
void _handleAuthState(BuildContext context, AuthState state) {
|
||||||
|
if (state is! Authenticated) return;
|
||||||
|
|
||||||
|
// 成功加入班级
|
||||||
|
if (!state.needsClassCode) {
|
||||||
|
context.go('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级码验证错误
|
||||||
|
final error = state.classCodeError;
|
||||||
|
if (error != null && error.isNotEmpty) {
|
||||||
|
_failedAttempts++;
|
||||||
|
|
||||||
|
// 检查是否为锁定错误
|
||||||
|
if (error.contains('30 分钟') || error.contains('尝试次数过多')) {
|
||||||
|
setState(() {
|
||||||
|
_lockoutEndTime = DateTime.now().add(
|
||||||
|
Duration(minutes: DesignTokens.classCodeLockoutMinutes),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空输入
|
||||||
|
_clearInputs();
|
||||||
|
|
||||||
|
// 显示错误提示
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(error),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return BlocListener<AuthBloc, AuthState>(
|
||||||
|
listener: _handleAuthState,
|
||||||
|
child: Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignTokens.spacing24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Spacer(flex: 2),
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
Icon(
|
||||||
|
Icons.groups_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
Text(
|
||||||
|
'加入班级',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing8),
|
||||||
|
Text(
|
||||||
|
'输入老师提供的 6 位班级码',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing48),
|
||||||
|
|
||||||
|
// 锁定状态 or 输入框
|
||||||
|
if (_isCurrentlyLocked)
|
||||||
|
_buildLockoutView(context, colorScheme)
|
||||||
|
else
|
||||||
|
_buildCodeInputs(context, colorScheme),
|
||||||
|
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
|
||||||
|
// 跳过按钮
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.go('/home'),
|
||||||
|
child: Text(
|
||||||
|
'稍后再加入',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 剩余尝试次数提示
|
||||||
|
if (!_isCurrentlyLocked && _failedAttempts > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
'剩余 ${DesignTokens.classCodeMaxAttempts - _failedAttempts} 次尝试机会',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: _failedAttempts >= 4
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Spacer(flex: 3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锁定视图 — 超过最大尝试次数后显示
|
||||||
|
Widget _buildLockoutView(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock_outline, size: 48, color: colorScheme.error),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'尝试次数过多',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'请等待 ${DesignTokens.classCodeLockoutMinutes} 分钟后再试',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 6 位班级码输入框
|
||||||
|
Widget _buildCodeInputs(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final isLoading = state is Authenticated && state.isLoading;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: List.generate(
|
||||||
|
DesignTokens.classCodeLength,
|
||||||
|
(index) => _buildCodeInput(context, index, colorScheme, isLoading),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个班级码输入框
|
||||||
|
Widget _buildCodeInput(
|
||||||
|
BuildContext context,
|
||||||
|
int index,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
bool isLoading,
|
||||||
|
) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 56,
|
||||||
|
child: TextField(
|
||||||
|
controller: _controllers[index],
|
||||||
|
focusNode: _focusNodes[index],
|
||||||
|
enabled: !isLoading,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
maxLength: 1,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
counterText: '',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) => _onChanged(index, value),
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (_isComplete) _submit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,548 @@
|
|||||||
import 'package:flutter/material.dart';
|
// 登录页面 — 用户名密码登录 + 注册切换
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// - 温暖治愈风格,使用珊瑚色主色调
|
||||||
|
// - 表单验证友好提示(面向小学生,语言简单)
|
||||||
|
// - 密码可切换可见性
|
||||||
|
// - 登录/注册模式平滑切换
|
||||||
|
|
||||||
class LoginPage extends StatelessWidget {
|
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_colors.dart';
|
||||||
|
import '../../../core/theme/app_radius.dart';
|
||||||
|
import '../bloc/auth_bloc.dart';
|
||||||
|
|
||||||
|
/// 登录/注册页面
|
||||||
|
class LoginPage extends StatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _usernameController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _displayNameController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _agreedToTerms = false;
|
||||||
|
|
||||||
|
late final AnimationController _animController;
|
||||||
|
late final Animation<double> _fadeAnim;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: DesignTokens.animNormal,
|
||||||
|
);
|
||||||
|
_fadeAnim = CurvedAnimation(
|
||||||
|
parent: _animController,
|
||||||
|
curve: DesignTokens.warmCurve,
|
||||||
|
);
|
||||||
|
_animController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_displayNameController.dispose();
|
||||||
|
_animController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
if (_isRegister && !_agreedToTerms) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请先阅读并同意用户协议和隐私政策')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister) {
|
||||||
|
context.read<AuthBloc>().add(RegisterRequested(
|
||||||
|
username: _usernameController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
displayName: _displayNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _displayNameController.text.trim(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
context.read<AuthBloc>().add(LoginRequested(
|
||||||
|
username: _usernameController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final theme = Theme.of(context);
|
||||||
body: Center(
|
final colorScheme = theme.colorScheme;
|
||||||
child: Text('登录 - 占位页面'),
|
|
||||||
|
return BlocListener<AuthBloc, AuthState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is Authenticated) {
|
||||||
|
if (state.needsRoleSelection) {
|
||||||
|
context.go('/role-selection');
|
||||||
|
} else if (state.needsClassCode) {
|
||||||
|
context.go('/class-code');
|
||||||
|
} else {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignTokens.spacing32,
|
||||||
|
),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _fadeAnim,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing32),
|
||||||
|
_buildForm(context, theme, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
_buildSubmitButton(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
_buildModeToggle(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
|
||||||
|
// 协议复选框(注册模式下显示)
|
||||||
|
if (_isRegister) ...[
|
||||||
|
_buildAgreementRow(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
],
|
||||||
|
|
||||||
|
BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is AuthError) {
|
||||||
|
return _buildErrorMessage(state.message, colorScheme);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// 社交登录分割线
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
_buildSocialLoginDivider(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
_buildSocialLoginButtons(context, colorScheme),
|
||||||
|
const SizedBox(height: DesignTokens.spacing32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final bgColor = isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0);
|
||||||
|
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [bgColor, tertiarySoft],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// 装饰圆圈
|
||||||
|
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
|
||||||
|
Positioned(right: 30, top: 20, child: _decorCircle(40, AppColors.secondary, 0.12)),
|
||||||
|
Positioned(left: 80, bottom: -20, child: _decorCircle(30, AppColors.tertiary, 0.18)),
|
||||||
|
Positioned(right: 60, bottom: 10, child: _decorCircle(20, AppColors.accent, 0.10)),
|
||||||
|
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
|
||||||
|
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Logo — 自定义笔记本图标
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppColors.accent, width: 3),
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.edit_note_rounded,
|
||||||
|
size: 44,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
// 品牌名
|
||||||
|
Text(
|
||||||
|
'暖记',
|
||||||
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing4),
|
||||||
|
// 标语 — Caveat 手写风格
|
||||||
|
Text(
|
||||||
|
'记录温暖,书写成长',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontFamily: 'Caveat',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 装饰圆圈
|
||||||
|
Widget _decorCircle(double size, Color color, double opacity) {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: opacity),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForm(BuildContext context, ThemeData theme, ColorScheme colorScheme) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: DesignTokens.animNormal,
|
||||||
|
curve: DesignTokens.warmCurve,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: DesignTokens.animNormal,
|
||||||
|
child: _isRegister
|
||||||
|
? Padding(
|
||||||
|
key: const ValueKey('display-name'),
|
||||||
|
padding: const EdgeInsets.only(bottom: DesignTokens.spacing16),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _displayNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '昵称',
|
||||||
|
hintText: '你想被叫什么名字?',
|
||||||
|
prefixIcon: Icon(Icons.face_rounded),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(key: ValueKey('display-name-hide')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _usernameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '账号',
|
||||||
|
hintText: _isRegister ? '设置一个账号名' : '输入你的账号',
|
||||||
|
prefixIcon: const Icon(Icons.person_rounded),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return '请输入账号';
|
||||||
|
}
|
||||||
|
if (value.trim().length < 3) {
|
||||||
|
return '账号至少需要 3 个字符';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '密码',
|
||||||
|
hintText: _isRegister ? '设置一个密码' : '输入你的密码',
|
||||||
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? Icons.visibility_off_rounded
|
||||||
|
: Icons.visibility_rounded,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return '请输入密码';
|
||||||
|
}
|
||||||
|
if (value.length < 6) {
|
||||||
|
return '密码至少需要 6 个字符';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmitButton(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final isLoading = state is Authenticating;
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 52,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? '注册' : '登录',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeToggle(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = !_isRegister;
|
||||||
|
});
|
||||||
|
_formKey.currentState?.reset();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
_isRegister ? '已有账号?去登录' : '没有账号?去注册',
|
||||||
|
style: TextStyle(color: colorScheme.primary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage(String message, ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: DesignTokens.spacing8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(color: colorScheme.onErrorContainer, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 社交登录分割线
|
||||||
|
Widget _buildSocialLoginDivider(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final dividerColor = colorScheme.onSurface.withValues(alpha: 0.15);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Divider(color: dividerColor)),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text('其他登录方式', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Expanded(child: Divider(color: dividerColor)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 社交登录按钮行
|
||||||
|
Widget _buildSocialLoginButtons(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 微信
|
||||||
|
_SocialButton(
|
||||||
|
bgColor: const Color(0xFF07C160),
|
||||||
|
icon: Icons.chat_bubble,
|
||||||
|
semanticLabel: '微信登录',
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('微信登录即将支持')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Apple
|
||||||
|
_SocialButton(
|
||||||
|
bgColor: const Color(0xFF1D1D1F),
|
||||||
|
icon: Icons.apple,
|
||||||
|
semanticLabel: 'Apple 登录',
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Apple 登录即将支持')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Google
|
||||||
|
_SocialButton(
|
||||||
|
bgColor: colorScheme.surface,
|
||||||
|
borderColor: colorScheme.outlineVariant,
|
||||||
|
child: const Text('G', style: TextStyle(
|
||||||
|
fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF4285F4),
|
||||||
|
)),
|
||||||
|
semanticLabel: 'Google 登录',
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Google 登录即将支持')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 协议复选框行
|
||||||
|
Widget _buildAgreementRow(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Checkbox(
|
||||||
|
value: _agreedToTerms,
|
||||||
|
onChanged: (v) => setState(() => _agreedToTerms = v ?? false),
|
||||||
|
activeColor: AppColors.accent,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
children: [
|
||||||
|
Text('我已阅读并同意', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
)),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: 打开用户协议
|
||||||
|
},
|
||||||
|
child: Text('《用户协议》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.accent, fontWeight: FontWeight.w500,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Text('和', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
)),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: 打开隐私政策
|
||||||
|
},
|
||||||
|
child: Text('《隐私政策》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.accent, fontWeight: FontWeight.w500,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 社交登录圆形按钮
|
||||||
|
class _SocialButton extends StatelessWidget {
|
||||||
|
const _SocialButton({
|
||||||
|
required this.bgColor,
|
||||||
|
required this.semanticLabel,
|
||||||
|
required this.onTap,
|
||||||
|
this.icon,
|
||||||
|
this.child,
|
||||||
|
this.borderColor,
|
||||||
|
});
|
||||||
|
final Color bgColor;
|
||||||
|
final Color? borderColor;
|
||||||
|
final IconData? icon;
|
||||||
|
final Widget? child;
|
||||||
|
final String semanticLabel;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
child: Material(
|
||||||
|
color: bgColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
side: borderColor != null
|
||||||
|
? BorderSide(color: borderColor!, width: 1.5)
|
||||||
|
: BorderSide.none,
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
customBorder: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: child ??
|
||||||
|
Icon(icon, size: 28, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
254
app/lib/features/auth/views/parental_consent_page.dart
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// 家长同意确认页面 — PIPL 第28条合规
|
||||||
|
//
|
||||||
|
// 未满 14 岁用户选择"学生"角色后,必须经过家长/监护人确认。
|
||||||
|
// 页面展示隐私政策要点,要求家长勾选同意并确认。
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/constants/design_tokens.dart';
|
||||||
|
import '../../../core/theme/app_radius.dart';
|
||||||
|
import '../bloc/auth_bloc.dart';
|
||||||
|
|
||||||
|
/// 家长同意确认页面
|
||||||
|
class ParentalConsentPage extends StatefulWidget {
|
||||||
|
const ParentalConsentPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParentalConsentPage> createState() => _ParentalConsentPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParentalConsentPageState extends State<ParentalConsentPage> {
|
||||||
|
bool _consentGiven = false;
|
||||||
|
bool _privacyPolicyAccepted = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final canProceed = _consentGiven && _privacyPolicyAccepted;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('家长/监护人确认'),
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 标题
|
||||||
|
Icon(
|
||||||
|
Icons.shield_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing12),
|
||||||
|
Text(
|
||||||
|
'儿童个人信息保护',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing8),
|
||||||
|
Text(
|
||||||
|
'根据《中华人民共和国个人信息保护法》第28条,'
|
||||||
|
'未满14周岁未成年人的个人信息处理需要取得父母或监护人的同意。',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
|
||||||
|
// 信息收集说明卡片
|
||||||
|
_buildInfoCard(
|
||||||
|
context,
|
||||||
|
icon: Icons.info_outline_rounded,
|
||||||
|
title: '我们会收集哪些信息',
|
||||||
|
items: const [
|
||||||
|
'昵称和年级(不收集真实姓名和身份证号)',
|
||||||
|
'日记内容和手写笔画',
|
||||||
|
'心情标签和照片',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing12),
|
||||||
|
|
||||||
|
// 用途说明卡片
|
||||||
|
_buildInfoCard(
|
||||||
|
context,
|
||||||
|
icon: Icons.security_rounded,
|
||||||
|
title: '信息如何保护',
|
||||||
|
items: const [
|
||||||
|
'所有数据加密存储和传输',
|
||||||
|
'仅用于日记记录和班级互动',
|
||||||
|
'不会用于商业广告或分享给第三方',
|
||||||
|
'您可以随时查阅、更正或删除孩子数据',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
|
|
||||||
|
// 同意复选框
|
||||||
|
_buildCheckbox(
|
||||||
|
value: _privacyPolicyAccepted,
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _privacyPolicyAccepted = v ?? false),
|
||||||
|
text: '我已阅读并同意《暖记隐私政策》和《儿童个人信息保护规则》',
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing4),
|
||||||
|
|
||||||
|
_buildCheckbox(
|
||||||
|
value: _consentGiven,
|
||||||
|
onChanged: (v) => setState(() => _consentGiven = v ?? false),
|
||||||
|
text: '我是该用户的家长/监护人,同意暖记收集和处理上述信息',
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing32),
|
||||||
|
|
||||||
|
// 确认按钮
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: canProceed ? _onConfirm : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: DesignTokens.spacing12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('确认同意,继续'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing8),
|
||||||
|
|
||||||
|
// 拒绝按钮
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: DesignTokens.spacing12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('不同意,返回'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required List<String> items,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: DesignTokens.spacing8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing8),
|
||||||
|
...items.map(
|
||||||
|
(item) => Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: DesignTokens.spacing4,
|
||||||
|
left: DesignTokens.spacing12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'• ',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCheckbox({
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool?> onChanged,
|
||||||
|
required String text,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => onChanged(!value),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: DesignTokens.spacing4,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
const SizedBox(width: DesignTokens.spacing4),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认同意 — 发出事件继续注册流程
|
||||||
|
void _onConfirm() {
|
||||||
|
final consentAt = DateTime.now();
|
||||||
|
context.read<AuthBloc>().add(ParentalConsentAccepted(consentAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
212
app/lib/features/auth/views/role_selection_page.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// 角色选择页面 — 注册后选择身份角色
|
||||||
|
//
|
||||||
|
// 暖记四种角色:
|
||||||
|
// - 🎓 老师 — 创建班级、布置主题、点评日记
|
||||||
|
// - ✏️ 学生 — 加入班级、写日记、查看点评
|
||||||
|
// - 👨👩👧 家长 — 查看孩子日记、管理数据
|
||||||
|
// - 📖 独立用户 — 个人日记、不加入班级
|
||||||
|
|
||||||
|
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 '../../../data/models/user.dart';
|
||||||
|
import '../bloc/auth_bloc.dart';
|
||||||
|
|
||||||
|
/// 角色卡片数据
|
||||||
|
class _RoleCard {
|
||||||
|
final UserRoleType type;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _RoleCard({
|
||||||
|
required this.type,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色选择页面
|
||||||
|
class RoleSelectionPage extends StatelessWidget {
|
||||||
|
const RoleSelectionPage({super.key});
|
||||||
|
|
||||||
|
static const _roles = [
|
||||||
|
_RoleCard(
|
||||||
|
type: UserRoleType.student,
|
||||||
|
title: '我是学生',
|
||||||
|
subtitle: '加入班级,记录每一天',
|
||||||
|
icon: Icons.school_rounded,
|
||||||
|
color: Color(0xFF81B29A), // 鼠尾草绿
|
||||||
|
),
|
||||||
|
_RoleCard(
|
||||||
|
type: UserRoleType.teacher,
|
||||||
|
title: '我是老师',
|
||||||
|
subtitle: '创建班级,陪伴学生成长',
|
||||||
|
icon: Icons.auto_stories_rounded,
|
||||||
|
color: Color(0xFFE07A5F), // 珊瑚色
|
||||||
|
),
|
||||||
|
_RoleCard(
|
||||||
|
type: UserRoleType.parent,
|
||||||
|
title: '我是家长',
|
||||||
|
subtitle: '关注孩子的成长记录',
|
||||||
|
icon: Icons.family_restroom_rounded,
|
||||||
|
color: Color(0xFFF2CC8F), // 暖金
|
||||||
|
),
|
||||||
|
_RoleCard(
|
||||||
|
type: UserRoleType.independent,
|
||||||
|
title: '独立使用',
|
||||||
|
subtitle: '个人日记,随心记录',
|
||||||
|
icon: Icons.menu_book_rounded,
|
||||||
|
color: Color(0xFFD4A5A5), // 玫瑰粉
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignTokens.spacing24,
|
||||||
|
vertical: DesignTokens.spacing32,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 标题
|
||||||
|
Text(
|
||||||
|
'你好!👋',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing8),
|
||||||
|
Text(
|
||||||
|
'告诉我你的身份,我会为你定制体验',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing32),
|
||||||
|
|
||||||
|
// 角色卡片网格
|
||||||
|
Expanded(
|
||||||
|
child: GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: DesignTokens.spacing16,
|
||||||
|
crossAxisSpacing: DesignTokens.spacing16,
|
||||||
|
childAspectRatio: 0.85,
|
||||||
|
children: _roles.map((role) => _RoleCardWidget(
|
||||||
|
role: role,
|
||||||
|
onTap: () => _selectRole(context, role.type),
|
||||||
|
)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectRole(BuildContext context, UserRoleType role) {
|
||||||
|
context.read<AuthBloc>().add(RoleSelected(role));
|
||||||
|
|
||||||
|
final state = context.read<AuthBloc>().state;
|
||||||
|
if (state is Authenticated) {
|
||||||
|
if (state.needsClassCode) {
|
||||||
|
context.go('/class-code');
|
||||||
|
} else {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色卡片组件
|
||||||
|
class _RoleCardWidget extends StatelessWidget {
|
||||||
|
final _RoleCard role;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _RoleCardWidget({
|
||||||
|
required this.role,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: role.color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
border: Border.all(
|
||||||
|
color: role.color.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 图标
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: role.color.withValues(alpha: 0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
role.icon,
|
||||||
|
size: 28,
|
||||||
|
color: role.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing12),
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
Text(
|
||||||
|
role.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: role.color,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing4),
|
||||||
|
|
||||||
|
// 副标题
|
||||||
|
Text(
|
||||||
|
role.subtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/lib/features/calendar/bloc/calendar_bloc.dart
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
|
// ===== Events =====
|
||||||
|
|
||||||
|
sealed class CalendarEvent {
|
||||||
|
const CalendarEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换到指定月份
|
||||||
|
final class CalendarMonthChanged extends CalendarEvent {
|
||||||
|
final DateTime month;
|
||||||
|
const CalendarMonthChanged(this.month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择某一天
|
||||||
|
final class CalendarDaySelected extends CalendarEvent {
|
||||||
|
final DateTime day;
|
||||||
|
const CalendarDaySelected(this.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换视图模式
|
||||||
|
final class CalendarViewModeChanged extends CalendarEvent {
|
||||||
|
final CalendarViewMode mode;
|
||||||
|
const CalendarViewModeChanged(this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
|
||||||
|
/// 日历视图模式
|
||||||
|
enum CalendarViewMode { month, week, timeline }
|
||||||
|
|
||||||
|
/// 日历状态
|
||||||
|
sealed class CalendarState {
|
||||||
|
const CalendarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CalendarInitial extends CalendarState {
|
||||||
|
const CalendarInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日历已加载
|
||||||
|
final class CalendarLoaded extends CalendarState {
|
||||||
|
final DateTime focusedMonth;
|
||||||
|
final DateTime selectedDay;
|
||||||
|
final Map<DateTime, List<JournalEntry>> journalsByDate;
|
||||||
|
final List<JournalEntry> selectedDayJournals;
|
||||||
|
final CalendarViewMode viewMode;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const CalendarLoaded({
|
||||||
|
required this.focusedMonth,
|
||||||
|
required this.selectedDay,
|
||||||
|
required this.journalsByDate,
|
||||||
|
required this.selectedDayJournals,
|
||||||
|
this.viewMode = CalendarViewMode.month,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
CalendarLoaded copyWith({
|
||||||
|
DateTime? focusedMonth,
|
||||||
|
DateTime? selectedDay,
|
||||||
|
Map<DateTime, List<JournalEntry>>? journalsByDate,
|
||||||
|
List<JournalEntry>? selectedDayJournals,
|
||||||
|
CalendarViewMode? viewMode,
|
||||||
|
bool? isLoading,
|
||||||
|
}) =>
|
||||||
|
CalendarLoaded(
|
||||||
|
focusedMonth: focusedMonth ?? this.focusedMonth,
|
||||||
|
selectedDay: selectedDay ?? this.selectedDay,
|
||||||
|
journalsByDate: journalsByDate ?? this.journalsByDate,
|
||||||
|
selectedDayJournals: selectedDayJournals ?? this.selectedDayJournals,
|
||||||
|
viewMode: viewMode ?? this.viewMode,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CalendarError extends CalendarState {
|
||||||
|
final String message;
|
||||||
|
const CalendarError(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== BLoC =====
|
||||||
|
|
||||||
|
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||||
|
final JournalRepository _journalRepo;
|
||||||
|
|
||||||
|
CalendarBloc({required JournalRepository journalRepository})
|
||||||
|
: _journalRepo = journalRepository,
|
||||||
|
super(const CalendarInitial()) {
|
||||||
|
on<CalendarMonthChanged>(_onMonthChanged);
|
||||||
|
on<CalendarDaySelected>(_onDaySelected);
|
||||||
|
on<CalendarViewModeChanged>(_onViewModeChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMonthChanged(
|
||||||
|
CalendarMonthChanged event,
|
||||||
|
Emitter<CalendarState> emit,
|
||||||
|
) async {
|
||||||
|
final currentState = state is CalendarLoaded ? state as CalendarLoaded : null;
|
||||||
|
|
||||||
|
emit(CalendarLoaded(
|
||||||
|
focusedMonth: event.month,
|
||||||
|
selectedDay: event.month,
|
||||||
|
journalsByDate: currentState?.journalsByDate ?? {},
|
||||||
|
selectedDayJournals: [],
|
||||||
|
viewMode: currentState?.viewMode ?? CalendarViewMode.month,
|
||||||
|
isLoading: true,
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 加载当月日记
|
||||||
|
final startOfMonth = DateTime(event.month.year, event.month.month, 1);
|
||||||
|
final endOfMonth = DateTime(event.month.year, event.month.month + 1, 0);
|
||||||
|
|
||||||
|
final journals = await _journalRepo.getJournals(
|
||||||
|
dateFrom: startOfMonth,
|
||||||
|
dateTo: endOfMonth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按日期索引
|
||||||
|
final byDate = <DateTime, List<JournalEntry>>{};
|
||||||
|
for (final journal in journals) {
|
||||||
|
final key = DateTime(journal.date.year, journal.date.month, journal.date.day);
|
||||||
|
byDate.putIfAbsent(key, () => []).add(journal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is CalendarLoaded) {
|
||||||
|
final current = state as CalendarLoaded;
|
||||||
|
// 根据当前选中日期查找日记,避免进入页面时空白
|
||||||
|
final dayKey = DateTime(
|
||||||
|
current.selectedDay.year,
|
||||||
|
current.selectedDay.month,
|
||||||
|
current.selectedDay.day,
|
||||||
|
);
|
||||||
|
final selectedJournals = byDate[dayKey] ?? [];
|
||||||
|
emit(current.copyWith(
|
||||||
|
journalsByDate: byDate,
|
||||||
|
selectedDayJournals: selectedJournals,
|
||||||
|
isLoading: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('CalendarBloc._onMonthChanged 失败: $e');
|
||||||
|
if (state is CalendarLoaded) {
|
||||||
|
emit((state as CalendarLoaded).copyWith(isLoading: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDaySelected(
|
||||||
|
CalendarDaySelected event,
|
||||||
|
Emitter<CalendarState> emit,
|
||||||
|
) {
|
||||||
|
if (state is! CalendarLoaded) return;
|
||||||
|
final current = state as CalendarLoaded;
|
||||||
|
|
||||||
|
final dayKey = DateTime(event.day.year, event.day.month, event.day.day);
|
||||||
|
final dayJournals = current.journalsByDate[dayKey] ?? [];
|
||||||
|
|
||||||
|
emit(current.copyWith(
|
||||||
|
selectedDay: event.day,
|
||||||
|
selectedDayJournals: dayJournals,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onViewModeChanged(
|
||||||
|
CalendarViewModeChanged event,
|
||||||
|
Emitter<CalendarState> emit,
|
||||||
|
) {
|
||||||
|
if (state is! CalendarLoaded) return;
|
||||||
|
emit((state as CalendarLoaded).copyWith(viewMode: event.mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
690
app/lib/features/calendar/views/monthly_page.dart
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
|
||||||
|
// 对齐 Open Design 原型稿 screens/monthly.html
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_element.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
|
/// 月度概览页面
|
||||||
|
class MonthlyPage extends StatefulWidget {
|
||||||
|
final JournalRepository? journalRepository;
|
||||||
|
const MonthlyPage({super.key, this.journalRepository});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MonthlyPage> createState() => _MonthlyPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MonthlyPageState extends State<MonthlyPage> {
|
||||||
|
late DateTime _focusedMonth;
|
||||||
|
List<JournalEntry> _journals = [];
|
||||||
|
int _photoCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusedMonth = DateTime.now();
|
||||||
|
_loadJournals();
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalRepository get _repo =>
|
||||||
|
widget.journalRepository ?? context.read<JournalRepository>();
|
||||||
|
|
||||||
|
Future<void> _loadJournals() async {
|
||||||
|
final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
|
||||||
|
// 下月 1 号作为上界(开区间),所以用 month+1
|
||||||
|
final nextMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 1);
|
||||||
|
final journals = await _repo.getJournals(
|
||||||
|
dateFrom: firstDay,
|
||||||
|
dateTo: nextMonth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 统计照片元素数量
|
||||||
|
var photoCount = 0;
|
||||||
|
for (final journal in journals) {
|
||||||
|
try {
|
||||||
|
final elements = await _repo.getElements(journal.id);
|
||||||
|
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('MonthlyPage: 加载日记 ${journal.id} 元素失败: $e');
|
||||||
|
// 单个日记加载元素失败不影响整体统计
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_journals = journals;
|
||||||
|
_photoCount = photoCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToPreviousMonth() {
|
||||||
|
setState(() {
|
||||||
|
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
|
||||||
|
});
|
||||||
|
_loadJournals();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToNextMonth() {
|
||||||
|
setState(() {
|
||||||
|
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
|
||||||
|
});
|
||||||
|
_loadJournals();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 月头部导航
|
||||||
|
_MonthHeader(
|
||||||
|
month: _focusedMonth,
|
||||||
|
onPrevious: _goToPreviousMonth,
|
||||||
|
onNext: _goToNextMonth,
|
||||||
|
),
|
||||||
|
// 可滚动内容区
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 心情色彩月历
|
||||||
|
_MoodCalendar(month: _focusedMonth, journals: _journals),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// 月度统计 2x2
|
||||||
|
_MonthSummary(journals: _journals, photoCount: _photoCount),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// 精选日记
|
||||||
|
_Highlights(journals: _journals),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 月头部导航 =====
|
||||||
|
|
||||||
|
class _MonthHeader extends StatelessWidget {
|
||||||
|
const _MonthHeader({
|
||||||
|
required this.month,
|
||||||
|
required this.onPrevious,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime month;
|
||||||
|
final VoidCallback onPrevious;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final title = '${month.year}年${month.month}月';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_NavButton(
|
||||||
|
icon: Icons.chevron_left_rounded,
|
||||||
|
onTap: onPrevious,
|
||||||
|
borderColor: colorScheme.outline,
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_NavButton(
|
||||||
|
icon: Icons.chevron_right_rounded,
|
||||||
|
onTap: onNext,
|
||||||
|
borderColor: colorScheme.outline,
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 圆形导航按钮 (44px 触摸目标)
|
||||||
|
class _NavButton extends StatelessWidget {
|
||||||
|
const _NavButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
required this.borderColor,
|
||||||
|
required this.foregroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Color borderColor;
|
||||||
|
final Color foregroundColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onTap,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
side: BorderSide(color: borderColor, width: 1.5),
|
||||||
|
foregroundColor: foregroundColor,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 心情色彩月历 =====
|
||||||
|
|
||||||
|
class _MoodCalendar extends StatelessWidget {
|
||||||
|
const _MoodCalendar({required this.month, required this.journals});
|
||||||
|
|
||||||
|
final DateTime month;
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
|
// 心情 → emoji(对齐 Mood 枚举: happy/calm/sad/angry/thinking)
|
||||||
|
static const _moodEmojis = <Mood, String>{
|
||||||
|
Mood.happy: '😊',
|
||||||
|
Mood.calm: '😌',
|
||||||
|
Mood.sad: '😢',
|
||||||
|
Mood.angry: '😡',
|
||||||
|
Mood.thinking: '🤔',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 心情 → 背景色
|
||||||
|
static const _moodBgColors = <Mood, Color>{
|
||||||
|
Mood.happy: AppColors.secondarySoftLight,
|
||||||
|
Mood.angry: AppColors.roseSoftLight,
|
||||||
|
Mood.calm: AppColors.tertiarySoftLight,
|
||||||
|
Mood.sad: Color(0xFFD4DDE8),
|
||||||
|
Mood.thinking: Color(0xFFE8E4E0),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
boxShadow: AppShadows.soft(context),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 星期标题行
|
||||||
|
_WeekdayRow(colorScheme: colorScheme),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 7列网格
|
||||||
|
_buildGrid(context, now),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGrid(BuildContext context, DateTime now) {
|
||||||
|
final firstDay = DateTime(month.year, month.month, 1);
|
||||||
|
// 周一=0 → 偏移量; weekday 返回 1(周一)..7(周日)
|
||||||
|
final startOffset = firstDay.weekday - 1; // 周一开头
|
||||||
|
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||||
|
|
||||||
|
// 按日期建索引:day → JournalEntry
|
||||||
|
final journalByDay = <int, JournalEntry>{};
|
||||||
|
for (final j in journals) {
|
||||||
|
if (j.date.year == month.year && j.date.month == month.month) {
|
||||||
|
journalByDay[j.date.day] = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final cells = <Widget>[];
|
||||||
|
|
||||||
|
// 空白填充
|
||||||
|
for (var i = 0; i < startOffset; i++) {
|
||||||
|
cells.add(const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var d = 1; d <= daysInMonth; d++) {
|
||||||
|
final isToday = now.year == month.year &&
|
||||||
|
now.month == month.month &&
|
||||||
|
now.day == d;
|
||||||
|
|
||||||
|
final entry = journalByDay[d];
|
||||||
|
final mood = entry?.mood;
|
||||||
|
final bgColor =
|
||||||
|
mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent;
|
||||||
|
final emoji = mood != null ? (_moodEmojis[mood] ?? '') : '';
|
||||||
|
|
||||||
|
cells.add(
|
||||||
|
_MoodCell(
|
||||||
|
day: d,
|
||||||
|
emoji: emoji,
|
||||||
|
bgColor: bgColor,
|
||||||
|
isToday: isToday,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 7,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 3,
|
||||||
|
crossAxisSpacing: 3,
|
||||||
|
childAspectRatio: 1,
|
||||||
|
children: cells,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 星期标题行
|
||||||
|
class _WeekdayRow extends StatelessWidget {
|
||||||
|
const _WeekdayRow({required this.colorScheme});
|
||||||
|
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
return Row(
|
||||||
|
children: weekdays.map((day) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
day,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个心情格子
|
||||||
|
class _MoodCell extends StatelessWidget {
|
||||||
|
const _MoodCell({
|
||||||
|
required this.day,
|
||||||
|
required this.emoji,
|
||||||
|
required this.bgColor,
|
||||||
|
required this.isToday,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int day;
|
||||||
|
final String emoji;
|
||||||
|
final Color bgColor;
|
||||||
|
final bool isToday;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: 选择日期,跳转详情
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: isToday
|
||||||
|
? Border.all(color: AppColors.accent, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$day',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: isToday ? FontWeight.w700 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 1),
|
||||||
|
Text(
|
||||||
|
emoji,
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 月度统计 2x2 =====
|
||||||
|
|
||||||
|
class _MonthSummary extends StatelessWidget {
|
||||||
|
const _MonthSummary({required this.journals, required this.photoCount});
|
||||||
|
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
final int photoCount;
|
||||||
|
|
||||||
|
/// 计算最长连续写日记天数
|
||||||
|
int _calcLongestStreak() {
|
||||||
|
if (journals.isEmpty) return 0;
|
||||||
|
final days = journals.map((j) => j.date.day).toSet().toList()..sort();
|
||||||
|
int longest = 1;
|
||||||
|
int current = 1;
|
||||||
|
for (var i = 1; i < days.length; i++) {
|
||||||
|
if (days[i] == days[i - 1] + 1) {
|
||||||
|
current++;
|
||||||
|
if (current > longest) longest = current;
|
||||||
|
} else {
|
||||||
|
current = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算"好心情"(happy/calm)占比
|
||||||
|
String _calcGoodMoodPercent() {
|
||||||
|
if (journals.isEmpty) return '0%';
|
||||||
|
final good = journals.where(
|
||||||
|
(j) => j.mood == Mood.happy || j.mood == Mood.calm,
|
||||||
|
).length;
|
||||||
|
return '${((good / journals.length) * 100).round()}%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'本月总结',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
children: [
|
||||||
|
_StatCard(
|
||||||
|
icon: '📝',
|
||||||
|
value: '${journals.length}',
|
||||||
|
label: '日记篇数',
|
||||||
|
bgColor: AppColors.tertiarySoftLight,
|
||||||
|
valueColor: const Color(0xFFB8860B),
|
||||||
|
),
|
||||||
|
_StatCard(
|
||||||
|
icon: '🔥',
|
||||||
|
value: '${_calcLongestStreak()}',
|
||||||
|
label: '最长连续',
|
||||||
|
bgColor: AppColors.secondarySoftLight,
|
||||||
|
valueColor: const Color(0xFF2D7D46),
|
||||||
|
),
|
||||||
|
_StatCard(
|
||||||
|
icon: '😊',
|
||||||
|
value: _calcGoodMoodPercent(),
|
||||||
|
label: '好心情占比',
|
||||||
|
bgColor: AppColors.roseSoftLight,
|
||||||
|
valueColor: const Color(0xFF9B4D4D),
|
||||||
|
),
|
||||||
|
_StatCard(
|
||||||
|
icon: '📸',
|
||||||
|
value: '$photoCount',
|
||||||
|
label: '照片数量',
|
||||||
|
bgColor: const Color(0xFFD4DDE8),
|
||||||
|
valueColor: const Color(0xFF4A6B8A),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单张统计小卡片
|
||||||
|
class _StatCard extends StatelessWidget {
|
||||||
|
const _StatCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.value,
|
||||||
|
required this.label,
|
||||||
|
required this.bgColor,
|
||||||
|
required this.valueColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String icon;
|
||||||
|
final String value;
|
||||||
|
final String label;
|
||||||
|
final Color bgColor;
|
||||||
|
final Color valueColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(icon, style: const TextStyle(fontSize: 24)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: valueColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 精选日记 =====
|
||||||
|
|
||||||
|
class _Highlights extends StatelessWidget {
|
||||||
|
const _Highlights({required this.journals});
|
||||||
|
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
|
static const _badgeConfig = <Mood, ({String badge, Color bg, Color fg})>{
|
||||||
|
Mood.happy: (badge: '最佳心情', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
|
||||||
|
Mood.calm: (badge: '平静时光', bg: AppColors.tertiarySoftLight, fg: Color(0xFFB8860B)),
|
||||||
|
Mood.sad: (badge: '真实记录', bg: Color(0xFFD4DDE8), fg: Color(0xFF4A6B8A)),
|
||||||
|
Mood.angry: (badge: '真情流露', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
|
||||||
|
Mood.thinking: (badge: '深度思考', bg: AppColors.secondarySoftLight, fg: Color(0xFF2D7D46)),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
// 按日期降序取前 3 篇
|
||||||
|
final top = List<JournalEntry>.from(journals)
|
||||||
|
..sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
final highlights = top.take(3).toList();
|
||||||
|
|
||||||
|
if (highlights.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'本月精选',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...highlights.map((entry) {
|
||||||
|
final mood = entry.mood;
|
||||||
|
final emoji = _MoodCalendar._moodEmojis[mood] ?? '📝';
|
||||||
|
final emojiBg = _MoodCalendar._moodBgColors[mood] ?? AppColors.tertiarySoftLight;
|
||||||
|
final cfg = _badgeConfig[mood] ??
|
||||||
|
(badge: '日记', bg: AppColors.tertiarySoftLight, fg: const Color(0xFFB8860B));
|
||||||
|
final dateStr = '${entry.date.month}月${entry.date.day}日';
|
||||||
|
|
||||||
|
return _HighlightCard(
|
||||||
|
emoji: emoji,
|
||||||
|
emojiBg: emojiBg,
|
||||||
|
date: dateStr,
|
||||||
|
title: entry.title,
|
||||||
|
badge: cfg.badge,
|
||||||
|
badgeBg: cfg.bg,
|
||||||
|
badgeFg: cfg.fg,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单张精选日记卡片
|
||||||
|
class _HighlightCard extends StatelessWidget {
|
||||||
|
const _HighlightCard({
|
||||||
|
required this.emoji,
|
||||||
|
required this.emojiBg,
|
||||||
|
required this.date,
|
||||||
|
required this.title,
|
||||||
|
required this.badge,
|
||||||
|
required this.badgeBg,
|
||||||
|
required this.badgeFg,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String emoji;
|
||||||
|
final Color emojiBg;
|
||||||
|
final String date;
|
||||||
|
final String title;
|
||||||
|
final String badge;
|
||||||
|
final Color badgeBg;
|
||||||
|
final Color badgeFg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
boxShadow: AppShadows.soft(context),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 48x48 emoji 圆圈
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: emojiBg,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(emoji, style: const TextStyle(fontSize: 24)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// 标题 + 日期
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// badge pill
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: badgeBg,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badge,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: badgeFg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
680
app/lib/features/calendar/views/weekly_page.dart
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
|
||||||
|
// 对齐 Open Design 原型稿 screens/weekly.html
|
||||||
|
// 接入 JournalRepository 加载真实数据
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||||
|
import 'package:nuanji_app/core/utils/mood_utils.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
|
/// 周概览页面
|
||||||
|
class WeeklyPage extends StatefulWidget {
|
||||||
|
const WeeklyPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeeklyPage> createState() => _WeeklyPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeeklyPageState extends State<WeeklyPage> {
|
||||||
|
late DateTime _focusedWeekStart;
|
||||||
|
List<JournalEntry> _journals = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final now = DateTime.now();
|
||||||
|
_focusedWeekStart = _startOfWeek(now);
|
||||||
|
_loadWeekData();
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalRepository get _repo => context.read<JournalRepository>();
|
||||||
|
|
||||||
|
/// 获取某天的周一日期
|
||||||
|
DateTime _startOfWeek(DateTime date) {
|
||||||
|
return date.subtract(Duration(days: date.weekday - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeekData() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final weekEnd = _focusedWeekStart.add(const Duration(days: 7));
|
||||||
|
final journals = await _repo.getJournals(
|
||||||
|
dateFrom: _focusedWeekStart,
|
||||||
|
dateTo: weekEnd,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_journals = journals;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('WeeklyPage._loadWeekData 失败: $e');
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToPreviousWeek() {
|
||||||
|
setState(() {
|
||||||
|
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
|
||||||
|
});
|
||||||
|
_loadWeekData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToNextWeek() {
|
||||||
|
setState(() {
|
||||||
|
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
|
||||||
|
});
|
||||||
|
_loadWeekData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表
|
||||||
|
Map<int, List<JournalEntry>> get _journalsByWeekday {
|
||||||
|
final map = <int, List<JournalEntry>>{};
|
||||||
|
for (final j in _journals) {
|
||||||
|
// 判断日记日期是否在本周范围内
|
||||||
|
final dayKey = j.date.difference(_focusedWeekStart).inDays;
|
||||||
|
if (dayKey >= 0 && dayKey < 7) {
|
||||||
|
final weekday = dayKey + 1; // 1=周一, 7=周日
|
||||||
|
(map[weekday] ??= []).add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 周头部导航
|
||||||
|
_WeekHeader(
|
||||||
|
weekStart: _focusedWeekStart,
|
||||||
|
onPrevious: _goToPreviousWeek,
|
||||||
|
onNext: _goToNextWeek,
|
||||||
|
),
|
||||||
|
// 可滚动内容区
|
||||||
|
Expanded(
|
||||||
|
child: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 7天条目(真实数据)
|
||||||
|
_WeekStrip(
|
||||||
|
weekStart: _focusedWeekStart,
|
||||||
|
journalsByWeekday: _journalsByWeekday,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// 本周总结(真实数据)
|
||||||
|
_WeekSummary(journals: _journals),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// 每日日记卡片(真实数据)
|
||||||
|
..._buildDayCards(theme, colorScheme),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
|
||||||
|
final byWeekday = _journalsByWeekday;
|
||||||
|
final cards = <Widget>[];
|
||||||
|
final weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
|
||||||
|
// 按日期倒序生成卡片(最新的在上面)
|
||||||
|
for (var i = 6; i >= 0; i--) {
|
||||||
|
final weekday = i + 1;
|
||||||
|
final dayJournals = byWeekday[weekday];
|
||||||
|
if (dayJournals == null || dayJournals.isEmpty) continue;
|
||||||
|
|
||||||
|
final day = _focusedWeekStart.add(Duration(days: i));
|
||||||
|
final first = dayJournals.first;
|
||||||
|
|
||||||
|
cards.add(_DayCard(
|
||||||
|
weekday: weekNames[i],
|
||||||
|
date: '${day.month}月${day.day}日',
|
||||||
|
moodEmoji: moodToEmoji(first.mood),
|
||||||
|
weatherEmoji: _weatherEmoji(first.weather),
|
||||||
|
body: first.contentExcerpt ?? first.title,
|
||||||
|
tags: first.tags.take(2).map((tag) {
|
||||||
|
// 根据标签内容选择颜色
|
||||||
|
return (tag, AppColors.secondarySoftLight, const Color(0xFF2D7D46));
|
||||||
|
}).toList(),
|
||||||
|
photoEmoji: dayJournals.any((j) => j.contentExcerpt != null && j.contentExcerpt!.contains('📷'))
|
||||||
|
? '📷'
|
||||||
|
: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无日记时显示空状态
|
||||||
|
if (cards.isEmpty) {
|
||||||
|
return [
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.edit_note_rounded, size: 48,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('这周还没有日记', style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _weatherEmoji(Weather weather) => switch (weather) {
|
||||||
|
Weather.sunny => '☀️',
|
||||||
|
Weather.cloudy => '⛅',
|
||||||
|
Weather.rainy => '🌧️',
|
||||||
|
Weather.snowy => '❄️',
|
||||||
|
Weather.windy => '💨',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 周头部导航 =====
|
||||||
|
|
||||||
|
class _WeekHeader extends StatelessWidget {
|
||||||
|
const _WeekHeader({
|
||||||
|
required this.weekStart,
|
||||||
|
required this.onPrevious,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime weekStart;
|
||||||
|
final VoidCallback onPrevious;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
// 格式化: "2026年6月 第1周"
|
||||||
|
final monthNames = [
|
||||||
|
'', '1月', '2月', '3月', '4月', '5月', '6月',
|
||||||
|
'7月', '8月', '9月', '10月', '11月', '12月',
|
||||||
|
];
|
||||||
|
final title =
|
||||||
|
'${weekStart.year}年${monthNames[weekStart.month]} 第${_weekOfMonth(weekStart)}周';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 左右箭头导航按钮
|
||||||
|
_NavButton(
|
||||||
|
icon: Icons.chevron_left_rounded,
|
||||||
|
onTap: onPrevious,
|
||||||
|
borderColor: colorScheme.outline,
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_NavButton(
|
||||||
|
icon: Icons.chevron_right_rounded,
|
||||||
|
onTap: onNext,
|
||||||
|
borderColor: colorScheme.outline,
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算是当月第几周
|
||||||
|
int _weekOfMonth(DateTime date) {
|
||||||
|
final firstDay = DateTime(date.year, date.month, 1);
|
||||||
|
final offset = firstDay.weekday - 1;
|
||||||
|
return ((date.day + offset) / 7).ceil();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 圆形导航按钮 (44px 触摸目标)
|
||||||
|
class _NavButton extends StatelessWidget {
|
||||||
|
const _NavButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
required this.borderColor,
|
||||||
|
required this.foregroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Color borderColor;
|
||||||
|
final Color foregroundColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onTap,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
side: BorderSide(color: borderColor, width: 1.5),
|
||||||
|
foregroundColor: foregroundColor,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 7天条目(真实数据)=====
|
||||||
|
|
||||||
|
class _WeekStrip extends StatelessWidget {
|
||||||
|
const _WeekStrip({
|
||||||
|
required this.weekStart,
|
||||||
|
required this.journalsByWeekday,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime weekStart;
|
||||||
|
final Map<int, List<JournalEntry>> journalsByWeekday;
|
||||||
|
|
||||||
|
static const _weekNames = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: List.generate(7, (i) {
|
||||||
|
final day = weekStart.add(Duration(days: i));
|
||||||
|
final weekday = i + 1;
|
||||||
|
final isToday = day.year == now.year &&
|
||||||
|
day.month == now.month &&
|
||||||
|
day.day == now.day;
|
||||||
|
final dayJournals = journalsByWeekday[weekday] ?? [];
|
||||||
|
final hasEntry = dayJournals.isNotEmpty;
|
||||||
|
final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·';
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: 选择某天后刷新下方日记卡片
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isToday ? AppColors.accent : null,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 周名
|
||||||
|
Text(
|
||||||
|
_weekNames[i],
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isToday
|
||||||
|
? const Color(0xFFFFF8F0).withValues(alpha: 0.85)
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 日期数字
|
||||||
|
Text(
|
||||||
|
'${day.day}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isToday
|
||||||
|
? const Color(0xFFFFF8F0) // accent-on
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 心情 emoji
|
||||||
|
Text(moodEmoji, style: TextStyle(
|
||||||
|
fontSize: hasEntry ? 16 : 14,
|
||||||
|
color: hasEntry ? null : colorScheme.onSurface.withValues(alpha: 0.2),
|
||||||
|
)),
|
||||||
|
// 有日记: 日期下方4px小圆点
|
||||||
|
if (hasEntry && !isToday)
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasEntry && isToday)
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Color(0xFFFFF8F0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 本周总结卡片(真实数据)=====
|
||||||
|
|
||||||
|
class _WeekSummary extends StatelessWidget {
|
||||||
|
const _WeekSummary({required this.journals});
|
||||||
|
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
// 统计真实数据
|
||||||
|
final recordDays = journals.map((j) => j.date.day).toSet().length;
|
||||||
|
final journalCount = journals.length;
|
||||||
|
// 统计贴纸元素 — 从日记标签中估算(Phase 1 简化)
|
||||||
|
final stickerCount = journals.fold<int>(
|
||||||
|
0, (sum, j) => sum + j.tags.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
boxShadow: AppShadows.soft(context),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'本周总结',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 3个统计数字
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_SummaryItem(
|
||||||
|
value: '$recordDays',
|
||||||
|
label: '记录天数',
|
||||||
|
valueColor: AppColors.accent,
|
||||||
|
),
|
||||||
|
_SummaryItem(
|
||||||
|
value: '$journalCount',
|
||||||
|
label: '日记篇数',
|
||||||
|
valueColor: AppColors.secondary,
|
||||||
|
),
|
||||||
|
_SummaryItem(
|
||||||
|
value: '$stickerCount',
|
||||||
|
label: '使用标签',
|
||||||
|
valueColor: AppColors.tertiary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 心情分布条
|
||||||
|
_MoodDistributionBar(journals: journals),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 心情分布条 — 从日记数据计算各心情占比
|
||||||
|
class _MoodDistributionBar extends StatelessWidget {
|
||||||
|
const _MoodDistributionBar({required this.journals});
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
|
static const _moodConfig = [
|
||||||
|
(Mood.happy, AppColors.secondary),
|
||||||
|
(Mood.calm, AppColors.tertiary),
|
||||||
|
(Mood.sad, Color(0xFF5B7DB1)),
|
||||||
|
(Mood.angry, AppColors.accent),
|
||||||
|
(Mood.thinking, Color(0xFF8B7E74)),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (journals.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计各心情数量
|
||||||
|
final counts = <Mood, int>{};
|
||||||
|
for (final j in journals) {
|
||||||
|
counts[j.mood] = (counts[j.mood] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: _moodConfig.where((c) => counts[c.$1] != null).map((config) {
|
||||||
|
final count = counts[config.$1]!;
|
||||||
|
return Expanded(
|
||||||
|
flex: count,
|
||||||
|
child: Container(
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: config.$2,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个统计项
|
||||||
|
class _SummaryItem extends StatelessWidget {
|
||||||
|
const _SummaryItem({
|
||||||
|
required this.value,
|
||||||
|
required this.label,
|
||||||
|
required this.valueColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final String label;
|
||||||
|
final Color valueColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.displayFont,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: valueColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 每日日记卡片 =====
|
||||||
|
|
||||||
|
class _DayCard extends StatelessWidget {
|
||||||
|
const _DayCard({
|
||||||
|
required this.weekday,
|
||||||
|
required this.date,
|
||||||
|
required this.moodEmoji,
|
||||||
|
required this.weatherEmoji,
|
||||||
|
required this.body,
|
||||||
|
required this.tags,
|
||||||
|
this.photoEmoji,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String weekday;
|
||||||
|
final String date;
|
||||||
|
final String moodEmoji;
|
||||||
|
final String weatherEmoji;
|
||||||
|
final String body;
|
||||||
|
final List<(String, Color, Color)> tags; // (label, bg, fg)
|
||||||
|
final String? photoEmoji;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
boxShadow: AppShadows.soft(context),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 头部: 日期 + 心情/weather emoji
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$weekday · $date',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$moodEmoji $weatherEmoji',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 正文预览 (3行截断)
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
// 标签 pills
|
||||||
|
if (tags.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: tags.map((tag) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tag.$2,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tag.$1,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: tag.$3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// 照片占位
|
||||||
|
if (photoEmoji != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(photoEmoji!, style: const TextStyle(fontSize: 24)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
425
app/lib/features/class_/bloc/class_bloc.dart
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
// 班级 BLoC — 通过 ClassRepository 管理班级数据
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/models/school_class.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
|
// ===== Events =====
|
||||||
|
|
||||||
|
sealed class ClassEvent {
|
||||||
|
const ClassEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassLoadMyClasses extends ClassEvent {
|
||||||
|
const ClassLoadMyClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassSelected extends ClassEvent {
|
||||||
|
final String classId;
|
||||||
|
const ClassSelected(this.classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassLoadMembers extends ClassEvent {
|
||||||
|
final String classId;
|
||||||
|
const ClassLoadMembers(this.classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassLoadDiaryWall extends ClassEvent {
|
||||||
|
final String classId;
|
||||||
|
const ClassLoadDiaryWall(this.classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassLoadTopics extends ClassEvent {
|
||||||
|
final String classId;
|
||||||
|
const ClassLoadTopics(this.classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassLoadComments extends ClassEvent {
|
||||||
|
final String journalId;
|
||||||
|
const ClassLoadComments(this.journalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassCreate extends ClassEvent {
|
||||||
|
final String name;
|
||||||
|
final String? schoolName;
|
||||||
|
const ClassCreate({required this.name, this.schoolName});
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TopicAssign extends ClassEvent {
|
||||||
|
final String classId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
const TopicAssign({
|
||||||
|
required this.classId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.dueDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassJoin extends ClassEvent {
|
||||||
|
final String classCode;
|
||||||
|
final String? nickname;
|
||||||
|
const ClassJoin({required this.classCode, this.nickname});
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CommentCreate extends ClassEvent {
|
||||||
|
final String journalId;
|
||||||
|
final String content;
|
||||||
|
const CommentCreate({required this.journalId, required this.content});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
|
||||||
|
class ClassMember {
|
||||||
|
final String userId;
|
||||||
|
final String role;
|
||||||
|
final String? nickname;
|
||||||
|
final DateTime joinedAt;
|
||||||
|
const ClassMember({required this.userId, required this.role, this.nickname, required this.joinedAt});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TopicAssignment {
|
||||||
|
final String id;
|
||||||
|
final String classId;
|
||||||
|
final String teacherId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final bool isActive;
|
||||||
|
const TopicAssignment({required this.id, required this.classId, required this.teacherId, required this.title, this.description, this.dueDate, this.isActive = true});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Comment {
|
||||||
|
final String id;
|
||||||
|
final String journalId;
|
||||||
|
final String authorId;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
const Comment({required this.id, required this.journalId, required this.authorId, required this.content, required this.createdAt});
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ClassState {
|
||||||
|
const ClassState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassInitial extends ClassState {
|
||||||
|
const ClassInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassLoading extends ClassState {
|
||||||
|
const ClassLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassListLoaded extends ClassState {
|
||||||
|
final List<SchoolClass> classes;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
const ClassListLoaded({this.classes = const [], this.isLoading = false, this.error});
|
||||||
|
ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading, String? error, bool clearError = false}) =>
|
||||||
|
ClassListLoaded(
|
||||||
|
classes: classes ?? this.classes,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassDetailLoaded extends ClassState {
|
||||||
|
final SchoolClass classInfo;
|
||||||
|
final List<ClassMember> members;
|
||||||
|
final List<JournalEntry> diaryWall;
|
||||||
|
final List<TopicAssignment> topics;
|
||||||
|
final List<Comment> comments;
|
||||||
|
final bool isLoadingWall;
|
||||||
|
final bool isLoadingMembers;
|
||||||
|
final String? selectedJournalId;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const ClassDetailLoaded({
|
||||||
|
required this.classInfo,
|
||||||
|
this.members = const [],
|
||||||
|
this.diaryWall = const [],
|
||||||
|
this.topics = const [],
|
||||||
|
this.comments = const [],
|
||||||
|
this.isLoadingWall = false,
|
||||||
|
this.isLoadingMembers = false,
|
||||||
|
this.selectedJournalId,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
ClassDetailLoaded copyWith({
|
||||||
|
SchoolClass? classInfo,
|
||||||
|
List<ClassMember>? members,
|
||||||
|
List<JournalEntry>? diaryWall,
|
||||||
|
List<TopicAssignment>? topics,
|
||||||
|
List<Comment>? comments,
|
||||||
|
bool? isLoadingWall,
|
||||||
|
bool? isLoadingMembers,
|
||||||
|
String? selectedJournalId,
|
||||||
|
bool clearSelectedJournal = false,
|
||||||
|
String? error,
|
||||||
|
bool clearError = false,
|
||||||
|
}) =>
|
||||||
|
ClassDetailLoaded(
|
||||||
|
classInfo: classInfo ?? this.classInfo,
|
||||||
|
members: members ?? this.members,
|
||||||
|
diaryWall: diaryWall ?? this.diaryWall,
|
||||||
|
topics: topics ?? this.topics,
|
||||||
|
comments: comments ?? this.comments,
|
||||||
|
isLoadingWall: isLoadingWall ?? this.isLoadingWall,
|
||||||
|
isLoadingMembers: isLoadingMembers ?? this.isLoadingMembers,
|
||||||
|
selectedJournalId: clearSelectedJournal ? null : (selectedJournalId ?? this.selectedJournalId),
|
||||||
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ClassError extends ClassState {
|
||||||
|
final String message;
|
||||||
|
const ClassError(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== BLoC =====
|
||||||
|
|
||||||
|
class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
||||||
|
final ClassRepository _classRepo;
|
||||||
|
final JournalRepository _journalRepo;
|
||||||
|
|
||||||
|
ClassBloc({
|
||||||
|
required ClassRepository classRepository,
|
||||||
|
required JournalRepository journalRepository,
|
||||||
|
}) : _classRepo = classRepository,
|
||||||
|
_journalRepo = journalRepository,
|
||||||
|
super(const ClassInitial()) {
|
||||||
|
on<ClassLoadMyClasses>(_onLoadMyClasses);
|
||||||
|
on<ClassSelected>(_onClassSelected);
|
||||||
|
on<ClassLoadMembers>(_onLoadMembers);
|
||||||
|
on<ClassLoadDiaryWall>(_onLoadDiaryWall);
|
||||||
|
on<ClassLoadTopics>(_onLoadTopics);
|
||||||
|
on<ClassLoadComments>(_onLoadComments);
|
||||||
|
on<ClassCreate>(_onCreateClass);
|
||||||
|
on<TopicAssign>(_onTopicAssign);
|
||||||
|
on<ClassJoin>(_onJoinClass);
|
||||||
|
on<CommentCreate>(_onCommentCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadMyClasses(
|
||||||
|
ClassLoadMyClasses event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const ClassListLoaded(isLoading: true));
|
||||||
|
try {
|
||||||
|
final classes = await _classRepo.getMyClasses();
|
||||||
|
emit(ClassListLoaded(classes: classes));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadMyClasses 失败: $e');
|
||||||
|
emit(const ClassListLoaded());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onClassSelected(
|
||||||
|
ClassSelected event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final classInfo = await _classRepo.getClass(event.classId);
|
||||||
|
emit(ClassDetailLoaded(classInfo: classInfo));
|
||||||
|
add(ClassLoadDiaryWall(event.classId));
|
||||||
|
add(ClassLoadMembers(event.classId));
|
||||||
|
add(ClassLoadTopics(event.classId));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onClassSelected 失败: $e');
|
||||||
|
emit(const ClassError('加载班级失败,请重试'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadMembers(
|
||||||
|
ClassLoadMembers event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
if (state is! ClassDetailLoaded) return;
|
||||||
|
final current = state as ClassDetailLoaded;
|
||||||
|
emit(current.copyWith(isLoadingMembers: true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dtos = await _classRepo.getMembers(event.classId);
|
||||||
|
final members = dtos
|
||||||
|
.map((d) => ClassMember(
|
||||||
|
userId: d.userId,
|
||||||
|
role: d.role,
|
||||||
|
nickname: d.nickname,
|
||||||
|
joinedAt: d.joinedAt,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
emit(current.copyWith(members: members, isLoadingMembers: false));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadMembers 失败: $e');
|
||||||
|
emit(current.copyWith(isLoadingMembers: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadDiaryWall(
|
||||||
|
ClassLoadDiaryWall event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
if (state is! ClassDetailLoaded) return;
|
||||||
|
final current = state as ClassDetailLoaded;
|
||||||
|
emit(current.copyWith(isLoadingWall: true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 服务端过滤:按 classId 查询班级公开日记(后端 API 已支持 ?class_id= 参数)
|
||||||
|
final journals = await _journalRepo.getJournals(classId: event.classId);
|
||||||
|
final classJournals = journals.where((j) => j.sharedToClass).toList();
|
||||||
|
|
||||||
|
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadDiaryWall 失败: $e');
|
||||||
|
emit(current.copyWith(isLoadingWall: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadTopics(
|
||||||
|
ClassLoadTopics event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
if (state is! ClassDetailLoaded) return;
|
||||||
|
final current = state as ClassDetailLoaded;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dtos = await _classRepo.getTopics(event.classId);
|
||||||
|
final topics = dtos
|
||||||
|
.map((d) => TopicAssignment(
|
||||||
|
id: d.id,
|
||||||
|
classId: d.classId,
|
||||||
|
teacherId: d.teacherId,
|
||||||
|
title: d.title,
|
||||||
|
description: d.description,
|
||||||
|
dueDate: d.dueDate,
|
||||||
|
isActive: d.isActive,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
emit(current.copyWith(topics: topics));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadTopics 失败: $e');
|
||||||
|
// 保留空列表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadComments(
|
||||||
|
ClassLoadComments event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
if (state is! ClassDetailLoaded) return;
|
||||||
|
final current = state as ClassDetailLoaded;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dtos = await _classRepo.getComments(event.journalId);
|
||||||
|
final comments = dtos
|
||||||
|
.map((d) => Comment(
|
||||||
|
id: d.id,
|
||||||
|
journalId: d.journalId,
|
||||||
|
authorId: d.authorId,
|
||||||
|
content: d.content,
|
||||||
|
createdAt: d.createdAt,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
emit(current.copyWith(comments: comments, selectedJournalId: event.journalId));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onLoadComments 失败: $e');
|
||||||
|
emit(current.copyWith(selectedJournalId: event.journalId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCreateClass(
|
||||||
|
ClassCreate event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final newClass = await _classRepo.createClass(
|
||||||
|
name: event.name,
|
||||||
|
schoolName: event.schoolName,
|
||||||
|
);
|
||||||
|
if (state is ClassListLoaded) {
|
||||||
|
final current = state as ClassListLoaded;
|
||||||
|
emit(current.copyWith(classes: [...current.classes, newClass]));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onCreateClass 失败: $e');
|
||||||
|
// 创建失败不改变状态,但通知 UI
|
||||||
|
if (state is ClassListLoaded) {
|
||||||
|
emit((state as ClassListLoaded).copyWith(error: '创建班级失败,请重试'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTopicAssign(
|
||||||
|
TopicAssign event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final dto = await _classRepo.assignTopic(
|
||||||
|
classId: event.classId,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
dueDate: event.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新本地 topics 列表(仅在班级详情视图中)
|
||||||
|
if (state is ClassDetailLoaded) {
|
||||||
|
final current = state as ClassDetailLoaded;
|
||||||
|
final newTopic = TopicAssignment(
|
||||||
|
id: dto.id,
|
||||||
|
classId: dto.classId,
|
||||||
|
teacherId: dto.teacherId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
dueDate: dto.dueDate,
|
||||||
|
);
|
||||||
|
emit(current.copyWith(topics: [newTopic, ...current.topics]));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onTopicAssign 失败: $e');
|
||||||
|
// 通知 UI 布置失败
|
||||||
|
if (state is ClassDetailLoaded) {
|
||||||
|
emit((state as ClassDetailLoaded).copyWith(error: '话题布置失败,请重试'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onJoinClass(
|
||||||
|
ClassJoin event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await _classRepo.joinClass(event.classCode, nickname: event.nickname);
|
||||||
|
// 加入成功后刷新列表
|
||||||
|
add(const ClassLoadMyClasses());
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onJoinClass 失败: $e');
|
||||||
|
emit(const ClassError('加入班级失败,请检查班级码'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCommentCreate(
|
||||||
|
CommentCreate event,
|
||||||
|
Emitter<ClassState> emit,
|
||||||
|
) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ClassDetailLoaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _classRepo.createComment(
|
||||||
|
journalId: event.journalId,
|
||||||
|
content: event.content,
|
||||||
|
);
|
||||||
|
// 创建成功后重新加载评语列表
|
||||||
|
add(ClassLoadComments(event.journalId));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('ClassBloc._onCommentCreate 失败: $e');
|
||||||
|
emit(currentState.copyWith(error: '评语发布失败'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,550 @@
|
|||||||
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:nuanji_app/core/theme/app_colors.dart';
|
||||||
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/models/school_class.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
import '../../../widgets/empty_state_widget.dart';
|
||||||
|
import '../../../widgets/error_state_widget.dart';
|
||||||
|
import '../../auth/bloc/auth_bloc.dart';
|
||||||
|
import '../bloc/class_bloc.dart';
|
||||||
|
import '../widgets/comment_bottom_sheet.dart';
|
||||||
|
|
||||||
|
/// 班级主页 — 日记墙 + 班级信息
|
||||||
class ClassPage extends StatelessWidget {
|
class ClassPage extends StatelessWidget {
|
||||||
const ClassPage({super.key});
|
const ClassPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return BlocProvider(
|
||||||
body: Center(
|
create: (context) => ClassBloc(
|
||||||
child: Text('班级 - 占位页面'),
|
classRepository: context.read<ClassRepository>(),
|
||||||
|
journalRepository: context.read<JournalRepository>(),
|
||||||
|
)..add(const ClassLoadMyClasses()),
|
||||||
|
child: const _ClassView(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClassView extends StatelessWidget {
|
||||||
|
const _ClassView();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<ClassBloc, ClassState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is ClassLoading || state is ClassInitial) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('班级')),
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is ClassError) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('班级')),
|
||||||
|
body: ErrorStateWidget(
|
||||||
|
message: state.message,
|
||||||
|
onRetry: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is ClassListLoaded) {
|
||||||
|
return _ClassListView(classes: state.classes, colorScheme: Theme.of(context).colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is ClassDetailLoaded) {
|
||||||
|
return _ClassDetailView(state: state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 班级列表视图 =====
|
||||||
|
|
||||||
|
class _ClassListView extends StatelessWidget {
|
||||||
|
const _ClassListView({required this.classes, required this.colorScheme});
|
||||||
|
|
||||||
|
final List<SchoolClass> classes;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('我的班级')),
|
||||||
|
body: classes.isEmpty
|
||||||
|
? _buildEmptyState(context, colorScheme)
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: classes.map((cls) {
|
||||||
|
return _ClassListCard(
|
||||||
|
cls: cls,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onTap: () =>
|
||||||
|
context.read<ClassBloc>().add(ClassSelected(cls.id)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return EmptyStateWidget(
|
||||||
|
icon: Icons.group_add_rounded,
|
||||||
|
title: '还没有加入班级',
|
||||||
|
actionLabel: '通过班级码加入',
|
||||||
|
onAction: () => context.go('/class-code'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClassListCard extends StatelessWidget {
|
||||||
|
const _ClassListCard({
|
||||||
|
required this.cls,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SchoolClass cls;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.secondary.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Icon(Icons.school_rounded, color: AppColors.secondary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(cls.name, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${cls.schoolName} · ${cls.memberCount} 人',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 班级详情视图(日记墙)=====
|
||||||
|
|
||||||
|
class _ClassDetailView extends StatelessWidget {
|
||||||
|
const _ClassDetailView({required this.state});
|
||||||
|
|
||||||
|
final ClassDetailLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final classInfo = state.classInfo;
|
||||||
|
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
),
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(classInfo.name),
|
||||||
|
Text(
|
||||||
|
'${classInfo.schoolName} · 班级码: ${classInfo.classCode}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottom: const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: '日记墙'),
|
||||||
|
Tab(text: '主题'),
|
||||||
|
Tab(text: '成员'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
children: [
|
||||||
|
// 日记墙
|
||||||
|
_DiaryWallTab(state: state),
|
||||||
|
// 主题布置
|
||||||
|
_TopicsTab(topics: state.topics),
|
||||||
|
// 成员列表
|
||||||
|
_MembersTab(state: state),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 日记墙 Tab =====
|
||||||
|
|
||||||
|
class _DiaryWallTab extends StatelessWidget {
|
||||||
|
const _DiaryWallTab({required this.state});
|
||||||
|
|
||||||
|
final ClassDetailLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
if (state.isLoadingWall) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.diaryWall.isEmpty) {
|
||||||
|
return const EmptyStateWidget(
|
||||||
|
icon: Icons.auto_stories_rounded,
|
||||||
|
title: '日记墙还是空的',
|
||||||
|
subtitle: '分享你的日记到这里吧',
|
||||||
|
iconSize: 48,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: state.diaryWall.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final journal = state.diaryWall[index];
|
||||||
|
return _DiaryWallCard(journal: journal, comments: state.comments);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiaryWallCard extends StatelessWidget {
|
||||||
|
const _DiaryWallCard({required this.journal, required this.comments});
|
||||||
|
|
||||||
|
final JournalEntry journal;
|
||||||
|
final List<Comment> comments;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 头部:作者 + 心情
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: AppColors.rose.withValues(alpha: 0.2),
|
||||||
|
child: Text(
|
||||||
|
journal.title.isNotEmpty ? journal.title[0] : '日',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(color: AppColors.rose),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
journal.title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: moodColor.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 日期
|
||||||
|
Text(
|
||||||
|
'${journal.date.month}月${journal.date.day}日',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 评语(按 journalId 过滤,避免显示在错误卡片下)
|
||||||
|
...(() {
|
||||||
|
final journalComments = comments.where((c) => c.journalId == journal.id).toList();
|
||||||
|
if (journalComments.isEmpty) return <Widget>[];
|
||||||
|
return [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.rate_review_rounded, size: 14),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
journalComments.first.content,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
// 写评语按钮(仅老师可见)
|
||||||
|
if (_isTeacher(context)) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => CommentBottomSheet(
|
||||||
|
journalId: journal.id,
|
||||||
|
studentName: journal.authorId, // TODO: 替换为真实昵称
|
||||||
|
onSubmit: (content) {
|
||||||
|
context.read<ClassBloc>().add(
|
||||||
|
CommentCreate(
|
||||||
|
journalId: journal.id,
|
||||||
|
content: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.rate_review_outlined, size: 16),
|
||||||
|
label: const Text('写评语'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断当前用户是否是老师
|
||||||
|
bool _isTeacher(BuildContext context) {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
if (authState is Authenticated) {
|
||||||
|
return authState.user.isTeacher;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _moodEmoji(Mood mood) => switch (mood) {
|
||||||
|
Mood.happy => '😊',
|
||||||
|
Mood.calm => '😌',
|
||||||
|
Mood.sad => '😢',
|
||||||
|
Mood.angry => '😠',
|
||||||
|
Mood.thinking => '🤔',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主题 Tab =====
|
||||||
|
|
||||||
|
class _TopicsTab extends StatelessWidget {
|
||||||
|
const _TopicsTab({required this.topics});
|
||||||
|
|
||||||
|
final List<TopicAssignment> topics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
if (topics.isEmpty) {
|
||||||
|
return const EmptyStateWidget(
|
||||||
|
icon: Icons.assignment_outlined,
|
||||||
|
title: '暂无主题布置',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: topics.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final topic = topics[index];
|
||||||
|
final isOverdue = topic.dueDate != null && topic.dueDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.push('/editor?topic=${topic.id}'),
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.assignment_outlined, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
topic.title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (topic.isActive && !isOverdue)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.secondary.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text('进行中', style: theme.textTheme.labelSmall?.copyWith(color: AppColors.secondary)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (topic.description != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(topic.description!, style: theme.textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
if (topic.dueDate != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'截止: ${topic.dueDate!.month}月${topic.dueDate!.day}日',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isOverdue ? colorScheme.error : colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 成员 Tab =====
|
||||||
|
|
||||||
|
class _MembersTab extends StatelessWidget {
|
||||||
|
const _MembersTab({required this.state});
|
||||||
|
|
||||||
|
final ClassDetailLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (state.isLoadingMembers) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: state.members.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final member = state.members[index];
|
||||||
|
final isTeacher = member.role == 'teacher';
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: isTeacher
|
||||||
|
? AppColors.accent.withValues(alpha: 0.15)
|
||||||
|
: AppColors.secondary.withValues(alpha: 0.15),
|
||||||
|
child: Text(
|
||||||
|
(member.nickname ?? '?').characters.first,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isTeacher ? AppColors.accent : AppColors.secondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(member.nickname ?? '未知'),
|
||||||
|
trailing: isTeacher
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text('老师', style: theme.textTheme.labelSmall?.copyWith(color: AppColors.accent)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
191
app/lib/features/class_/widgets/comment_bottom_sheet.dart
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// 评语输入 BottomSheet — 老师点评学生日记
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// - 7 个快捷评语模板,一键选择
|
||||||
|
// - 自由文字输入,支持多行
|
||||||
|
// - 温暖鼓励的语气(面向小学生日记)
|
||||||
|
// - 触摸目标 ≥ 44px
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 老师点评输入面板
|
||||||
|
class CommentBottomSheet extends StatefulWidget {
|
||||||
|
final String journalId;
|
||||||
|
final String studentName;
|
||||||
|
final void Function(String content) onSubmit;
|
||||||
|
|
||||||
|
const CommentBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.journalId,
|
||||||
|
required this.studentName,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CommentBottomSheet> createState() => _CommentBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentBottomSheetState extends State<CommentBottomSheet> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
// 快捷评语模板 — 温暖鼓励风格
|
||||||
|
static const _quickComments = [
|
||||||
|
'🌟 写得真好!继续加油!',
|
||||||
|
'📖 故事很精彩,想象力很丰富!',
|
||||||
|
'💪 字写得很工整,继续保持!',
|
||||||
|
'🎨 画得很漂亮,很有创意!',
|
||||||
|
'🌈 内容很丰富,观察很仔细!',
|
||||||
|
'✍️ 可以再多写一点自己的感受哦',
|
||||||
|
'📸 照片拍得很好,记录很用心!',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Future.microtask(() => _focusNode.requestFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final content = _controller.text.trim();
|
||||||
|
if (content.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
widget.onSubmit(content);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 拖拽条
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 标题
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'点评 ${widget.studentName} 的日记',
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// 快捷评语
|
||||||
|
SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: _quickComments.map((comment) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ActionChip(
|
||||||
|
label: Text(comment, style: const TextStyle(fontSize: 12)),
|
||||||
|
onPressed: () {
|
||||||
|
_controller.text = comment;
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// 输入框
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '写下你的评语...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// 提交按钮
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
child: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('发布评语'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/lib/features/discover/bloc/discover_bloc.dart
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// 发现页 BLoC — 管理发现页数据加载状态
|
||||||
|
//
|
||||||
|
// 职责:调用 /diary/discover API,解析响应,管理加载/成功/失败状态。
|
||||||
|
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../data/remote/api_client.dart';
|
||||||
|
import '../models/discover_models.dart';
|
||||||
|
|
||||||
|
part 'discover_event.dart';
|
||||||
|
part 'discover_state.dart';
|
||||||
|
|
||||||
|
class DiscoverBloc extends Bloc<DiscoverEvent, DiscoverState> {
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
DiscoverBloc({required ApiClient api})
|
||||||
|
: _api = api,
|
||||||
|
super(const DiscoverInitial()) {
|
||||||
|
on<DiscoverLoadData>(_onLoadData);
|
||||||
|
on<DiscoverRefresh>(_onRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载 — 显示 loading 状态
|
||||||
|
Future<void> _onLoadData(
|
||||||
|
DiscoverLoadData event,
|
||||||
|
Emitter<DiscoverState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const DiscoverLoading());
|
||||||
|
await _fetchData(emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新 — 不显示 loading,静默更新
|
||||||
|
Future<void> _onRefresh(
|
||||||
|
DiscoverRefresh event,
|
||||||
|
Emitter<DiscoverState> emit,
|
||||||
|
) async {
|
||||||
|
await _fetchData(emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用数据获取逻辑
|
||||||
|
Future<void> _fetchData(Emitter<DiscoverState> emit) async {
|
||||||
|
try {
|
||||||
|
final response = await _api.get('/diary/discover');
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// 后端信封格式: { success, data: { ... }, message }
|
||||||
|
final dataJson = body['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
final discoverData = DiscoverData.fromJson(dataJson);
|
||||||
|
|
||||||
|
emit(DiscoverLoaded(discoverData));
|
||||||
|
} on OfflineException {
|
||||||
|
// 离线时,如果有已加载的数据,保留它
|
||||||
|
if (state is DiscoverLoaded) return;
|
||||||
|
emit(const DiscoverError('网络不可用,请检查网络连接'));
|
||||||
|
} catch (e) {
|
||||||
|
if (state is DiscoverLoaded) return;
|
||||||
|
emit(DiscoverError('加载失败:${_friendlyError(e)}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将异常转换为用户友好的错误消息
|
||||||
|
String _friendlyError(Object error) {
|
||||||
|
final msg = error.toString();
|
||||||
|
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||||
|
return '无法连接服务器';
|
||||||
|
}
|
||||||
|
if (msg.contains('401')) {
|
||||||
|
return '登录已过期,请重新登录';
|
||||||
|
}
|
||||||
|
if (msg.contains('403')) {
|
||||||
|
return '没有访问权限';
|
||||||
|
}
|
||||||
|
if (msg.contains('500')) {
|
||||||
|
return '服务器错误,请稍后重试';
|
||||||
|
}
|
||||||
|
return '请稍后重试';
|
||||||
|
}
|
||||||
|
}
|
||||||