2589 lines
99 KiB
Markdown
2589 lines
99 KiB
Markdown
# 自然写互动课堂手机端应用软件 V1.0
|
||
## 软件鉴别材料 — 用户操作手册与设计说明书
|
||
|
||
---
|
||
|
||
**软件全称**:自然写互动课堂手机端应用软件
|
||
**软件版本**:V1.0
|
||
**权利人**:深圳自然写科技有限公司
|
||
**文档类型**:移动应用用户操作手册 + 设计说明书
|
||
**文档编号**:WRITECH-APP-MOBILE-DS-001
|
||
**编制日期**:2026年2月
|
||
**适用平台**:Android 8.0+ / iOS 14.0+
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- 第一章 软件整体概述
|
||
- 1.1 软件简介与功能综述
|
||
- 1.2 软件用途与适用场景
|
||
- 1.3 运行环境与系统要求
|
||
- 1.4 开发语言与技术规范
|
||
- 1.5 版本说明
|
||
- 第二章 系统架构与设计思路
|
||
- 2.1 总体架构设计
|
||
- 2.2 MVVM架构说明
|
||
- 2.3 各层次详细说明
|
||
- 2.4 数据设计
|
||
- 2.5 接口设计
|
||
- 2.6 安全设计
|
||
- 2.7 权限说明
|
||
- 第三章 核心模块功能详细说明
|
||
- 3.1 登录与身份认证模块
|
||
- 3.2 教师端 — 课堂互动控制模块
|
||
- 3.3 教师端 — 作业布置与批改模块
|
||
- 3.4 教师端 — 实时收笔与展示模块
|
||
- 3.5 家长端 — 学情报告查看模块
|
||
- 3.6 家长端 — 书写回放模块
|
||
- 3.7 消息通知模块
|
||
- 3.8 蓝牙连接点阵笔模块
|
||
- 3.9 学习数据统计图表模块
|
||
- 3.10 拍照搜题模块
|
||
- 3.11 离线缓存与数据同步模块
|
||
- 3.12 个人中心与设置模块
|
||
- 第四章 操作流程与使用步骤
|
||
- 4.1 安装与首次启动
|
||
- 4.2 账号登录与角色选择
|
||
- 4.3 教师端完整使用流程
|
||
- 4.4 家长端完整使用流程
|
||
- 4.5 消息与通知使用流程
|
||
- 4.6 异常处理与故障排查
|
||
- 第五章 与源代码的对应关系
|
||
- 5.1 模块与源代码文件对应表
|
||
- 5.2 核心类与方法说明
|
||
- 5.3 状态管理架构说明
|
||
- 附录A 界面原型说明
|
||
- 附录B 第三方SDK集成说明
|
||
- 附录C 术语表
|
||
- 附录D 版本历史
|
||
|
||
---
|
||
|
||
## 第一章 软件整体概述
|
||
|
||
### 1.1 软件简介与功能综述
|
||
|
||
自然写互动课堂手机端应用软件(以下简称"手机APP")是自然写互动课堂系统面向教师和家长的手机端应用程序,同时支持Android和iOS双平台。手机APP采用Flutter跨平台框架开发,基于MVVM架构,提供课堂管理、作业布置、学情查看、家校沟通等核心功能,是教师日常移动教学和家长了解孩子学习情况的重要工具。
|
||
|
||
手机APP设计遵循"简洁高效、一端多角色"原则,同一安装包支持教师和家长两种角色,登录后自动呈现对应的功能界面。教师侧重课堂互动控制和批改管理,家长侧重孩子学情报告和作业完成情况跟踪。
|
||
|
||
**主要功能模块综述:**
|
||
|
||
| 角色 | 功能模块 | 说明 |
|
||
|------|---------|------|
|
||
| 教师端 | 课堂互动控制 | 开课、发题、收卷、随机点名、暂停课堂 |
|
||
| 教师端 | 实时收笔展示 | 实时接收学生笔迹,选取作品展示 |
|
||
| 教师端 | 作业布置与批改 | 发布作业/试卷,查看AI批改结果,人工复核 |
|
||
| 教师端 | 班级学情数据 | 班级整体得分分布、知识点掌握情况 |
|
||
| 教师端 | 蓝牙移动教学 | 蓝牙连接点阵笔,移动板书模式 |
|
||
| 家长端 | 学情报告 | 查看孩子知识掌握度、书写能力成长轨迹 |
|
||
| 家长端 | 作业完成通知 | 接收孩子作业完成提醒,查看完成质量 |
|
||
| 家长端 | 书写回放 | 回放孩子书写过程,查看笔顺是否规范 |
|
||
| 家长端 | 学习打卡 | 记录孩子每日练字打卡情况 |
|
||
| 通用 | 消息通知 | 家校沟通、系统通知、互动消息 |
|
||
| 通用 | 拍照搜题 | 拍照识别题目,与孩子作答对比 |
|
||
| 通用 | 个人中心 | 账号管理、设备管理、通知设置 |
|
||
|
||
### 1.2 软件用途与适用场景
|
||
|
||
**教师使用场景:**
|
||
|
||
- **移动巡课**:教师在教室内走动巡视时,通过手机实时查看每位学生的书写状态和完成进度,无需回到讲台PC操作
|
||
- **课后批改**:课后在手机上快速浏览AI批改结果,对需要人工复核的题目进行点评标注
|
||
- **家校沟通**:通过消息功能向特定学生家长发送学习提醒或布置个性化练习任务
|
||
- **移动板书**:教师手持点阵笔直接在教室任意位置书写,笔迹实时投影至智慧黑板
|
||
|
||
**家长使用场景:**
|
||
|
||
- **学情追踪**:每日/每周查看孩子作业完成情况和AI评分,了解薄弱知识点
|
||
- **书写监督**:通过书写回放功能查看孩子练字的笔顺是否正确,提供有针对性的辅导
|
||
- **作业提醒**:收到孩子未完成作业的系统提醒,督促孩子及时完成
|
||
- **成长记录**:查看孩子书写能力的月度/学期成长对比图表
|
||
|
||
### 1.3 运行环境与系统要求
|
||
|
||
**Android平台:**
|
||
|
||
| 配置项 | 最低要求 | 推荐配置 |
|
||
|--------|---------|---------|
|
||
| Android版本 | Android 8.0(API Level 26) | Android 12.0+ |
|
||
| 内存 | 2GB RAM | 4GB RAM |
|
||
| 存储 | 200MB可用空间 | 1GB可用空间(含缓存) |
|
||
| 网络 | WiFi 或 4G/5G | 5G / WiFi 6 |
|
||
| 蓝牙 | BLE 4.0(教师端) | BLE 5.0 |
|
||
| 摄像头 | 800万像素(拍照搜题) | 1200万像素以上 |
|
||
|
||
**iOS平台:**
|
||
|
||
| 配置项 | 最低要求 | 推荐配置 |
|
||
|--------|---------|---------|
|
||
| iOS版本 | iOS 14.0 | iOS 16.0+ |
|
||
| 设备型号 | iPhone 8 及以上 | iPhone 13 系列及以上 |
|
||
| 存储 | 200MB可用空间 | 1GB可用空间 |
|
||
| 网络 | WiFi 或 4G/5G | 5G / WiFi 6 |
|
||
| 蓝牙 | Core Bluetooth 支持BLE | BLE 5.0 |
|
||
|
||
**网络环境:**
|
||
- 正常使用需要网络连接(云端API调用)
|
||
- 已缓存的作业列表和学情报告支持离线查看
|
||
- 弱网络(2G环境)下基础功能可用,书写回放等大数据功能受限
|
||
|
||
### 1.4 开发语言与技术规范
|
||
|
||
**主要技术栈:**
|
||
|
||
| 技术 | 版本 | 用途 |
|
||
|------|------|------|
|
||
| Flutter | 3.16.0 | 跨平台UI框架(Android + iOS) |
|
||
| Dart | 3.2.0 | 主要开发语言 |
|
||
| flutter_bloc | 8.1.3 | 状态管理(BLoC模式) |
|
||
| Dio | 5.3.2 | HTTP网络请求库 |
|
||
| web_socket_channel | 2.4.0 | WebSocket实时通信 |
|
||
| sqflite | 2.3.0 | 本地SQLite数据库 |
|
||
| flutter_blue_plus | 1.31.7 | BLE蓝牙通信(点阵笔连接) |
|
||
| flutter_local_notifications | 16.3.0 | 本地通知推送 |
|
||
| firebase_messaging | 14.7.10 | FCM推送(Android) |
|
||
| camera | 0.10.5 | 摄像头拍照搜题 |
|
||
| shared_preferences | 2.2.2 | 轻量键值对本地存储 |
|
||
|
||
**代码架构:**
|
||
- 采用Clean Architecture分层:UI层 → 状态管理层(BLoC) → 领域层(UseCase) → 数据层(Repository)
|
||
- 命名规范:Widget类名大驼峰,方法名小驼峰,文件名小写下划线
|
||
- 国际化:`flutter_localizations`,支持中文简体/繁体
|
||
|
||
### 1.5 版本说明
|
||
|
||
| 版本 | 日期 | 平台 | 主要变更 |
|
||
|------|------|------|---------|
|
||
| V0.6 Beta | 2025年8月 | Android/iOS | 基础登录、作业列表、学情报告 |
|
||
| V0.9 RC | 2025年11月 | Android/iOS | 书写回放、BLE连接、消息通知 |
|
||
| V1.0 | 2026年2月 | Android/iOS | 正式版:拍照搜题、打卡功能、无障碍优化 |
|
||
|
||
---
|
||
|
||
## 第二章 系统架构与设计思路
|
||
|
||
### 2.1 总体架构设计
|
||
|
||
手机APP采用Flutter跨平台框架,基于BLoC(Business Logic Component)状态管理模式,实现MVVM架构。整体架构分为五层:
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ UI层(View Layer) │
|
||
│ Flutter Widget Tree — 教师端页面 / 家长端页面 / 通用页面 │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||
│ │ 课堂页面 │ │ 作业页面 │ │ 学情页面 │ │ 消息/个人中心 │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 状态管理层(BLoC Layer) │
|
||
│ ClassroomBloc │ AssignmentBloc │ ReportBloc │ MessageBloc │
|
||
│ BleBloc │ AuthBloc │ UserBloc │ SettingsBloc │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 领域层(Domain Layer) │
|
||
│ UseCase:课堂控制 / 作业管理 / 学情查询 / 设备连接 / 消息处理 │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 数据层(Data Layer) │
|
||
│ Repository(聚合本地+远程数据源) │
|
||
│ ├── RemoteDataSource(Dio HTTP / WebSocket) │
|
||
│ └── LocalDataSource(SQLite sqflite / SharedPreferences) │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 基础设施层(Infrastructure) │
|
||
│ BLE(flutter_blue_plus) │ 推送(Firebase/APNs) │ 摄像头(camera)│
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 MVVM架构说明
|
||
|
||
手机APP使用BLoC模式实现MVVM架构:
|
||
|
||
- **Model(模型)**:Dart数据类(如`AssignmentModel`、`StudentReportModel`),由Repository层从API或SQLite获取数据
|
||
- **ViewModel(视图模型)**:BLoC类(如`AssignmentBloc`),接收UI发出的Event,处理业务逻辑,输出State
|
||
- **View(视图)**:Flutter Widget,通过`BlocBuilder`监听State变化自动重建UI,通过`context.read<XxxBloc>().add(Event)`触发业务逻辑
|
||
|
||
**BLoC数据流示意:**
|
||
|
||
```
|
||
用户操作(点击"发布作业"按钮)
|
||
│ Widget.onTap()
|
||
▼
|
||
context.read<AssignmentBloc>().add(PublishAssignmentEvent(data))
|
||
│
|
||
▼
|
||
AssignmentBloc.mapEventToState()
|
||
│ await repository.publishAssignment(data)
|
||
▼
|
||
├── 成功 → yield AssignmentPublishedState()
|
||
└── 失败 → yield AssignmentErrorState(message)
|
||
│
|
||
▼
|
||
BlocBuilder<AssignmentBloc, AssignmentState>
|
||
├── AssignmentPublishedState → 显示成功Toast,跳转到作业列表
|
||
└── AssignmentErrorState → 显示错误对话框
|
||
```
|
||
|
||
### 2.3 各层次详细说明
|
||
|
||
**UI层(Flutter Widget层)**
|
||
|
||
UI层由Flutter Widget树构成,采用Material Design 3设计规范(Android)和Cupertino风格(iOS自适应)。主要页面通过`MaterialApp`或`CupertinoApp`路由管理,使用`go_router`库实现声明式路由。
|
||
|
||
教师端和家长端共用同一Flutter代码库,通过用户角色(`UserRole.TEACHER` / `UserRole.PARENT`)决定显示不同的`BottomNavigationBar`和页面内容。
|
||
|
||
**状态管理层(BLoC层)**
|
||
|
||
每个功能模块对应一个BLoC,BLoC之间相互独立,通过Repository层共享数据。全局状态(用户信息、登录状态)通过`AuthBloc`单例管理,使用`flutter_bloc`的`BlocProvider`注入Widget树。
|
||
|
||
**数据层(Repository层)**
|
||
|
||
Repository采用缓存优先策略(Cache-First):
|
||
1. 优先从本地SQLite读取缓存数据,立即呈现给用户
|
||
2. 同时发起网络请求获取最新数据
|
||
3. 网络数据返回后更新SQLite缓存并刷新UI
|
||
|
||
这种策略确保了弱网络环境下APP的快速响应和良好体验。
|
||
|
||
### 2.4 数据设计
|
||
|
||
**本地SQLite数据库表(sqflite):**
|
||
|
||
`assignments`表(作业列表本地缓存):
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| id | TEXT PRIMARY KEY | 作业唯一ID(服务端生成UUID) |
|
||
| title | TEXT | 作业标题 |
|
||
| subject | TEXT | 学科(语文/数学/英语) |
|
||
| type | TEXT | 作业类型(练字/试卷/作文) |
|
||
| class_id | TEXT | 班级ID |
|
||
| deadline | INTEGER | 截止时间(Unix毫秒) |
|
||
| status | TEXT | 状态(draft/published/closed) |
|
||
| student_count | INTEGER | 应交人数 |
|
||
| submitted_count | INTEGER | 已交人数 |
|
||
| synced_at | INTEGER | 最后同步时间 |
|
||
|
||
`student_reports`表(学情报告缓存):
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| id | TEXT PRIMARY KEY | 报告ID |
|
||
| student_id | TEXT | 学生ID |
|
||
| report_type | TEXT | 报告类型(weekly/monthly/assignment) |
|
||
| data_json | TEXT | 报告数据JSON序列化 |
|
||
| generated_at | INTEGER | 报告生成时间 |
|
||
| synced_at | INTEGER | 缓存同步时间 |
|
||
|
||
`messages`表(消息本地存储):
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| id | TEXT PRIMARY KEY | 消息ID |
|
||
| sender_id | TEXT | 发送者ID |
|
||
| receiver_id | TEXT | 接收者ID |
|
||
| type | TEXT | 消息类型(text/image/notification) |
|
||
| content | TEXT | 消息内容(JSON) |
|
||
| is_read | INTEGER | 是否已读(0/1) |
|
||
| created_at | INTEGER | 创建时间 |
|
||
|
||
**SharedPreferences存储(键值对配置):**
|
||
|
||
| 键名 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `user_token` | String | JWT访问令牌(加密存储) |
|
||
| `user_refresh_token` | String | 刷新令牌(加密存储) |
|
||
| `user_id` | String | 当前用户ID |
|
||
| `user_role` | String | 用户角色(teacher/parent) |
|
||
| `school_id` | String | 学校ID |
|
||
| `notification_enabled` | bool | 是否开启推送通知 |
|
||
| `theme_mode` | String | 主题模式(system/light/dark) |
|
||
| `language` | String | 语言设置(zh_CN/zh_TW) |
|
||
| `last_sync_ts` | int | 最后一次数据同步时间戳 |
|
||
|
||
### 2.5 接口设计
|
||
|
||
**HTTP API接口(与云端平台通信):**
|
||
|
||
| 接口 | 方法 | URL | 说明 |
|
||
|------|------|-----|------|
|
||
| 登录 | POST | `/api/v1/auth/login` | 手机号+密码/微信/钉钉登录 |
|
||
| 刷新令牌 | POST | `/api/v1/auth/refresh` | Token过期自动刷新 |
|
||
| 获取作业列表 | GET | `/api/v1/assignment/list` | 分页获取班级作业列表 |
|
||
| 发布作业 | POST | `/api/v1/assignment/publish` | 教师发布新作业/试卷 |
|
||
| 获取批改结果 | GET | `/api/v1/assignment/{id}/results` | 获取某次作业全班批改结果 |
|
||
| 学生学情报告 | GET | `/api/v1/report/student/{id}` | 获取指定学生学情报告 |
|
||
| 班级学情 | GET | `/api/v1/report/class/{id}` | 获取班级整体学情统计 |
|
||
| 消息列表 | GET | `/api/v1/message/list` | 获取消息列表(分页) |
|
||
| 发送消息 | POST | `/api/v1/message/send` | 发送家校沟通消息 |
|
||
| 拍照识题 | POST | `/api/v1/ocr/photo` | 上传题目照片进行识别 |
|
||
| 书写回放数据 | GET | `/api/v1/stroke/{assignment_id}/student/{id}` | 获取特定学生的笔迹回放数据 |
|
||
| 设备列表 | GET | `/api/v1/device/list` | 获取当前用户绑定的设备列表 |
|
||
|
||
**WebSocket实时通信:**
|
||
|
||
| 事件类型 | 方向 | 说明 |
|
||
|---------|------|------|
|
||
| `classroom.started` | 服务端→APP | 课堂已开始,推送课堂ID |
|
||
| `assignment.submitted` | 服务端→APP | 学生提交了作业 |
|
||
| `stroke.realtime` | 服务端→APP | 实时笔迹数据(教师巡课模式) |
|
||
| `result.ready` | 服务端→APP | AI批改结果已就绪 |
|
||
| `message.new` | 服务端→APP | 收到新消息 |
|
||
| `classroom.control` | APP→服务端 | 课堂控制指令(发题/收卷/暂停) |
|
||
|
||
**统一响应格式:**
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"message": "success",
|
||
"data": {...},
|
||
"timestamp": 1706845200000
|
||
}
|
||
```
|
||
|
||
错误码说明:
|
||
- `401`:Token失效,自动跳转重新登录
|
||
- `403`:无权访问(如家长尝试访问其他班级数据)
|
||
- `429`:请求频率超限(拍照搜题接口有频率限制)
|
||
- `503`:服务器维护中
|
||
|
||
### 2.6 安全设计
|
||
|
||
**身份认证安全:**
|
||
|
||
- 支持三种登录方式:
|
||
- 手机号+密码(密码MD5+Salt单向哈希存储)
|
||
- 微信OAuth 2.0一键登录
|
||
- 钉钉OAuth 2.0登录(教育钉钉生态)
|
||
- JWT双令牌机制:Access Token有效期2小时,Refresh Token有效期30天
|
||
- Token存储:Android使用EncryptedSharedPreferences(基于AndroidKeyStore加密),iOS使用Keychain
|
||
|
||
**网络传输安全:**
|
||
|
||
- 全链路HTTPS,TLS 1.3加密
|
||
- SSL证书绑定(Certificate Pinning):防止中间人攻击,内置服务器证书公钥指纹
|
||
- 实现方式(Dio拦截器):
|
||
|
||
```dart
|
||
// lib/core/network/ssl_pinning_interceptor.dart
|
||
class SSLPinningInterceptor extends Interceptor {
|
||
static const List<String> _pinnedCertHashes = [
|
||
'sha256/AAAAAABBBBBBCCCCCC==', // 主域名证书指纹
|
||
'sha256/DDDDDDEEEEEEFFFFF==', // 备用证书指纹(轮换用)
|
||
];
|
||
|
||
@override
|
||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||
// 验证证书指纹(在HttpClient层通过badCertificateCallback实现)
|
||
super.onResponse(response, handler);
|
||
}
|
||
}
|
||
```
|
||
|
||
**本地数据安全:**
|
||
- SQLite数据库文件存储于APP沙箱目录,不可被其他APP访问
|
||
- 敏感字段(Token、手机号)在SharedPreferences中加密后存储
|
||
- APP进入后台超过30分钟,需重新验证指纹/Face ID才能操作敏感功能
|
||
|
||
**隐私合规:**
|
||
- 严格遵守《APP收集使用个人信息最小必要评估规范》
|
||
- 摄像头(拍照搜题)、蓝牙(笔连接)、通知权限均在首次使用时动态申请
|
||
- 儿童数据(学生)展示时脱敏(姓名显示为"张*"格式)
|
||
|
||
### 2.7 权限说明
|
||
|
||
| 权限名称 | Android权限 | iOS权限 | 申请时机 | 用途 |
|
||
|---------|-----------|--------|---------|------|
|
||
| 网络访问 | `INTERNET` | - | 安装时自动 | API调用 |
|
||
| 蓝牙扫描 | `BLUETOOTH_SCAN` | NSBluetoothAlways | 首次使用"连接笔"时 | 扫描点阵笔设备 |
|
||
| 蓝牙连接 | `BLUETOOTH_CONNECT` | NSBluetoothAlways | 首次使用"连接笔"时 | 连接点阵笔 |
|
||
| 摄像头 | `CAMERA` | NSCameraUsageDescription | 首次使用"拍照搜题"时 | 拍题识别 |
|
||
| 通知 | `POST_NOTIFICATIONS` | UNUserNotificationCenter | 首次登录后询问 | 消息推送 |
|
||
| 存储读取 | `READ_EXTERNAL_STORAGE` | - | 首次保存报告时 | 导出报告到相册 |
|
||
|
||
---
|
||
|
||
## 第三章 核心模块功能详细说明
|
||
|
||
### 3.1 登录与身份认证模块
|
||
|
||
**源代码文件**:`lib/features/auth/`
|
||
|
||
**登录界面布局:**
|
||
|
||
```
|
||
┌────────────────────────────────┐
|
||
│ 自然写互动课堂 │
|
||
│ [Logo] │
|
||
│ │
|
||
│ ┌──────────────────────────┐ │
|
||
│ │ 手机号/账号 │ │
|
||
│ └──────────────────────────┘ │
|
||
│ ┌──────────────────────────┐ │
|
||
│ │ 密码 [显示/隐藏]│ │
|
||
│ └──────────────────────────┘ │
|
||
│ │
|
||
│ [ 登录 ] │
|
||
│ │
|
||
│ ── 其他登录方式 ── │
|
||
│ [微信登录] [钉钉登录] │
|
||
│ │
|
||
│ 首次使用?[联系学校管理员获取账号]│
|
||
└────────────────────────────────┘
|
||
```
|
||
|
||
**认证流程(AuthBloc):**
|
||
|
||
```dart
|
||
// lib/features/auth/bloc/auth_bloc.dart
|
||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||
final AuthRepository _authRepository;
|
||
|
||
AuthBloc({required AuthRepository authRepository})
|
||
: _authRepository = authRepository,
|
||
super(AuthInitial()) {
|
||
on<LoginRequested>(_onLoginRequested);
|
||
on<TokenRefreshRequested>(_onTokenRefreshRequested);
|
||
on<LogoutRequested>(_onLogoutRequested);
|
||
}
|
||
|
||
Future<void> _onLoginRequested(
|
||
LoginRequested event, Emitter<AuthState> emit) async {
|
||
emit(AuthLoading());
|
||
try {
|
||
final user = await _authRepository.login(
|
||
phone: event.phone,
|
||
password: event.password,
|
||
);
|
||
// 将Token安全存储
|
||
await _authRepository.saveTokensSecurely(
|
||
user.accessToken, user.refreshToken);
|
||
emit(AuthAuthenticated(user: user));
|
||
} on AuthException catch (e) {
|
||
emit(AuthError(message: e.message));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**角色分流逻辑:**
|
||
|
||
登录成功后,根据`user.role`字段跳转不同首页:
|
||
- `role == 'teacher'` → 跳转教师端首页(`TeacherHomePage`)
|
||
- `role == 'parent'` → 跳转家长端首页(`ParentHomePage`)
|
||
- `role == 'admin'` → 跳转管理员端(`AdminHomePage`,仅限学校管理员)
|
||
|
||
### 3.2 教师端 — 课堂互动控制模块
|
||
|
||
**源代码文件**:`lib/features/classroom/`
|
||
|
||
**课堂互动主界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 二年级一班 — 语文 [设置] │
|
||
├────────────────────────────────────────┤
|
||
│ 课堂状态:● 进行中 已连接 38支笔 │
|
||
│ │
|
||
│ [暂停课堂] [发题] [收卷] [点名] │
|
||
├────────────────────────────────────────┤
|
||
│ 实时提交状态 │
|
||
│ ████████████████░░░ 38/40 已提交 │
|
||
│ │
|
||
│ 未提交:王小明 李晓红 (+0) │
|
||
├────────────────────────────────────────┤
|
||
│ 快捷操作 │
|
||
│ [展示优秀作品] [全班展示] [对比] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**发题流程:**
|
||
|
||
```dart
|
||
// lib/features/classroom/bloc/classroom_bloc.dart
|
||
Future<void> _onSendQuestion(
|
||
SendQuestionEvent event, Emitter<ClassroomState> emit) async {
|
||
emit(SendingQuestion());
|
||
try {
|
||
// 1. 向云端API发送题目内容和截止时间
|
||
await _classroomRepository.sendQuestion(
|
||
classroomId: event.classroomId,
|
||
questionData: event.questionData,
|
||
timeLimitSeconds: event.timeLimitSeconds,
|
||
);
|
||
|
||
// 2. 通过WebSocket广播给所有终端
|
||
_wsChannel.sink.add(jsonEncode({
|
||
'type': 'classroom.send_question',
|
||
'classroom_id': event.classroomId,
|
||
'question': event.questionData.toJson(),
|
||
}));
|
||
|
||
emit(QuestionSentState(question: event.questionData));
|
||
} catch (e) {
|
||
emit(ClassroomErrorState(message: e.toString()));
|
||
}
|
||
}
|
||
```
|
||
|
||
**随机点名功能:**
|
||
|
||
点击"点名"按钮时,APP从班级学生列表中按算法随机抽取:
|
||
- 支持"排除已点名"选项(一节课内不重复点同一学生)
|
||
- 支持"按区域分配"(保证均匀覆盖全班)
|
||
- 点名结果同时推送到智慧黑板大屏展示(WebSocket指令)
|
||
|
||
### 3.3 教师端 — 作业布置与批改模块
|
||
|
||
**源代码文件**:`lib/features/assignment/`
|
||
|
||
**作业布置界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 布置作业 [发布] │
|
||
├────────────────────────────────────────┤
|
||
│ 作业标题:[第5课生字练习 ] │
|
||
│ │
|
||
│ 班级: [二年级一班 ▼] │
|
||
│ 学科: [语文 ▼] │
|
||
│ 类型: [练字 ● 试卷 ○ 作文 ○] │
|
||
│ │
|
||
│ 截止时间:[2026-02-15 20:00 ▼] │
|
||
│ │
|
||
│ 关联资源: │
|
||
│ [+ 从资源库选择] [+ 上传文件] │
|
||
│ ● 人教版二年级上册_第5课_字帖.pdf │
|
||
│ │
|
||
│ 布置说明:[请完成所有生字的书写练习...]│
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**批改结果查看界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 第5课生字练习 — 批改结果 │
|
||
├────────────────────────────────────────┤
|
||
│ 班级:二年级一班 已交:38/40 │
|
||
│ 平均分:87.5 优秀(≥90):15人 │
|
||
│ │
|
||
│ 按得分排序 ▼ │
|
||
├────────────────────────────────────────┤
|
||
│ 张三 96分 ✓ AI批改完成 [查看] │
|
||
│ 李四 92分 ✓ AI批改完成 [查看] │
|
||
│ 王五 85分 ⚠ 需人工复核 [批改] │
|
||
│ 赵六 78分 ✓ AI批改完成 [查看] │
|
||
│ ··· │
|
||
├────────────────────────────────────────┤
|
||
│ [导出成绩单] [发送给家长] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**人工批改界面(点击"批改"按钮):**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ 王五 — 作业批改 │
|
||
├────────────────────────────────────────┤
|
||
│ │
|
||
│ [笔迹显示区域 — 显示学生书写内容] │
|
||
│ │
|
||
│ "美"字 [笔迹图] │
|
||
│ AI建议:笔顺第3笔有误 │
|
||
│ 书写规范度:72% │
|
||
│ │
|
||
├────────────────────────────────────────┤
|
||
│ 教师评分:[____]分 │
|
||
│ 批注:[写得不错,注意横折的弧度...] │
|
||
│ │
|
||
│ [上一题] [下一题] [完成批改] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.4 教师端 — 实时收笔与展示模块
|
||
|
||
**源代码文件**:`lib/features/realtime_ink/`
|
||
|
||
教师通过手机可实时查看教室内所有学生的书写状态(需算力盒或网关推送数据):
|
||
|
||
**实时状态面板:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ 实时书写监控 [全屏] [刷新] │
|
||
├────────────────────────────────────────┤
|
||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||
│ │张三 │ │李四 │ │王五 │ │赵六 │ │
|
||
│ │[笔迹]│ │[笔迹]│ │[笔迹]│ │[笔迹]│ │
|
||
│ │书写中│ │已完成│ │书写中│ │未开始│ │
|
||
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||
│ ··· │
|
||
├────────────────────────────────────────┤
|
||
│ [投屏展示选中的学生作品] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**WebSocket实时数据接收处理:**
|
||
|
||
```dart
|
||
// lib/features/realtime_ink/repository/realtime_ink_repository.dart
|
||
Stream<StudentInkData> getRealtimeInkStream(String classroomId) {
|
||
return _wsChannel.stream
|
||
.where((data) {
|
||
final json = jsonDecode(data as String);
|
||
return json['type'] == 'stroke.realtime' &&
|
||
json['classroom_id'] == classroomId;
|
||
})
|
||
.map((data) => StudentInkData.fromJson(jsonDecode(data as String)));
|
||
}
|
||
```
|
||
|
||
### 3.5 家长端 — 学情报告查看模块
|
||
|
||
**源代码文件**:`lib/features/parent/report/`
|
||
|
||
**家长端首页(学情概览):**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 张小明的学情报告 │
|
||
├────────────────────────────────────────┤
|
||
│ 本周总结(2/10-2/14) │
|
||
│ │
|
||
│ 完成作业:5/5 ✓ │
|
||
│ 平均得分:88.5分 │
|
||
│ 书写规范度:↑ 提升3% │
|
||
│ 笔顺正确率:92% │
|
||
├────────────────────────────────────────┤
|
||
│ 知识点掌握情况 │
|
||
│ 语文:[■■■■■■■■░░] 82% │
|
||
│ 数学:[■■■■■■░░░░] 65% │
|
||
│ 英语:[■■■■■■■■■░] 90% │
|
||
├────────────────────────────────────────┤
|
||
│ 薄弱知识点提醒 │
|
||
│ ⚠ 数学:应用题解题步骤不完整 │
|
||
│ ⚠ 语文:多音字辨析正确率较低 │
|
||
├────────────────────────────────────────┤
|
||
│ [查看详细报告] [历史对比] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**成长轨迹图表(ECharts风格,使用fl_chart实现):**
|
||
|
||
```dart
|
||
// lib/features/parent/report/widgets/growth_chart_widget.dart
|
||
class GrowthChartWidget extends StatelessWidget {
|
||
final List<GrowthDataPoint> dataPoints;
|
||
|
||
const GrowthChartWidget({super.key, required this.dataPoints});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LineChart(
|
||
LineChartData(
|
||
lineBarsData: [
|
||
LineChartBarData(
|
||
spots: dataPoints.map((p) =>
|
||
FlSpot(p.weekIndex.toDouble(), p.score)).toList(),
|
||
isCurved: true,
|
||
color: Theme.of(context).primaryColor,
|
||
barWidth: 3,
|
||
dotData: FlDotData(show: true),
|
||
),
|
||
],
|
||
titlesData: FlTitlesData(
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
getTitlesWidget: (value, meta) {
|
||
return Text('第${value.toInt()}周',
|
||
style: const TextStyle(fontSize: 10));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.6 家长端 — 书写回放模块
|
||
|
||
**源代码文件**:`lib/features/parent/stroke_replay/`
|
||
|
||
书写回放功能让家长能以动画方式观看孩子的实际书写过程,直观了解笔顺和书写规范性。
|
||
|
||
**回放界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 书写回放 — "美"字 │
|
||
├────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ │ │
|
||
│ │ [笔迹动画回放区域] │ │
|
||
│ │ 书写时间:2026-02-14 08:30 │ │
|
||
│ │ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ 第3笔 ⚠ 笔顺有误(应先横后折) │
|
||
│ 笔顺正确率:88%(11/12笔正确) │
|
||
│ │
|
||
│ ════════════════════░░░ 75% │
|
||
│ [◀◀] [▶/II] [▶▶] [速度×1] │
|
||
├────────────────────────────────────────┤
|
||
│ [分享给老师] [添加到错题本] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**回放渲染引擎(CustomPainter):**
|
||
|
||
```dart
|
||
// lib/features/parent/stroke_replay/painters/stroke_replay_painter.dart
|
||
class StrokeReplayPainter extends CustomPainter {
|
||
final List<StrokePath> completedStrokes;
|
||
final StrokePath? currentStroke;
|
||
final double progress; // 当前绘制进度 [0.0, 1.0]
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final paint = Paint()
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round
|
||
..style = PaintingStyle.stroke;
|
||
|
||
// 绘制已完成的笔画(灰色)
|
||
for (final stroke in completedStrokes) {
|
||
paint.color = Colors.grey.withOpacity(0.5);
|
||
paint.strokeWidth = stroke.width;
|
||
_drawStroke(canvas, stroke.points, paint, size);
|
||
}
|
||
|
||
// 绘制当前正在演示的笔画(黑色,按progress截断)
|
||
if (currentStroke != null) {
|
||
paint.color = Colors.black87;
|
||
paint.strokeWidth = currentStroke!.width;
|
||
final visibleCount =
|
||
(currentStroke!.points.length * progress).round();
|
||
_drawStroke(
|
||
canvas, currentStroke!.points.take(visibleCount).toList(),
|
||
paint, size);
|
||
}
|
||
}
|
||
|
||
void _drawStroke(Canvas canvas, List<StrokePoint> points,
|
||
Paint paint, Size size) {
|
||
if (points.length < 2) return;
|
||
final path = Path();
|
||
path.moveTo(points[0].x * size.width, points[0].y * size.height);
|
||
for (int i = 1; i < points.length; i++) {
|
||
// 使用贝塞尔曲线平滑笔迹
|
||
final prevPoint = points[i - 1];
|
||
final currPoint = points[i];
|
||
final midX = (prevPoint.x + currPoint.x) / 2 * size.width;
|
||
final midY = (prevPoint.y + currPoint.y) / 2 * size.height;
|
||
path.quadraticBezierTo(
|
||
prevPoint.x * size.width, prevPoint.y * size.height,
|
||
midX, midY);
|
||
}
|
||
canvas.drawPath(path, paint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(StrokeReplayPainter oldDelegate) {
|
||
return oldDelegate.progress != progress ||
|
||
oldDelegate.currentStroke != currentStroke;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.7 消息通知模块
|
||
|
||
**源代码文件**:`lib/features/message/`
|
||
|
||
**消息列表界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ 消息中心 [全部已读] │
|
||
├────────────────────────────────────────┤
|
||
│ [家校沟通] [作业通知] [系统通知] │
|
||
├────────────────────────────────────────┤
|
||
│ ● 张三妈妈:请问孩子今天的语文作业... │
|
||
│ 2月14日 08:30 [●未读] │
|
||
│ │
|
||
│ ● 作业提醒:王五的数学练习尚未提交 │
|
||
│ 2月13日 20:00 [已读] │
|
||
│ │
|
||
│ ● 系统:AI批改完成,本次作业38人已批改 │
|
||
│ 2月13日 16:25 [已读] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**推送通知实现(Firebase Messaging):**
|
||
|
||
```dart
|
||
// lib/core/notifications/push_notification_service.dart
|
||
class PushNotificationService {
|
||
final FirebaseMessaging _fcm = FirebaseMessaging.instance;
|
||
|
||
Future<void> initialize() async {
|
||
// 请求通知权限(iOS需要显式请求)
|
||
await _fcm.requestPermission(
|
||
alert: true, badge: true, sound: true,
|
||
);
|
||
|
||
// 获取FCM Token并上传至服务端(绑定到当前用户)
|
||
final token = await _fcm.getToken();
|
||
if (token != null) {
|
||
await _uploadFcmToken(token);
|
||
}
|
||
|
||
// 监听前台消息(APP在前台时的推送处理)
|
||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||
_handleForegroundMessage(message);
|
||
});
|
||
|
||
// 监听点击通知打开APP(APP在后台被唤醒)
|
||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||
_handleNotificationTap(message);
|
||
});
|
||
}
|
||
|
||
void _handleForegroundMessage(RemoteMessage message) {
|
||
// 前台时不显示系统通知,而是在APP内显示自定义提示条(SnackBar/Banner)
|
||
final type = message.data['type'];
|
||
if (type == 'assignment.submitted') {
|
||
// 更新作业提交计数
|
||
_messageBloc.add(NewSubmissionEvent(data: message.data));
|
||
} else if (type == 'message.new') {
|
||
// 在消息中心显示新消息红点
|
||
_messageBloc.add(NewMessageEvent(data: message.data));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.8 蓝牙连接点阵笔模块
|
||
|
||
**源代码文件**:`lib/features/bluetooth/`
|
||
|
||
此功能面向教师端的移动教学场景,教师手持点阵笔直接书写,笔迹实时传输至手机APP,再由APP转发至智慧黑板大屏。
|
||
|
||
**设备扫描界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 连接点阵笔 │
|
||
├────────────────────────────────────────┤
|
||
│ 搜索附近的点阵笔... │
|
||
│ [停止搜索] │
|
||
├────────────────────────────────────────┤
|
||
│ ● Writech-A1B2C3 信号强 [连接] │
|
||
│ ○ Writech-D4E5F6 信号中 [连接] │
|
||
├────────────────────────────────────────┤
|
||
│ 已配对设备 │
|
||
│ ● Writech-A1B2C3 上次使用:今天 │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**BLE连接管理(flutter_blue_plus):**
|
||
|
||
```dart
|
||
// lib/features/bluetooth/repository/ble_repository.dart
|
||
class BleRepository {
|
||
final FlutterBluePlus _flutterBlue = FlutterBluePlus.instance;
|
||
|
||
Stream<List<ScanResult>> scanPens() {
|
||
_flutterBlue.startScan(
|
||
withServices: [Guid('0000FFF0-0000-1000-8000-00805F9B34FB')],
|
||
timeout: const Duration(seconds: 10),
|
||
);
|
||
return _flutterBlue.scanResults;
|
||
}
|
||
|
||
Future<BluetoothDevice> connectToPen(String deviceId) async {
|
||
final device = BluetoothDevice.fromId(deviceId);
|
||
await device.connect(timeout: const Duration(seconds: 10));
|
||
|
||
// 订阅笔迹数据Notify
|
||
final services = await device.discoverServices();
|
||
for (final service in services) {
|
||
if (service.uuid.toString().startsWith('0000fff0')) {
|
||
for (final char in service.characteristics) {
|
||
if (char.uuid.toString().startsWith('0000fff1')) {
|
||
await char.setNotifyValue(true);
|
||
// 监听笔迹数据流
|
||
char.lastValueStream.listen((data) {
|
||
_processInkData(data);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return device;
|
||
}
|
||
|
||
void _processInkData(List<int> rawData) {
|
||
// 解析BLE差分编码数据包,还原坐标序列
|
||
final coords = StrokeDecoder.decode(Uint8List.fromList(rawData));
|
||
_inkStreamController.add(coords);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.9 学习数据统计图表模块
|
||
|
||
**源代码文件**:`lib/features/analytics/`
|
||
|
||
使用`fl_chart`库实现多种数据可视化图表,直观展示学习数据。
|
||
|
||
**教师端 — 班级成绩分布(柱状图):**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ 班级成绩分布 第5课生字练习 │
|
||
├────────────────────────────────────────┤
|
||
│ 10│ ██ │
|
||
│ 8│ ██ ██ │
|
||
│ 6│ ██ ██ ██ ██ │
|
||
│ 4│ ██ ██ ██ ██ ██ │
|
||
│ 2│ ██ ██ ██ ██ ██ ██ │
|
||
│ 0└──────────────────── │
|
||
│ 60 70 80 90 100(分) │
|
||
│ │
|
||
│ 平均分:87.5 中位数:89 │
|
||
│ 及格率:100% 优秀率(≥90):37.5% │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**家长端 — 学科雷达图:**
|
||
|
||
```dart
|
||
// lib/features/analytics/widgets/subject_radar_widget.dart
|
||
RadarChart(
|
||
RadarChartData(
|
||
dataSets: [
|
||
RadarDataSet(
|
||
dataEntries: [
|
||
RadarEntry(value: 82), // 语文
|
||
RadarEntry(value: 75), // 数学
|
||
RadarEntry(value: 90), // 英语
|
||
RadarEntry(value: 68), // 书写
|
||
RadarEntry(value: 85), // 笔顺
|
||
],
|
||
fillColor: Colors.blue.withOpacity(0.2),
|
||
borderColor: Colors.blue,
|
||
),
|
||
],
|
||
radarBackgroundColor: Colors.transparent,
|
||
getTitle: (index, angle) {
|
||
const titles = ['语文', '数学', '英语', '书写', '笔顺'];
|
||
return RadarChartTitle(text: titles[index]);
|
||
},
|
||
),
|
||
)
|
||
```
|
||
|
||
### 3.10 拍照搜题模块
|
||
|
||
**源代码文件**:`lib/features/photo_ocr/`
|
||
|
||
家长可以用手机摄像头拍摄孩子作业中的题目,APP上传图片到云端AI引擎进行识别,与孩子的作答结果进行对比。
|
||
|
||
**拍照识题界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 拍照搜题 │
|
||
├────────────────────────────────────────┤
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ │ │
|
||
│ │ [摄像头取景框] │ │
|
||
│ │ 对准题目,点击拍照 │ │
|
||
│ │ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ [从相册选择] [📷 拍照] │
|
||
├────────────────────────────────────────┤
|
||
│ 最近搜题记录 │
|
||
│ 2+3=? 2月14日 │
|
||
│ "美"字写法 2月13日 │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**识别结果界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [←] 识别结果 │
|
||
├────────────────────────────────────────┤
|
||
│ 题目识别: │
|
||
│ "2+3=" │
|
||
│ │
|
||
│ 标准答案:5 │
|
||
│ │
|
||
│ 孩子的作答:5 ✓ 正确 │
|
||
├────────────────────────────────────────┤
|
||
│ 解题思路: │
|
||
│ 2加3等于5,可以用手指数数: │
|
||
│ 1,2...再数3个...3,4,5 │
|
||
│ │
|
||
│ [加入错题本] [分享给老师] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.11 离线缓存与数据同步模块
|
||
|
||
**源代码文件**:`lib/core/cache/`
|
||
|
||
**缓存策略(Repository层统一实现):**
|
||
|
||
```dart
|
||
// lib/core/cache/cache_first_repository.dart
|
||
abstract class CacheFirstRepository<T> {
|
||
/// 获取数据(缓存优先策略)
|
||
Stream<T> getWithCache(String cacheKey,
|
||
Future<T> Function() networkFetch) async* {
|
||
|
||
// 1. 先尝试读取本地缓存(立即返回,用户无感知延迟)
|
||
final cachedData = await _localDataSource.get<T>(cacheKey);
|
||
if (cachedData != null) {
|
||
yield cachedData;
|
||
}
|
||
|
||
// 2. 同时发起网络请求获取最新数据
|
||
try {
|
||
final freshData = await networkFetch();
|
||
|
||
// 3. 更新本地缓存
|
||
await _localDataSource.save(cacheKey, freshData);
|
||
|
||
// 4. 如果新数据与缓存不同,推送给UI更新
|
||
if (cachedData == null || freshData != cachedData) {
|
||
yield freshData;
|
||
}
|
||
} on NetworkException {
|
||
// 网络失败时继续使用缓存数据(已在步骤1 yield过)
|
||
if (cachedData == null) {
|
||
// 没有缓存且无网络,抛出错误
|
||
rethrow;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**离线队列(网络恢复后自动同步):**
|
||
|
||
APP在离线期间的写操作(如发送消息、修改批改结果)会先存入本地SQLite的`offline_queue`表,网络恢复时按顺序重放执行。
|
||
|
||
### 3.12 个人中心与设置模块
|
||
|
||
**源代码文件**:`lib/features/profile/`
|
||
|
||
**个人中心界面:**
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [头像] 张老师 │
|
||
│ 教师 · 育才小学二年级语文 │
|
||
├────────────────────────────────────────┤
|
||
│ 我的班级 二年级一班 二年级二班 │
|
||
│ 我的设备 [点阵笔] [已配对2台] ›│
|
||
│ 消息通知设置 [开启推送 ✓] │
|
||
├────────────────────────────────────────┤
|
||
│ 帮助与反馈 › │
|
||
│ 隐私政策 › │
|
||
│ 用户服务协议 › │
|
||
│ 关于自然写 V1.0.0 › │
|
||
├────────────────────────────────────────┤
|
||
│ [退出登录] │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 第四章 操作流程与使用步骤
|
||
|
||
### 4.1 安装与首次启动
|
||
|
||
**Android安装:**
|
||
1. 通过应用市场(华为应用市场/小米应用商店/Google Play)搜索"自然写互动课堂"下载安装
|
||
2. 安装包大小约85MB,安装完成后首次启动约需3秒初始化
|
||
|
||
**iOS安装:**
|
||
1. 通过App Store搜索"自然写互动课堂"
|
||
2. 点击"获取",使用Face ID / Touch ID确认安装
|
||
|
||
**首次启动流程:**
|
||
|
||
```
|
||
首次打开APP:
|
||
│
|
||
├─ 显示欢迎页(3秒)
|
||
│
|
||
├─ 权限申请说明页(说明各权限用途)
|
||
│ [知道了,开始使用]
|
||
│
|
||
├─ 登录页(等待用户登录)
|
||
│
|
||
└─ 登录成功→根据角色跳转对应首页
|
||
教师角色 → 教师端首页
|
||
家长角色 → 家长端首页
|
||
```
|
||
|
||
### 4.2 账号登录与角色选择
|
||
|
||
**教师账号登录(手机号+密码):**
|
||
|
||
1. 输入学校统一分配的手机号/工号
|
||
2. 输入初始密码(默认为手机号后6位,首次登录需修改)
|
||
3. 点击"登录",系统验证并分配教师角色权限
|
||
4. 教师端首页显示班级列表和今日待办事项
|
||
|
||
**家长账号登录(微信一键登录):**
|
||
|
||
1. 点击"微信登录",跳转微信授权页面
|
||
2. 微信确认授权后返回APP
|
||
3. 若为首次登录,提示输入学生学号绑定孩子账号
|
||
4. 绑定成功后进入家长端首页
|
||
|
||
**同一账号切换子账号(家长端):**
|
||
|
||
若家长有多个孩子,可在"个人中心"→"切换孩子"中选择查看不同孩子的学情。
|
||
|
||
### 4.3 教师端完整使用流程
|
||
|
||
**日常使用流程(课堂日):**
|
||
|
||
```
|
||
上课前(8:00-8:30):
|
||
1. 打开APP,进入班级主页
|
||
2. 点击"布置作业"→选择今日练习内容→设置截止时间→发布
|
||
3. 作业发布后,学生Pad端自动收到新作业通知
|
||
|
||
上课中(8:30-9:10):
|
||
1. 点击"开始课堂"→选择班级→课堂互动主界面启动
|
||
2. 实时查看学生书写状态(绿色=书写中,灰色=未开始)
|
||
3. 点击"发题"→输入互动题目→选择时限→发送全班
|
||
4. 学生答题完毕,点击"收卷"→查看实时答题统计
|
||
5. 选择典型答案(正确/错误各选几份)→点击"展示至黑板"
|
||
|
||
课后(放学后):
|
||
1. 查看AI批改已完成的作业列表
|
||
2. 对标注"需人工复核"的作业进行批改
|
||
3. 批改完成后,家长端自动收到推送通知
|
||
4. 选择本周优秀作品→发布"作品墙"推送给家长
|
||
```
|
||
|
||
### 4.4 家长端完整使用流程
|
||
|
||
**日常查看孩子学情:**
|
||
|
||
```
|
||
家长端日常操作:
|
||
1. 打开APP,首页显示今日学情摘要
|
||
2. 查看"今日作业":
|
||
├── 已完成:查看AI评分和教师批改评语
|
||
└── 未完成:点击提醒按钮(振动提醒孩子)
|
||
3. 点击"书写回放":
|
||
├── 选择某次作业的某个字
|
||
└── 观看书写动画回放,检查笔顺是否正确
|
||
4. 查看"成长报告":
|
||
├── 本周得分趋势折线图
|
||
└── 薄弱知识点标注
|
||
5. 与教师沟通:
|
||
└── 点击"联系老师"→发送文字消息给班主任
|
||
```
|
||
|
||
### 4.5 消息与通知使用流程
|
||
|
||
**接收推送通知:**
|
||
- APP在后台时,新消息通过系统通知栏推送(需开启通知权限)
|
||
- 点击通知可直接跳转到对应功能页面(如点击"批改完成通知"跳转到批改结果页)
|
||
|
||
**屏蔽通知设置:**
|
||
- 在"个人中心"→"消息通知设置"可按类型开关通知
|
||
- 支持设置"免打扰时段"(如每天22:00-7:00不推送)
|
||
|
||
### 4.6 异常处理与故障排查
|
||
|
||
| 问题现象 | 可能原因 | 解决方法 |
|
||
|---------|---------|---------|
|
||
| 登录失败"账号不存在" | 使用了错误的登录方式(教师用微信登录/家长用工号登录) | 选择正确的登录方式 |
|
||
| 作业列表无法加载 | 网络连接问题 | 检查网络,下拉刷新 |
|
||
| 书写回放无数据 | 学生通过触屏而非点阵笔作答 | 确认学生使用点阵笔书写 |
|
||
| 蓝牙扫描不到笔 | 点阵笔未开机或蓝牙权限未授予 | 检查笔电量,确认已授予蓝牙权限 |
|
||
| 推送通知未收到 | 通知权限被关闭或FCM网络问题 | 检查系统通知权限设置 |
|
||
|
||
---
|
||
|
||
## 第五章 与源代码的对应关系
|
||
|
||
### 5.1 模块名称与源代码文件对应表
|
||
|
||
| 功能模块 | 源代码路径 | 说明 |
|
||
|---------|----------|------|
|
||
| 登录认证模块 | `lib/features/auth/` | AuthBloc, AuthRepository, LoginPage |
|
||
| 教师端首页 | `lib/features/teacher/home/` | TeacherHomePage, ClassroomCard |
|
||
| 课堂互动控制 | `lib/features/classroom/` | ClassroomBloc, ClassroomPage |
|
||
| 作业布置与批改 | `lib/features/assignment/` | AssignmentBloc, AssignmentRepository |
|
||
| 实时收笔展示 | `lib/features/realtime_ink/` | RealtimeInkBloc, InkMonitorPage |
|
||
| 家长端首页 | `lib/features/parent/home/` | ParentHomePage, ReportSummaryCard |
|
||
| 学情报告 | `lib/features/parent/report/` | ReportBloc, GrowthChartWidget |
|
||
| 书写回放 | `lib/features/parent/stroke_replay/` | StrokeReplayPage, StrokeReplayPainter |
|
||
| 消息通知 | `lib/features/message/` | MessageBloc, MessageListPage |
|
||
| 蓝牙连接笔 | `lib/features/bluetooth/` | BleBloc, BleRepository, DeviceScanPage |
|
||
| 学情数据图表 | `lib/features/analytics/` | AnalyticsPage, RadarChartWidget |
|
||
| 拍照搜题 | `lib/features/photo_ocr/` | PhotoOCRPage, PhotoOCRRepository |
|
||
| 个人中心 | `lib/features/profile/` | ProfilePage, SettingsPage |
|
||
| 离线缓存 | `lib/core/cache/` | CacheFirstRepository, OfflineQueue |
|
||
| 网络请求 | `lib/core/network/` | ApiClient, SSLPinningInterceptor |
|
||
| 本地数据库 | `lib/core/database/` | AppDatabase, DAOs |
|
||
| 推送通知 | `lib/core/notifications/` | PushNotificationService |
|
||
| BLoC公共基类 | `lib/core/bloc/` | BaseBloc, BaseState, BaseEvent |
|
||
| 路由管理 | `lib/core/router/` | AppRouter, RouteNames |
|
||
| 主题配置 | `lib/core/theme/` | AppTheme, ColorScheme |
|
||
|
||
### 5.2 核心类与方法说明
|
||
|
||
| 类名 | 所在文件 | 功能说明 |
|
||
|------|---------|---------|
|
||
| `AuthBloc` | `auth/bloc/auth_bloc.dart` | 认证状态管理,处理登录/登出/Token刷新 |
|
||
| `AuthRepository` | `auth/repository/auth_repository.dart` | 认证数据访问,JWT令牌存储管理 |
|
||
| `ClassroomBloc` | `classroom/bloc/classroom_bloc.dart` | 课堂互动状态管理 |
|
||
| `AssignmentBloc` | `assignment/bloc/assignment_bloc.dart` | 作业管理状态管理 |
|
||
| `AssignmentRepository` | `assignment/repository/assignment_repository.dart` | 作业数据访问(网络+本地缓存) |
|
||
| `ReportBloc` | `parent/report/bloc/report_bloc.dart` | 学情报告状态管理 |
|
||
| `StrokeReplayPainter` | `stroke_replay/painters/stroke_replay_painter.dart` | 笔迹回放Canvas渲染 |
|
||
| `BleBloc` | `bluetooth/bloc/ble_bloc.dart` | BLE蓝牙连接状态管理 |
|
||
| `BleRepository` | `bluetooth/repository/ble_repository.dart` | BLE设备扫描与连接管理 |
|
||
| `PushNotificationService` | `core/notifications/push_notification_service.dart` | FCM/APNs推送初始化与处理 |
|
||
| `SSLPinningInterceptor` | `core/network/ssl_pinning_interceptor.dart` | SSL证书绑定安全拦截器 |
|
||
| `CacheFirstRepository` | `core/cache/cache_first_repository.dart` | 缓存优先数据访问基类 |
|
||
| `AppDatabase` | `core/database/app_database.dart` | SQLite数据库初始化与迁移 |
|
||
|
||
### 5.3 状态管理架构说明
|
||
|
||
**BLoC事件定义示例(作业模块):**
|
||
|
||
```dart
|
||
// lib/features/assignment/bloc/assignment_event.dart
|
||
abstract class AssignmentEvent extends Equatable {}
|
||
|
||
class LoadAssignmentListEvent extends AssignmentEvent {
|
||
final String classId;
|
||
const LoadAssignmentListEvent({required this.classId});
|
||
@override List<Object?> get props => [classId];
|
||
}
|
||
|
||
class PublishAssignmentEvent extends AssignmentEvent {
|
||
final AssignmentData data;
|
||
const PublishAssignmentEvent({required this.data});
|
||
@override List<Object?> get props => [data];
|
||
}
|
||
|
||
class MarkGradedEvent extends AssignmentEvent {
|
||
final String assignmentId;
|
||
final String studentId;
|
||
final double score;
|
||
final String comment;
|
||
const MarkGradedEvent({
|
||
required this.assignmentId,
|
||
required this.studentId,
|
||
required this.score,
|
||
required this.comment,
|
||
});
|
||
@override List<Object?> get props =>
|
||
[assignmentId, studentId, score, comment];
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 附录A 界面设计稿(GUI Mockup)
|
||
|
||
本附录以手机竖屏线框图形式呈现手机APP各核心界面的设计稿,反映真实的界面布局与交互元素。
|
||
|
||
---
|
||
|
||
### A.1 登录界面
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ 09:41 ●●● WiFi │ 状态栏
|
||
├─────────────────────┤
|
||
│ │
|
||
│ 🖊 │
|
||
│ 自 然 写 │
|
||
│ Writech APP │
|
||
│ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 👤 手机号/账号 │ │
|
||
│ └─────────────────┘ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 🔒 密 码 │ │
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 立 即 登 录 │ │ 主按钮(品牌蓝)
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ ─────── 其他方式 ─── │
|
||
│ [微信登录] [钉钉登录] │
|
||
│ │
|
||
│ 教师端 / 家长端 │
|
||
├─────────────────────┤
|
||
│ © 2026 自然写科技 │
|
||
└─────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### A.2 教师端首页(课堂列表)
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ 09:41 │
|
||
├─────────────────────┤
|
||
│ 早上好,李老师 │
|
||
│ 今日课堂:3节 │
|
||
├─────────────────────┤
|
||
│ ┌─────────────────┐ │
|
||
│ │ ▶ 立即开始课堂 │ │ 绿色操作按钮
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ 今日课程安排 │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 08:00 语文 │ │
|
||
│ │ 高一(3)班·45人 │ │
|
||
│ │ 状态: ✅ 已完成 │ │
|
||
│ └─────────────────┘ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 10:00 数学 │ │
|
||
│ │ 高一(3)班·45人 │ │
|
||
│ │ 状态: ⏳ 进行中 │ │ 高亮
|
||
│ └─────────────────┘ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 14:00 英语 │ │
|
||
│ │ 高一(3)班·45人 │ │
|
||
│ │ 状态: ○ 未开始 │ │
|
||
│ └─────────────────┘ │
|
||
├─────────────────────┤
|
||
│ 🏠首页 📝作业 📊报表 👤我 │
|
||
└─────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### A.3 课堂互动主界面(教师端)
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ ◀ 数学课堂 ··· │
|
||
├─────────────────────┤
|
||
│ 高一(3)班 45/45人 │
|
||
│ ⏱ 00:23:45 进行中 │
|
||
├─────────────────────┤
|
||
│ 实时书写状态 │
|
||
│ ┌─────────────────┐ │
|
||
│ │ ██████░░░░ 38/45│ │ 进度条:已提交
|
||
│ │ 正在书写: 7人 │ │
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ 题目内容 │
|
||
│ ┌─────────────────┐ │
|
||
│ │ 解方程: │ │
|
||
│ │ 2x + 5 = 13 │ │
|
||
│ │ │ │
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ 操作区 │
|
||
│ ┌───────┐ ┌───────┐ │
|
||
│ │📤 收卷 │ │📊 批改│ │
|
||
│ └───────┘ └───────┘ │
|
||
│ ┌───────┐ ┌───────┐ │
|
||
│ │🔴 点名 │ │💬 评语│ │
|
||
│ └───────┘ └───────┘ │
|
||
├─────────────────────┤
|
||
│ [结束课堂] │
|
||
└─────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### A.4 作业批改界面(教师端)
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ ◀ 作业批改 │
|
||
├─────────────────────┤
|
||
│ 2月14日语文作业 │
|
||
│ 已提交 42/45 待批改 38 │
|
||
├─────────────────────┤
|
||
│ ┌─────────────────┐ │
|
||
│ │ 王小花 │ │
|
||
│ │ ┌─────────────┐ │ │
|
||
│ │ │ [手写笔迹 │ │ │
|
||
│ │ │ 图像区域] │ │ │
|
||
│ │ │ │ │ │
|
||
│ │ └─────────────┘ │ │
|
||
│ │ AI识别:正确 ✅ │ │
|
||
│ │ 识别内容:春眠不觉晓│ │
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ 批改操作 │
|
||
│ ┌──┐ ┌──┐ ┌──────┐ │
|
||
│ │✅│ │❌│ │半对 ◑ │ │
|
||
│ └──┘ └──┘ └──────┘ │
|
||
│ ┌─────────────────┐ │
|
||
│ │ ✏️ 添加批注... │ │
|
||
│ └─────────────────┘ │
|
||
│ [← 上一个] [下一个 →] │
|
||
└─────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### A.5 学情报告界面(家长端)
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ 09:41 │
|
||
├─────────────────────┤
|
||
│ 📊 孩子学情报告 │
|
||
│ 李小明 · 高一(3)班 │
|
||
├─────────────────────┤
|
||
│ 本周综合表现 │
|
||
│ │
|
||
│ 综合掌握度: 73.4% │
|
||
│ [████████████░░░░] │
|
||
│ │
|
||
│ 作业完成情况 │
|
||
│ 提交: 5/5 优秀: 3 │
|
||
│ 良好: 2 待提高: 0 │
|
||
│ │
|
||
│ 知识点进展 │
|
||
│ ┌─────────────────┐ │
|
||
│ │ ✅ 整数运算 95% │ │
|
||
│ │ ⚡ 一元方程 61% │ │
|
||
│ │ ⚠️ 二元方程 34% │ │
|
||
│ └─────────────────┘ │
|
||
│ │
|
||
│ 教师评语 │
|
||
│ ┌─────────────────┐ │
|
||
│ │"本周书写认真, │ │
|
||
│ │方程部分需多练习" │ │
|
||
│ └─────────────────┘ │
|
||
│ [下载完整报告] │
|
||
├─────────────────────┤
|
||
│ 🏠首页 📊报告 💬消息 👤我 │
|
||
└─────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
手机APP设计遵循以下设计规范:
|
||
|
||
- **色彩系统**:主色调为自然写品牌蓝(#1E6FFF),辅助色绿色(成功)、橙色(警告)、红色(错误)
|
||
- **字体**:系统字体(Android: Roboto + 思源黑体;iOS: SF Pro + PingFang SC)
|
||
- **间距系统**:基础间距单位8dp,组件间距16dp,页面内边距16dp
|
||
- **图标**:Material Design 3图标集(教师端)+ 自定义业务图标
|
||
- **适配**:支持深色模式、大字体模式(无障碍)、分屏模式(Android平板)
|
||
|
||
---
|
||
|
||
## 附录B 第三方SDK集成说明
|
||
|
||
| SDK | 版本 | 集成方式 | 功能 |
|
||
|-----|------|---------|------|
|
||
| Flutter SDK | 3.16.0 | Dart pubspec.yaml | 跨平台框架 |
|
||
| Firebase Messaging | 14.7.10 | pubspec.yaml + google-services.json | FCM推送(Android) |
|
||
| flutter_blue_plus | 1.31.7 | pubspec.yaml | BLE蓝牙通信 |
|
||
| Dio | 5.3.2 | pubspec.yaml | HTTP网络请求 |
|
||
| fl_chart | 0.66.0 | pubspec.yaml | 图表可视化 |
|
||
| 微信SDK | 3.5.6 | Android AAR / iOS Framework | 微信登录 |
|
||
| 钉钉SDK | 2.15 | Android AAR / iOS Framework | 钉钉登录 |
|
||
| camera | 0.10.5 | pubspec.yaml | 拍照搜题 |
|
||
|
||
---
|
||
|
||
## 附录C 术语表
|
||
|
||
| 术语 | 说明 |
|
||
|------|------|
|
||
| Flutter | Google开源的跨平台UI框架,单代码库编译Android和iOS |
|
||
| BLoC | Business Logic Component,Flutter状态管理模式 |
|
||
| MVVM | Model-View-ViewModel,Android推荐的架构模式 |
|
||
| Dart | Flutter使用的编程语言 |
|
||
| JWT | JSON Web Token,无状态身份认证令牌 |
|
||
| BLE | Bluetooth Low Energy,低功耗蓝牙(点阵笔通信协议) |
|
||
| GATT | Generic Attribute Profile,BLE应用层协议 |
|
||
| FCM | Firebase Cloud Messaging,Google推送服务 |
|
||
| APNs | Apple Push Notification service,苹果推送服务 |
|
||
| Certificate Pinning | 证书绑定,防止中间人攻击的安全措施 |
|
||
| CustomPainter | Flutter自定义Canvas绘制接口(用于笔迹渲染) |
|
||
| sqflite | Flutter SQLite本地数据库插件 |
|
||
|
||
---
|
||
|
||
## 附录D 版本历史
|
||
|
||
| 版本 | 日期 | 平台 | 变更说明 | 编制人 |
|
||
|------|------|------|---------|--------|
|
||
| V0.6 Beta | 2025-08-15 | Android/iOS | 基础功能:登录、作业列表、学情报告MVP | 研发团队 |
|
||
| V0.9 RC | 2025-11-30 | Android/iOS | 书写回放、BLE连接、消息通知、拍照搜题 | 研发团队 |
|
||
| V1.0 | 2026-02-14 | Android/iOS | 正式版:打卡功能、深色模式、无障碍优化、性能优化 | 研发团队 |
|
||
|
||
---
|
||
|
||
*文档编制:深圳自然写科技有限公司 移动端研发团队*
|
||
*文档版本:V1.0*
|
||
*最后更新:2026年2月14日*
|
||
*版权所有 © 2026 深圳自然写科技有限公司*
|
||
|
||
---
|
||
|
||
## 附录E 核心技术实现详述
|
||
|
||
### E.1 Flutter BLoC状态管理架构
|
||
|
||
手机APP采用BLoC(Business Logic Component)模式严格分离UI与业务逻辑,确保代码可测试性和可维护性。
|
||
|
||
#### E.1.1 作业模块BLoC实现
|
||
|
||
```dart
|
||
// lib/features/homework/bloc/homework_bloc.dart
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import '../repository/homework_repository.dart';
|
||
import 'homework_event.dart';
|
||
import 'homework_state.dart';
|
||
|
||
class HomeworkBloc extends Bloc<HomeworkEvent, HomeworkState> {
|
||
final HomeworkRepository _repository;
|
||
|
||
HomeworkBloc({required HomeworkRepository repository})
|
||
: _repository = repository,
|
||
super(HomeworkInitial()) {
|
||
on<LoadHomeworkList>(_onLoadHomeworkList);
|
||
on<SubmitHomework>(_onSubmitHomework);
|
||
on<LoadHomeworkDetail>(_onLoadHomeworkDetail);
|
||
on<RefreshHomework>(_onRefreshHomework);
|
||
}
|
||
|
||
Future<void> _onLoadHomeworkList(
|
||
LoadHomeworkList event,
|
||
Emitter<HomeworkState> emit,
|
||
) async {
|
||
emit(HomeworkLoading());
|
||
try {
|
||
final homeworks = await _repository.getHomeworkList(
|
||
page: event.page,
|
||
status: event.status,
|
||
);
|
||
emit(HomeworkListLoaded(homeworks: homeworks, hasMore: homeworks.length >= 20));
|
||
} on NetworkException catch (e) {
|
||
// 网络异常时尝试从本地缓存加载
|
||
final cached = await _repository.getCachedHomeworkList();
|
||
if (cached.isNotEmpty) {
|
||
emit(HomeworkListLoaded(homeworks: cached, fromCache: true));
|
||
} else {
|
||
emit(HomeworkError(message: '网络连接失败:${e.message}'));
|
||
}
|
||
} catch (e) {
|
||
emit(HomeworkError(message: e.toString()));
|
||
}
|
||
}
|
||
|
||
Future<void> _onSubmitHomework(
|
||
SubmitHomework event,
|
||
Emitter<HomeworkState> emit,
|
||
) async {
|
||
emit(HomeworkSubmitting());
|
||
try {
|
||
// 1. 压缩笔迹数据
|
||
final compressedData = await _repository.compressInkData(event.inkData);
|
||
|
||
// 2. 上传笔迹(支持断点续传)
|
||
final uploadResult = await _repository.uploadInkData(
|
||
homeworkId: event.homeworkId,
|
||
inkData: compressedData,
|
||
onProgress: (sent, total) {
|
||
emit(HomeworkUploadProgress(progress: sent / total));
|
||
},
|
||
);
|
||
|
||
// 3. 提交作业记录
|
||
await _repository.submitHomework(
|
||
homeworkId: event.homeworkId,
|
||
inkDataUrl: uploadResult.url,
|
||
submitTime: DateTime.now(),
|
||
);
|
||
|
||
emit(HomeworkSubmitSuccess(homeworkId: event.homeworkId));
|
||
} on UploadException catch (e) {
|
||
emit(HomeworkError(message: '上传失败,请重试:${e.message}'));
|
||
}
|
||
}
|
||
}
|
||
|
||
// lib/features/homework/bloc/homework_event.dart
|
||
abstract class HomeworkEvent {}
|
||
|
||
class LoadHomeworkList extends HomeworkEvent {
|
||
final int page;
|
||
final HomeworkStatus? status;
|
||
LoadHomeworkList({this.page = 1, this.status});
|
||
}
|
||
|
||
class SubmitHomework extends HomeworkEvent {
|
||
final String homeworkId;
|
||
final List<InkStroke> inkData;
|
||
SubmitHomework({required this.homeworkId, required this.inkData});
|
||
}
|
||
|
||
class LoadHomeworkDetail extends HomeworkEvent {
|
||
final String homeworkId;
|
||
LoadHomeworkDetail({required this.homeworkId});
|
||
}
|
||
|
||
class RefreshHomework extends HomeworkEvent {}
|
||
|
||
// lib/features/homework/bloc/homework_state.dart
|
||
abstract class HomeworkState {}
|
||
|
||
class HomeworkInitial extends HomeworkState {}
|
||
class HomeworkLoading extends HomeworkState {}
|
||
|
||
class HomeworkListLoaded extends HomeworkState {
|
||
final List<HomeworkItem> homeworks;
|
||
final bool hasMore;
|
||
final bool fromCache;
|
||
HomeworkListLoaded({
|
||
required this.homeworks,
|
||
this.hasMore = false,
|
||
this.fromCache = false,
|
||
});
|
||
}
|
||
|
||
class HomeworkSubmitting extends HomeworkState {}
|
||
|
||
class HomeworkUploadProgress extends HomeworkState {
|
||
final double progress; // 0.0 ~ 1.0
|
||
HomeworkUploadProgress({required this.progress});
|
||
}
|
||
|
||
class HomeworkSubmitSuccess extends HomeworkState {
|
||
final String homeworkId;
|
||
HomeworkSubmitSuccess({required this.homeworkId});
|
||
}
|
||
|
||
class HomeworkError extends HomeworkState {
|
||
final String message;
|
||
HomeworkError({required this.message});
|
||
}
|
||
```
|
||
|
||
### E.2 手写作业提交完整流程
|
||
|
||
#### E.2.1 InkCanvas书写组件
|
||
|
||
```dart
|
||
// lib/features/homework/widgets/ink_canvas_widget.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/gestures.dart';
|
||
|
||
class InkCanvasWidget extends StatefulWidget {
|
||
final double width;
|
||
final double height;
|
||
final Function(List<InkStroke>) onStrokesChanged;
|
||
final bool readonly;
|
||
final List<InkStroke> initialStrokes;
|
||
|
||
const InkCanvasWidget({
|
||
required this.width,
|
||
required this.height,
|
||
required this.onStrokesChanged,
|
||
this.readonly = false,
|
||
this.initialStrokes = const [],
|
||
super.key,
|
||
});
|
||
|
||
@override
|
||
State<InkCanvasWidget> createState() => _InkCanvasWidgetState();
|
||
}
|
||
|
||
class _InkCanvasWidgetState extends State<InkCanvasWidget> {
|
||
final List<InkStroke> _strokes = [];
|
||
InkStroke? _currentStroke;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_strokes.addAll(widget.initialStrokes);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Listener(
|
||
onPointerDown: _onPointerDown,
|
||
onPointerMove: _onPointerMove,
|
||
onPointerUp: _onPointerUp,
|
||
child: CustomPaint(
|
||
size: Size(widget.width, widget.height),
|
||
painter: InkStrokePainter(
|
||
strokes: _strokes,
|
||
currentStroke: _currentStroke,
|
||
),
|
||
child: Container(
|
||
width: widget.width,
|
||
height: widget.height,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _onPointerDown(PointerDownEvent event) {
|
||
if (widget.readonly) return;
|
||
// 兼容手写笔(Stylus)的压力感应
|
||
final pressure = event.pressure;
|
||
_currentStroke = InkStroke(
|
||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||
points: [InkPoint(
|
||
x: event.localPosition.dx / widget.width,
|
||
y: event.localPosition.dy / widget.height,
|
||
pressure: pressure,
|
||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||
)],
|
||
color: Colors.black,
|
||
);
|
||
setState(() {});
|
||
}
|
||
|
||
void _onPointerMove(PointerMoveEvent event) {
|
||
if (widget.readonly || _currentStroke == null) return;
|
||
_currentStroke!.points.add(InkPoint(
|
||
x: event.localPosition.dx / widget.width,
|
||
y: event.localPosition.dy / widget.height,
|
||
pressure: event.pressure,
|
||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||
));
|
||
setState(() {});
|
||
}
|
||
|
||
void _onPointerUp(PointerUpEvent event) {
|
||
if (widget.readonly || _currentStroke == null) return;
|
||
_strokes.add(_currentStroke!);
|
||
_currentStroke = null;
|
||
widget.onStrokesChanged(List.unmodifiable(_strokes));
|
||
setState(() {});
|
||
}
|
||
|
||
void clearAll() {
|
||
_strokes.clear();
|
||
_currentStroke = null;
|
||
widget.onStrokesChanged([]);
|
||
setState(() {});
|
||
}
|
||
|
||
void undo() {
|
||
if (_strokes.isEmpty) return;
|
||
_strokes.removeLast();
|
||
widget.onStrokesChanged(List.unmodifiable(_strokes));
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
// CustomPainter实现贝塞尔曲线平滑渲染
|
||
class InkStrokePainter extends CustomPainter {
|
||
final List<InkStroke> strokes;
|
||
final InkStroke? currentStroke;
|
||
|
||
const InkStrokePainter({required this.strokes, this.currentStroke});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
for (final stroke in [...strokes, if (currentStroke != null) currentStroke!]) {
|
||
_drawStroke(canvas, size, stroke);
|
||
}
|
||
}
|
||
|
||
void _drawStroke(Canvas canvas, Size size, InkStroke stroke) {
|
||
if (stroke.points.length < 2) return;
|
||
final paint = Paint()
|
||
..color = stroke.color
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round
|
||
..style = PaintingStyle.stroke;
|
||
|
||
final path = Path();
|
||
final pts = stroke.points;
|
||
path.moveTo(pts[0].x * size.width, pts[0].y * size.height);
|
||
|
||
for (int i = 1; i < pts.length - 1; i++) {
|
||
final midX = (pts[i].x + pts[i+1].x) / 2 * size.width;
|
||
final midY = (pts[i].y + pts[i+1].y) / 2 * size.height;
|
||
path.quadraticBezierTo(
|
||
pts[i].x * size.width, pts[i].y * size.height, midX, midY
|
||
);
|
||
paint.strokeWidth = 1.5 + pts[i].pressure * 2.5;
|
||
canvas.drawPath(path, paint);
|
||
path.reset();
|
||
path.moveTo(midX, midY);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(InkStrokePainter old) =>
|
||
strokes != old.strokes || currentStroke != old.currentStroke;
|
||
}
|
||
```
|
||
|
||
### E.3 学情报告图表展示
|
||
|
||
#### E.3.1 折线图与柱状图实现(fl_chart)
|
||
|
||
```dart
|
||
// lib/features/report/widgets/score_trend_chart.dart
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
class ScoreTrendChart extends StatelessWidget {
|
||
final List<ScoreRecord> scores;
|
||
final String title;
|
||
|
||
const ScoreTrendChart({
|
||
required this.scores,
|
||
required this.title,
|
||
super.key,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Text(title,
|
||
style: Theme.of(context).textTheme.titleMedium),
|
||
),
|
||
SizedBox(
|
||
height: 200,
|
||
child: LineChart(
|
||
LineChartData(
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
getDrawingHorizontalLine: (value) => FlLine(
|
||
color: Colors.grey.shade200,
|
||
strokeWidth: 1,
|
||
),
|
||
),
|
||
titlesData: FlTitlesData(
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
getTitlesWidget: (value, meta) {
|
||
final index = value.toInt();
|
||
if (index < 0 || index >= scores.length) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Text(scores[index].dateLabel,
|
||
style: const TextStyle(fontSize: 10));
|
||
},
|
||
),
|
||
),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 35,
|
||
getTitlesWidget: (value, meta) =>
|
||
Text('${value.toInt()}', style: const TextStyle(fontSize: 10)),
|
||
),
|
||
),
|
||
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
),
|
||
borderData: FlBorderData(
|
||
show: true,
|
||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||
),
|
||
minY: 0,
|
||
maxY: 100,
|
||
lineBarsData: [
|
||
LineChartBarData(
|
||
spots: scores.asMap().entries.map((e) =>
|
||
FlSpot(e.key.toDouble(), e.value.score)).toList(),
|
||
isCurved: true,
|
||
color: Theme.of(context).primaryColor,
|
||
barWidth: 2,
|
||
dotData: FlDotData(
|
||
show: true,
|
||
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
||
radius: 4,
|
||
color: Colors.white,
|
||
strokeWidth: 2,
|
||
strokeColor: Theme.of(context).primaryColor,
|
||
),
|
||
),
|
||
belowBarData: BarAreaData(
|
||
show: true,
|
||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### E.4 推送通知与消息模块
|
||
|
||
#### E.4.1 FCM消息处理(Android/iOS统一)
|
||
|
||
```dart
|
||
// lib/core/notification/notification_service.dart
|
||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||
|
||
class NotificationService {
|
||
static final NotificationService _instance = NotificationService._internal();
|
||
factory NotificationService() => _instance;
|
||
NotificationService._internal();
|
||
|
||
final FirebaseMessaging _fcm = FirebaseMessaging.instance;
|
||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||
FlutterLocalNotificationsPlugin();
|
||
|
||
Future<void> initialize() async {
|
||
// 请求通知权限
|
||
final settings = await _fcm.requestPermission(
|
||
alert: true,
|
||
badge: true,
|
||
sound: true,
|
||
);
|
||
|
||
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
|
||
// 获取FCM Token,上传至服务器用于推送
|
||
final token = await _fcm.getToken();
|
||
if (token != null) {
|
||
await _uploadFcmToken(token);
|
||
}
|
||
|
||
// 监听Token刷新
|
||
_fcm.onTokenRefresh.listen((newToken) {
|
||
_uploadFcmToken(newToken);
|
||
});
|
||
|
||
// 处理前台消息(应用在前台时不自动弹通知,需手动显示)
|
||
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
||
|
||
// 处理通知点击(应用在后台时)
|
||
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
|
||
|
||
// 处理应用终止时收到的通知
|
||
final initialMessage = await _fcm.getInitialMessage();
|
||
if (initialMessage != null) {
|
||
_handleNotificationTap(initialMessage);
|
||
}
|
||
}
|
||
|
||
// 初始化本地通知(用于前台消息展示)
|
||
await _initLocalNotifications();
|
||
}
|
||
|
||
void _handleForegroundMessage(RemoteMessage message) {
|
||
final notification = message.notification;
|
||
if (notification == null) return;
|
||
|
||
final notificationType = message.data['type'] ?? 'general';
|
||
_showLocalNotification(
|
||
id: message.hashCode,
|
||
title: notification.title ?? '自然写',
|
||
body: notification.body ?? '',
|
||
payload: message.data['payload'],
|
||
channelId: _getChannelId(notificationType),
|
||
);
|
||
}
|
||
|
||
void _handleNotificationTap(RemoteMessage message) {
|
||
final type = message.data['type'];
|
||
final id = message.data['id'];
|
||
switch (type) {
|
||
case 'homework_graded':
|
||
// 跳转到作业批改结果页
|
||
NavigationService.instance.navigateTo('/homework/result/$id');
|
||
break;
|
||
case 'classroom_invite':
|
||
// 跳转到课堂加入页
|
||
NavigationService.instance.navigateTo('/classroom/join/$id');
|
||
break;
|
||
case 'message':
|
||
// 跳转到消息详情
|
||
NavigationService.instance.navigateTo('/messages/$id');
|
||
break;
|
||
}
|
||
}
|
||
|
||
Future<void> _initLocalNotifications() async {
|
||
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||
const iosInit = DarwinInitializationSettings(
|
||
requestAlertPermission: false,
|
||
requestBadgePermission: false,
|
||
requestSoundPermission: false,
|
||
);
|
||
await _localNotifications.initialize(
|
||
const InitializationSettings(android: androidInit, iOS: iosInit),
|
||
onDidReceiveNotificationResponse: (response) {
|
||
if (response.payload != null) {
|
||
_handleLocalNotificationTap(response.payload!);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _showLocalNotification({
|
||
required int id,
|
||
required String title,
|
||
required String body,
|
||
String? payload,
|
||
String channelId = 'general',
|
||
}) async {
|
||
final androidDetails = AndroidNotificationDetails(
|
||
channelId,
|
||
_getChannelName(channelId),
|
||
importance: Importance.high,
|
||
priority: Priority.high,
|
||
icon: '@mipmap/ic_launcher',
|
||
);
|
||
const iosDetails = DarwinNotificationDetails(
|
||
presentAlert: true,
|
||
presentBadge: true,
|
||
presentSound: true,
|
||
);
|
||
await _localNotifications.show(
|
||
id, title, body,
|
||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||
payload: payload,
|
||
);
|
||
}
|
||
|
||
String _getChannelId(String type) {
|
||
switch (type) {
|
||
case 'homework_graded': return 'homework';
|
||
case 'classroom_invite': return 'classroom';
|
||
default: return 'general';
|
||
}
|
||
}
|
||
|
||
String _getChannelName(String channelId) {
|
||
switch (channelId) {
|
||
case 'homework': return '作业通知';
|
||
case 'classroom': return '课堂通知';
|
||
default: return '通用通知';
|
||
}
|
||
}
|
||
|
||
Future<void> _uploadFcmToken(String token) async {
|
||
// 上传Token到服务器(用于定向推送)
|
||
await ApiClient.instance.post('/api/v1/device/token', {
|
||
'token': token,
|
||
'platform': Platform.isAndroid ? 'android' : 'ios',
|
||
'appVersion': AppInfo.version,
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### E.5 打卡功能实现
|
||
|
||
#### E.5.1 作业打卡连续天数统计
|
||
|
||
```dart
|
||
// lib/features/checkin/checkin_service.dart
|
||
class CheckinService {
|
||
|
||
// 贝叶斯知识追踪:根据打卡质量更新知识掌握度
|
||
double updateMasteryBKT({
|
||
required double currentMastery,
|
||
required double quality, // 0.0~1.0 本次打卡质量
|
||
}) {
|
||
const pTransit = 0.1; // 知识迁移概率
|
||
const pSlip = 0.08; // 已掌握却答错(遗忘)概率
|
||
const pGuess = 0.2; // 未掌握却答对(猜测)概率
|
||
|
||
final bool correct = quality > 0.6; // 质量阈值:>60%视为掌握
|
||
final pCorrect = currentMastery * (1 - pSlip) + (1 - currentMastery) * pGuess;
|
||
|
||
double updatedMastery;
|
||
if (correct) {
|
||
updatedMastery = (currentMastery * (1 - pSlip)) / pCorrect;
|
||
} else {
|
||
updatedMastery = (currentMastery * pSlip) / (1 - pCorrect);
|
||
}
|
||
// 叠加知识迁移
|
||
return updatedMastery + (1 - updatedMastery) * pTransit;
|
||
}
|
||
|
||
// 计算Leitner间隔复习下次打卡日期
|
||
DateTime calcNextReviewDate(int currentBox, DateTime lastReviewDate) {
|
||
const boxIntervals = [1, 2, 4, 8, 16, 999]; // 天数间隔
|
||
final interval = currentBox < boxIntervals.length
|
||
? boxIntervals[currentBox]
|
||
: boxIntervals.last;
|
||
return lastReviewDate.add(Duration(days: interval));
|
||
}
|
||
|
||
// 计算连续打卡天数
|
||
int calcConsecutiveDays(List<DateTime> checkinDates) {
|
||
if (checkinDates.isEmpty) return 0;
|
||
final sorted = checkinDates
|
||
.map((d) => DateTime(d.year, d.month, d.day))
|
||
.toSet()
|
||
.toList()
|
||
..sort((a, b) => b.compareTo(a)); // 降序
|
||
|
||
int consecutive = 1;
|
||
for (int i = 1; i < sorted.length; i++) {
|
||
final diff = sorted[i-1].difference(sorted[i]).inDays;
|
||
if (diff == 1) {
|
||
consecutive++;
|
||
} else {
|
||
break; // 断链
|
||
}
|
||
}
|
||
return consecutive;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 附录F 接口清单与权限说明
|
||
|
||
### F.1 关键API接口
|
||
|
||
| 接口路径 | 方法 | 说明 | 认证 |
|
||
|---------|------|------|------|
|
||
| /api/v1/auth/login | POST | 账号密码登录,返回JWT Token | 无 |
|
||
| /api/v1/auth/refresh | POST | 刷新JWT Token | Token |
|
||
| /api/v1/homework/list | GET | 获取作业列表,支持分页与状态过滤 | Token |
|
||
| /api/v1/homework/{id} | GET | 获取作业详情(含批改结果) | Token |
|
||
| /api/v1/homework/{id}/submit | POST | 提交作业(上传笔迹OSS链接) | Token |
|
||
| /api/v1/ink/upload | POST | 上传笔迹数据(分片上传) | Token |
|
||
| /api/v1/report/student | GET | 获取个人学情报告 | Token |
|
||
| /api/v1/report/class | GET | 获取班级学情报告(教师权限) | Token |
|
||
| /api/v1/classroom/join | POST | 加入课堂(通过课堂码) | Token |
|
||
| /api/v1/device/token | PUT | 更新FCM推送Token | Token |
|
||
| /api/v1/messages | GET | 获取消息列表(分页) | Token |
|
||
| /api/v1/messages/{id}/read | PUT | 标记消息已读 | Token |
|
||
|
||
### F.2 Android权限说明
|
||
|
||
| 权限 | 用途 | 是否必需 |
|
||
|------|------|---------|
|
||
| INTERNET | 网络请求、上传笔迹数据 | 必需 |
|
||
| BLUETOOTH_SCAN | 扫描附近BLE智能笔 | 可选(有笔时必需) |
|
||
| BLUETOOTH_CONNECT | 连接BLE智能笔 | 可选(有笔时必需) |
|
||
| CAMERA | 拍照上传作业、扫二维码 | 可选 |
|
||
| READ_MEDIA_IMAGES | 从相册选择图片 | 可选 |
|
||
| POST_NOTIFICATIONS | 接收作业批改通知 | 可选(Android 13+) |
|
||
| VIBRATE | 打卡/提交成功震动反馈 | 可选 |
|
||
| USE_BIOMETRIC | 指纹/面容解锁应用 | 可选 |
|
||
|
||
### F.3 iOS Info.plist权限说明
|
||
|
||
| 键名 | 说明 |
|
||
|------|------|
|
||
| NSBluetoothAlwaysUsageDescription | 连接自然写智能笔,需要访问蓝牙 |
|
||
| NSCameraUsageDescription | 拍摄作业照片或扫描课堂码,需要相机权限 |
|
||
| NSPhotoLibraryUsageDescription | 从相册选择作业图片 |
|
||
| NSFaceIDUsageDescription | 使用Face ID快速登录 |
|
||
| NSUserNotificationsUsageDescription | 接收作业批改和课堂提醒通知 |
|
||
|
||
---
|
||
|
||
*文档编制:深圳自然写科技有限公司 移动端研发团队*
|
||
*文档版本:V1.0(附录更新)*
|
||
*最后更新:2026年2月14日*
|
||
*版权所有 © 2026 深圳自然写科技有限公司*
|
||
|
||
---
|
||
|
||
## 附录G 性能指标与兼容性
|
||
|
||
### G.1 性能基准测试
|
||
|
||
| 测试场景 | 设备 | 系统 | 结果 |
|
||
|---------|------|------|------|
|
||
| 冷启动时间 | iPhone 14 Pro | iOS 16 | 1.2秒 |
|
||
| 冷启动时间 | Pixel 7 (Tensor G2) | Android 13 | 1.6秒 |
|
||
| 作业列表加载(100条) | iPhone 14 | iOS 16 | 180ms |
|
||
| 笔迹渲染帧率(BLE书写) | iPad Air 5 | iPadOS 16 | 60fps |
|
||
| 笔迹图片上传(单页作业) | WiFi 50Mbps | - | 1.1秒 |
|
||
| FCM推送到达延迟 | WiFi环境 | - | < 300ms |
|
||
| 离线模式笔迹保存 | - | - | < 10ms/笔画 |
|
||
| BLoC状态重建耗时 | 平均 | - | 32ms |
|
||
|
||
### G.2 手机APP支持设备
|
||
|
||
| 平台 | 最低版本 | 推荐版本 | 必需特性 |
|
||
|------|---------|---------|---------|
|
||
| Android | Android 7.0 (API 24) | Android 12+ | 蓝牙BLE 4.2+ |
|
||
| iOS | iOS 13.0 | iOS 16+ | CoreBluetooth |
|
||
|
||
### G.3 主要第三方依赖
|
||
|
||
**Android (Gradle)**
|
||
|
||
| 依赖 | 版本 | 用途 |
|
||
|------|------|------|
|
||
| flutter_blue_plus | 1.x | BLE蓝牙连接 |
|
||
| flutter_bloc | 8.x | BLoC状态管理 |
|
||
| firebase_messaging | 14.x | FCM推送通知 |
|
||
| google_mlkit_face_detection | 0.x | 护眼距离检测 |
|
||
| sqflite | 2.x | SQLite本地数据库 |
|
||
| hive | 2.x | 键值本地存储 |
|
||
| fl_chart | 0.x | 数据图表渲染 |
|
||
| dio | 5.x | HTTP客户端 |
|
||
| flutter_local_notifications | 16.x | 本地通知 |
|
||
| image_picker | 1.x | 相机/相册选图 |
|
||
|
||
### G.4 源代码目录结构
|
||
|
||
```
|
||
lib/
|
||
├── main.dart # 应用入口
|
||
├── app.dart # MaterialApp配置、路由、主题
|
||
├── core/ # 核心模块
|
||
│ ├── api/ # HTTP请求封装(Dio拦截器)
|
||
│ ├── auth/ # JWT认证管理
|
||
│ ├── ble/ # BLE连接管理(PenBleManager)
|
||
│ ├── notification/ # FCM推送处理
|
||
│ ├── navigation/ # 路由导航服务
|
||
│ └── storage/ # 本地存储(Hive + sqflite)
|
||
├── features/ # 功能模块(BLoC分层)
|
||
│ ├── auth/ # 登录/登出
|
||
│ │ ├── bloc/ # LoginBloc, AuthState, AuthEvent
|
||
│ │ ├── repository/ # AuthRepository
|
||
│ │ └── pages/ # LoginPage, SplashPage
|
||
│ ├── homework/ # 作业功能
|
||
│ │ ├── bloc/ # HomeworkBloc
|
||
│ │ ├── repository/ # HomeworkRepository
|
||
│ │ └── pages/ # HomeworkListPage, HomeworkDetailPage
|
||
│ ├── classroom/ # 课堂功能
|
||
│ │ ├── bloc/ # ClassroomBloc
|
||
│ │ └── pages/ # JoinClassroomPage, ClassroomPage
|
||
│ ├── report/ # 学情报告
|
||
│ │ └── pages/ # ReportPage, StudentReportPage
|
||
│ ├── checkin/ # 打卡功能
|
||
│ │ └── pages/ # CheckinPage, CheckinHistoryPage
|
||
│ └── message/ # 消息中心
|
||
│ └── pages/ # MessageListPage, MessageDetailPage
|
||
└── shared/ # 通用组件
|
||
├── widgets/ # InkCanvas, ScoreChart, AvatarWidget...
|
||
└── utils/ # 工具函数、常量定义
|
||
```
|
||
|
||
---
|
||
|
||
*本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。*
|
||
|
||
---
|
||
|
||
## 附录G 补充技术规格
|
||
|
||
### G.1 iOS端Swift实现
|
||
|
||
#### G.1.1 CoreBluetooth智能笔连接
|
||
|
||
```swift
|
||
// PenBLEManager.swift
|
||
import CoreBluetooth
|
||
|
||
class PenBLEManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
||
private var centralManager: CBCentralManager!
|
||
private var connectedPen: CBPeripheral?
|
||
private var inkCharacteristic: CBCharacteristic?
|
||
|
||
// WritechPen GATT服务UUID
|
||
private let SERVICE_UUID = CBUUID(string: "12345678-1234-5678-1234-56789ABCDEF0")
|
||
private let INK_CHAR_UUID = CBUUID(string: "12345678-1234-5678-1234-56789ABCDEF1")
|
||
|
||
var onInkData: (([InkPoint]) -> Void)?
|
||
var onConnectionChanged: ((Bool) -> Void)?
|
||
|
||
override init() {
|
||
super.init()
|
||
centralManager = CBCentralManager(delegate: self, queue: .main)
|
||
}
|
||
|
||
func startScan() {
|
||
guard centralManager.state == .poweredOn else { return }
|
||
centralManager.scanForPeripherals(
|
||
withServices: [SERVICE_UUID],
|
||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||
)
|
||
}
|
||
|
||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||
if central.state == .poweredOn { startScan() }
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didDiscover peripheral: CBPeripheral,
|
||
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||
guard let name = peripheral.name, name.hasPrefix("WritechPen") else { return }
|
||
central.stopScan()
|
||
connectedPen = peripheral
|
||
connectedPen?.delegate = self
|
||
central.connect(peripheral, options: nil)
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didConnect peripheral: CBPeripheral) {
|
||
peripheral.discoverServices([SERVICE_UUID])
|
||
onConnectionChanged?(true)
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral,
|
||
didDiscoverServices error: Error?) {
|
||
peripheral.services?.first?.let { service in
|
||
peripheral.discoverCharacteristics([INK_CHAR_UUID], for: service)
|
||
}
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral,
|
||
didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||
if let char = service.characteristics?.first(where: { $0.uuid == INK_CHAR_UUID }) {
|
||
inkCharacteristic = char
|
||
peripheral.setNotifyValue(true, for: char)
|
||
}
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral,
|
||
didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||
guard let data = characteristic.value else { return }
|
||
let points = parseInkData(data)
|
||
onInkData?(points)
|
||
}
|
||
|
||
private func parseInkData(_ data: Data) -> [InkPoint] {
|
||
var points: [InkPoint] = []
|
||
var offset = 0
|
||
while offset + 10 <= data.count {
|
||
let x = Float(UInt16(data[offset]) << 8 | UInt16(data[offset+1])) / 65535.0
|
||
let y = Float(UInt16(data[offset+2]) << 8 | UInt16(data[offset+3])) / 65535.0
|
||
let pressure = Float(data[offset+4]) / 255.0
|
||
points.append(InkPoint(x: x, y: y, pressure: pressure))
|
||
offset += 10
|
||
}
|
||
return points
|
||
}
|
||
}
|
||
```
|
||
|
||
### G.2 推送通知实现
|
||
|
||
```swift
|
||
// NotificationManager.swift
|
||
import UserNotifications
|
||
|
||
class NotificationManager {
|
||
func requestPermission() async -> Bool {
|
||
let center = UNUserNotificationCenter.current()
|
||
do {
|
||
return try await center.requestAuthorization(
|
||
options: [.alert, .badge, .sound]
|
||
)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
func scheduleHomeworkReminder(homework: Homework) {
|
||
let content = UNMutableNotificationContent()
|
||
content.title = "作业提醒"
|
||
content.body = "《\(homework.title)》截止时间:\(homework.deadline.formatted())"
|
||
content.sound = .default
|
||
content.badge = 1
|
||
|
||
// 截止前2小时提醒
|
||
let triggerDate = homework.deadline.addingTimeInterval(-7200)
|
||
let components = Calendar.current.dateComponents(
|
||
[.year, .month, .day, .hour, .minute], from: triggerDate)
|
||
let trigger = UNCalendarNotificationTrigger(
|
||
dateMatching: components, repeats: false)
|
||
|
||
let request = UNNotificationRequest(
|
||
identifier: "homework_\(homework.id)",
|
||
content: content,
|
||
trigger: trigger
|
||
)
|
||
|
||
UNUserNotificationCenter.current().add(request)
|
||
}
|
||
}
|
||
```
|
||
|
||
### G.3 Android端ViewModel架构
|
||
|
||
```kotlin
|
||
// HomeworkViewModel.kt
|
||
class HomeworkViewModel(
|
||
private val homeworkRepo: HomeworkRepository
|
||
) : ViewModel() {
|
||
|
||
private val _homeworkList = MutableStateFlow<List<Homework>>(emptyList())
|
||
val homeworkList: StateFlow<List<Homework>> = _homeworkList.asStateFlow()
|
||
|
||
private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
|
||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||
|
||
fun loadHomework(courseId: String) {
|
||
viewModelScope.launch {
|
||
_uiState.value = UiState.Loading
|
||
try {
|
||
homeworkRepo.getHomeworkList(courseId)
|
||
.collect { list ->
|
||
_homeworkList.value = list
|
||
_uiState.value = UiState.Success
|
||
}
|
||
} catch (e: Exception) {
|
||
_uiState.value = UiState.Error(e.message ?: "加载失败")
|
||
}
|
||
}
|
||
}
|
||
|
||
fun submitHomework(homeworkId: String, inkData: ByteArray) {
|
||
viewModelScope.launch {
|
||
_uiState.value = UiState.Uploading
|
||
try {
|
||
homeworkRepo.submitHomework(homeworkId, inkData)
|
||
_uiState.value = UiState.Submitted
|
||
} catch (e: Exception) {
|
||
_uiState.value = UiState.Error(e.message ?: "提交失败")
|
||
}
|
||
}
|
||
}
|
||
|
||
sealed class UiState {
|
||
object Idle : UiState()
|
||
object Loading : UiState()
|
||
object Uploading : UiState()
|
||
object Success : UiState()
|
||
object Submitted : UiState()
|
||
data class Error(val message: String) : UiState()
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 附录H 补充技术规格
|
||
|
||
### H.1 图片压缩上传
|
||
|
||
```kotlin
|
||
// ImageCompressUploader.kt
|
||
class ImageCompressUploader(private val apiService: ApiService) {
|
||
|
||
companion object {
|
||
const val MAX_SIZE_BYTES = 2 * 1024 * 1024 // 2MB
|
||
const val INITIAL_QUALITY = 90
|
||
}
|
||
|
||
suspend fun compressAndUpload(
|
||
uri: Uri,
|
||
context: Context,
|
||
targetUrl: String
|
||
): UploadResult = withContext(Dispatchers.IO) {
|
||
val bitmap = BitmapFactory.decodeStream(
|
||
context.contentResolver.openInputStream(uri))
|
||
|
||
var quality = INITIAL_QUALITY
|
||
var compressedBytes: ByteArray
|
||
|
||
// 循环压缩直到文件大小≤2MB
|
||
do {
|
||
val baos = ByteArrayOutputStream()
|
||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos)
|
||
compressedBytes = baos.toByteArray()
|
||
quality -= 10
|
||
} while (compressedBytes.size > MAX_SIZE_BYTES && quality > 10)
|
||
|
||
// 上传
|
||
val requestBody = compressedBytes.toRequestBody("image/jpeg".toMediaType())
|
||
val part = MultipartBody.Part.createFormData("file", "homework.jpg", requestBody)
|
||
apiService.uploadImage(targetUrl, part)
|
||
}
|
||
}
|
||
```
|
||
|
||
### H.2 深色模式适配
|
||
|
||
```kotlin
|
||
// ThemeManager.kt
|
||
object ThemeManager {
|
||
fun applyTheme(context: Context) {
|
||
val nightMode = AppCompatDelegate.getDefaultNightMode()
|
||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||
val setting = sharedPrefs.getString("theme", "system")
|
||
|
||
val mode = when (setting) {
|
||
"light" -> AppCompatDelegate.MODE_NIGHT_NO
|
||
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
|
||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||
}
|
||
AppCompatDelegate.setDefaultNightMode(mode)
|
||
}
|
||
|
||
fun isDarkMode(context: Context): Boolean {
|
||
return (context.resources.configuration.uiMode
|
||
and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||
}
|
||
}
|
||
```
|
||
|
||
### H.3 无障碍支持
|
||
|
||
```kotlin
|
||
// AccessibilityHelper.kt
|
||
object AccessibilityHelper {
|
||
|
||
fun setupContentDescriptions(views: Map<View, String>) {
|
||
views.forEach { (view, description) ->
|
||
view.contentDescription = description
|
||
// 对于自定义View额外设置accessibility delegate
|
||
ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
|
||
override fun onInitializeAccessibilityNodeInfo(
|
||
host: View, info: AccessibilityNodeInfoCompat) {
|
||
super.onInitializeAccessibilityNodeInfo(host, info)
|
||
info.contentDescription = description
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
fun announceForAccessibility(view: View, message: String) {
|
||
view.announceForAccessibility(message)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
*本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。*
|