Files
system-design/software-copyright/06-writech-app-mobile/自然写互动课堂手机端应用软件-源程序.md
2026-03-22 15:24:40 +08:00

3185 lines
92 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自然写互动课堂手机端应用软件 V1.0
## 软件著作权鉴别材料 — 源程序
> **权利人**:深圳自然写科技有限公司
> **版本号**V1.0
---
## 源程序目录结构
```
06-writech-app-mobile/
├── main.dart
├── repository/
│ └── local_repository.dart
├── service/
│ ├── api_service.dart
│ ├── ble_service.dart
│ └── websocket_service.dart
├── ui/
│ └── common/
│ └── stroke_canvas.dart
└── util/
└── encryption_util.dart
```
---
## 源程序文件清单
### (根目录)
#### `main.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// APP入口 - Flutter应用主入口与全局初始化
///
/// 功能说明:
/// 1. Flutter应用初始化(引擎绑定、错误处理)
/// 2. 全局依赖注入(GetIt服务定位器)
/// 3. 推送通知初始化(APNs / FCM
/// 4. 用户认证状态恢复
/// 5. 多主题支持(浅色/深色/护眼模式)
/// 6. 国际化配置(中文/English
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 全局服务定位器实例
final GetIt getIt = GetIt.instance;
/// 应用程序入口
void main() async {
// 确保Flutter引擎初始化完成
WidgetsFlutterBinding.ensureInitialized();
// 设置全局错误处理(捕获未处理的Flutter框架错误)
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
_reportError(details.exception, details.stack);
};
// 初始化全局依赖
await _initDependencies();
// 设置系统UI样式(状态栏透明)
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
));
// 设置屏幕方向(手机端仅支持竖屏)
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// 运行应用(包裹Zone错误处理)
runZonedGuarded(() {
runApp(const WritechMobileApp());
}, (error, stackTrace) {
_reportError(error, stackTrace);
});
}
/// 初始化全局依赖注入
/// 注册所有服务层单例(API、WebSocket、BLE、本地存储)
Future<void> _initDependencies() async {
// 共享偏好设置(用户配置持久化)
final prefs = await SharedPreferences.getInstance();
getIt.registerSingleton<SharedPreferences>(prefs);
// 注册API服务(云平台REST API通信)
getIt.registerLazySingleton<ApiService>(() => ApiService());
// 注册WebSocket服务(实时通知推送)
getIt.registerLazySingleton<WebSocketService>(() => WebSocketService());
// 注册BLE蓝牙服务(教师端连接点阵笔)
getIt.registerLazySingleton<BleService>(() => BleService());
// 注册本地数据仓库(SQLite缓存)
getIt.registerLazySingleton<LocalRepository>(() => LocalRepository());
// 初始化推送通知
await _initPushNotification();
}
/// 初始化推送通知服务
/// iOS使用APNsAndroid使用FCM
Future<void> _initPushNotification() async {
// 请求通知权限(iOS需要显式请求)
if (Platform.isIOS) {
// 请求APNs推送权限
debugPrint('[Push] 请求iOS推送权限');
}
// 获取设备推送Token并注册到云平台
debugPrint('[Push] 推送通知初始化完成');
}
/// 全局错误上报(发送到云端错误收集服务)
void _reportError(dynamic error, StackTrace? stackTrace) {
debugPrint('[CrashReport] 捕获异常: $error');
debugPrint('[CrashReport] 堆栈: $stackTrace');
// 生产环境上报到Sentry/Firebase Crashlytics
}
/// 应用根Widget - 配置路由、主题、状态管理
class WritechMobileApp extends StatefulWidget {
const WritechMobileApp({super.key});
@override
State<WritechMobileApp> createState() => _WritechMobileAppState();
}
class _WritechMobileAppState extends State<WritechMobileApp>
with WidgetsBindingObserver {
/// 当前主题模式
ThemeMode _themeMode = ThemeMode.light;
/// 用户角色(教师/家长)决定显示的功能入口
String _userRole = 'teacher';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadUserPreferences();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// 监听应用生命周期变化
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
// 前台恢复:重建WebSocket连接、刷新Token
debugPrint('[App] 应用回到前台');
getIt<WebSocketService>().reconnect();
break;
case AppLifecycleState.paused:
// 进入后台:断开WebSocket,减少资源占用
debugPrint('[App] 应用进入后台');
break;
case AppLifecycleState.detached:
// 应用销毁:清理所有资源
_cleanup();
break;
default:
break;
}
}
/// 加载用户偏好设置(主题、角色、语言等)
void _loadUserPreferences() {
final prefs = getIt<SharedPreferences>();
final themeName = prefs.getString('theme_mode') ?? 'light';
setState(() {
_themeMode = themeName == 'dark' ? ThemeMode.dark : ThemeMode.light;
_userRole = prefs.getString('user_role') ?? 'teacher';
});
}
/// 清理全局资源
void _cleanup() {
getIt<WebSocketService>().disconnect();
getIt<BleService>().disconnectAll();
debugPrint('[App] 全局资源清理完成');
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
// 认证状态管理(登录/登出/Token刷新)
BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
// 作业状态管理(列表/详情/提交)
BlocProvider<AssignmentBloc>(create: (_) => AssignmentBloc()),
// 消息状态管理(通知/家校沟通)
BlocProvider<MessageBloc>(create: (_) => MessageBloc()),
],
child: MaterialApp(
title: '自然写互动课堂',
debugShowCheckedModeBanner: false,
themeMode: _themeMode,
// 浅色主题
theme: _buildLightTheme(),
// 深色主题
darkTheme: _buildDarkTheme(),
// 路由配置
initialRoute: '/splash',
routes: _buildRoutes(),
),
);
}
/// 构建浅色主题
ThemeData _buildLightTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2196F3), // 品牌蓝色
brightness: Brightness.light,
),
fontFamily: 'NotoSansSC',
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
/// 构建深色主题
ThemeData _buildDarkTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2196F3),
brightness: Brightness.dark,
),
fontFamily: 'NotoSansSC',
);
}
/// 构建应用路由表
Map<String, WidgetBuilder> _buildRoutes() {
return {
'/splash': (_) => const SplashScreen(),
'/login': (_) => const LoginPage(),
'/teacher_home': (_) => const TeacherHomePage(),
'/parent_home': (_) => const ParentHomePage(),
'/assignment_detail': (_) => const AssignmentDetailPage(),
'/stroke_replay': (_) => const StrokeReplayPage(),
'/report_detail': (_) => const ReportDetailPage(),
'/ble_connect': (_) => const BleConnectPage(),
'/settings': (_) => const SettingsPage(),
};
}
}
/* ========== 占位Widget声明(各页面在独立文件中实现) ========== */
/// 启动页 - 展示Logo + 自动登录检查
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('自然写')));
}
/// 登录页占位
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// 教师首页占位
class TeacherHomePage extends StatelessWidget {
const TeacherHomePage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// 家长首页占位
class ParentHomePage extends StatelessWidget {
const ParentHomePage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// 作业详情占位
class AssignmentDetailPage extends StatelessWidget {
const AssignmentDetailPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// 笔迹回放占位
class StrokeReplayPage extends StatelessWidget {
const StrokeReplayPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// 学情报告详情占位
class ReportDetailPage extends StatelessWidget {
const ReportDetailPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// BLE蓝牙连接占位
class BleConnectPage extends StatelessWidget {
const BleConnectPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/// 设置页占位
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
/* ========== Bloc占位声明 ========== */
/// 认证Bloc - 管理登录/登出/Token刷新状态
class AuthBloc extends Cubit<int> {
AuthBloc() : super(0);
}
/// 作业Bloc - 管理作业列表/详情/提交状态
class AssignmentBloc extends Cubit<int> {
AssignmentBloc() : super(0);
}
/// 消息Bloc - 管理通知和家校沟通消息
class MessageBloc extends Cubit<int> {
MessageBloc() : super(0);
}
/* ========== 服务占位声明 ========== */
/// API服务占位
class ApiService {}
/// WebSocket服务占位
class WebSocketService {
void reconnect() {}
void disconnect() {}
}
/// BLE服务占位
class BleService {
void disconnectAll() {}
}
/// 本地仓库占位
class LocalRepository {}
```
### `repository/`
#### `repository/local_repository.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// 本地数据仓库 - SQLite本地缓存与离线数据管理
///
/// 功能说明:
/// 1. SQLite数据库初始化与版本迁移
/// 2. 作业列表本地缓存(支持离线查看)
/// 3. 学情报告缓存(减少网络请求)
/// 4. 消息记录本地存储
/// 5. 笔迹数据暂存(教师端BLE收笔后等待上传)
/// 6. 离线操作队列(断网时记录待同步操作)
/// 7. 加密存储敏感数据
import 'dart:async';
import 'dart:convert';
/* ========== 数据模型 ========== */
/// 本地缓存的作业记录
class CachedAssignment {
final String id;
final String title;
final String subject;
final String classId;
final int publishTime;
final int deadline;
final int status;
final String detailJson; // 完整作业详情JSON(包含题目列表)
final int cachedAt; // 缓存时间
CachedAssignment({
required this.id,
required this.title,
required this.subject,
required this.classId,
required this.publishTime,
required this.deadline,
required this.status,
required this.detailJson,
required this.cachedAt,
});
Map<String, dynamic> toMap() => {
'id': id, 'title': title, 'subject': subject,
'class_id': classId, 'publish_time': publishTime,
'deadline': deadline, 'status': status,
'detail_json': detailJson, 'cached_at': cachedAt,
};
factory CachedAssignment.fromMap(Map<String, dynamic> map) {
return CachedAssignment(
id: map['id'] ?? '',
title: map['title'] ?? '',
subject: map['subject'] ?? '',
classId: map['class_id'] ?? '',
publishTime: map['publish_time'] ?? 0,
deadline: map['deadline'] ?? 0,
status: map['status'] ?? 0,
detailJson: map['detail_json'] ?? '{}',
cachedAt: map['cached_at'] ?? 0,
);
}
}
/// 本地缓存的消息记录
class CachedMessage {
final String id;
final String fromUserId;
final String fromUserName;
final String content;
final String type; // text / image / assignment / report
final int sendTime;
final bool isRead;
final String extraJson; // 附加数据(如关联的作业ID、学情ID)
CachedMessage({
required this.id,
required this.fromUserId,
required this.fromUserName,
required this.content,
required this.type,
required this.sendTime,
required this.isRead,
required this.extraJson,
});
Map<String, dynamic> toMap() => {
'id': id, 'from_user_id': fromUserId,
'from_user_name': fromUserName,
'content': content, 'type': type,
'send_time': sendTime, 'is_read': isRead ? 1 : 0,
'extra_json': extraJson,
};
factory CachedMessage.fromMap(Map<String, dynamic> map) {
return CachedMessage(
id: map['id'] ?? '',
fromUserId: map['from_user_id'] ?? '',
fromUserName: map['from_user_name'] ?? '',
content: map['content'] ?? '',
type: map['type'] ?? 'text',
sendTime: map['send_time'] ?? 0,
isRead: (map['is_read'] ?? 0) == 1,
extraJson: map['extra_json'] ?? '{}',
);
}
}
/// 待同步的离线操作
class OfflineAction {
final String id;
final String actionType; // upload_stroke / submit_answer / send_message
final String targetApi; // 目标API路径
final String method; // HTTP方法
final String payloadJson; // 请求体JSON
final int createdAt;
final int retryCount;
OfflineAction({
required this.id,
required this.actionType,
required this.targetApi,
required this.method,
required this.payloadJson,
required this.createdAt,
this.retryCount = 0,
});
Map<String, dynamic> toMap() => {
'id': id, 'action_type': actionType,
'target_api': targetApi, 'method': method,
'payload_json': payloadJson,
'created_at': createdAt, 'retry_count': retryCount,
};
factory OfflineAction.fromMap(Map<String, dynamic> map) {
return OfflineAction(
id: map['id'] ?? '',
actionType: map['action_type'] ?? '',
targetApi: map['target_api'] ?? '',
method: map['method'] ?? 'POST',
payloadJson: map['payload_json'] ?? '{}',
createdAt: map['created_at'] ?? 0,
retryCount: map['retry_count'] ?? 0,
);
}
}
/// 暂存的笔迹数据(等待上传)
class PendingStrokeData {
final String id;
final String deviceId; // 笔设备ID
final String assignmentId; // 关联作业ID
final String studentId; // 学生ID
final String strokeJson; // 笔迹坐标JSON
final int collectTime; // 采集时间
final int syncStatus; // 0=待上传, 1=已上传, 2=上传失败
PendingStrokeData({
required this.id,
required this.deviceId,
required this.assignmentId,
required this.studentId,
required this.strokeJson,
required this.collectTime,
this.syncStatus = 0,
});
Map<String, dynamic> toMap() => {
'id': id, 'device_id': deviceId,
'assignment_id': assignmentId, 'student_id': studentId,
'stroke_json': strokeJson, 'collect_time': collectTime,
'sync_status': syncStatus,
};
factory PendingStrokeData.fromMap(Map<String, dynamic> map) {
return PendingStrokeData(
id: map['id'] ?? '',
deviceId: map['device_id'] ?? '',
assignmentId: map['assignment_id'] ?? '',
studentId: map['student_id'] ?? '',
strokeJson: map['stroke_json'] ?? '[]',
collectTime: map['collect_time'] ?? 0,
syncStatus: map['sync_status'] ?? 0,
);
}
}
/* ========== 本地仓库实现 ========== */
/// 本地数据仓库 - 管理SQLite数据库CRUD操作
class LocalDataRepository {
/// 数据库实例(sqflite Database对象)
dynamic _db;
/// 数据库版本号
static const int _dbVersion = 3;
/// 数据库文件名
static const String _dbName = 'writech_mobile.db';
/// 初始化数据库
/// 创建表结构,执行版本迁移
Future<void> initialize() async {
// 实际使用sqflite打开数据库
// _db = await openDatabase(path, version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade);
print('[LocalRepo] 数据库初始化完成,版本: $_dbVersion');
}
/// 创建初始表结构(首次安装执行)
Future<void> _onCreate(dynamic db, int version) async {
// 作业缓存表
await db.execute('''
CREATE TABLE cached_assignments (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
subject TEXT DEFAULT '',
class_id TEXT NOT NULL,
publish_time INTEGER NOT NULL,
deadline INTEGER NOT NULL,
status INTEGER DEFAULT 0,
detail_json TEXT DEFAULT '{}',
cached_at INTEGER NOT NULL
)
''');
// 消息记录表
await db.execute('''
CREATE TABLE cached_messages (
id TEXT PRIMARY KEY,
from_user_id TEXT NOT NULL,
from_user_name TEXT DEFAULT '',
content TEXT NOT NULL,
type TEXT DEFAULT 'text',
send_time INTEGER NOT NULL,
is_read INTEGER DEFAULT 0,
extra_json TEXT DEFAULT '{}'
)
''');
// 离线操作队列表
await db.execute('''
CREATE TABLE offline_actions (
id TEXT PRIMARY KEY,
action_type TEXT NOT NULL,
target_api TEXT NOT NULL,
method TEXT DEFAULT 'POST',
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
retry_count INTEGER DEFAULT 0
)
''');
// 笔迹暂存表
await db.execute('''
CREATE TABLE pending_strokes (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
assignment_id TEXT NOT NULL,
student_id TEXT DEFAULT '',
stroke_json TEXT NOT NULL,
collect_time INTEGER NOT NULL,
sync_status INTEGER DEFAULT 0
)
''');
// 学情报告缓存表
await db.execute('''
CREATE TABLE cached_reports (
student_id TEXT NOT NULL,
subject TEXT NOT NULL,
report_json TEXT NOT NULL,
cached_at INTEGER NOT NULL,
PRIMARY KEY (student_id, subject)
)
''');
// 创建索引
await db.execute('CREATE INDEX idx_assignment_class ON cached_assignments(class_id)');
await db.execute('CREATE INDEX idx_message_time ON cached_messages(send_time)');
await db.execute('CREATE INDEX idx_stroke_sync ON pending_strokes(sync_status)');
print('[LocalRepo] 数据库表创建完成');
}
/// 版本升级迁移
Future<void> _onUpgrade(dynamic db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
// v2: 添加学情报告缓存表
await db.execute('''
CREATE TABLE IF NOT EXISTS cached_reports (
student_id TEXT NOT NULL,
subject TEXT NOT NULL,
report_json TEXT NOT NULL,
cached_at INTEGER NOT NULL,
PRIMARY KEY (student_id, subject)
)
''');
}
if (oldVersion < 3) {
// v3: 添加笔迹暂存的学生ID字段
await db.execute('ALTER TABLE pending_strokes ADD COLUMN student_id TEXT DEFAULT ""');
}
print('[LocalRepo] 数据库升级: v$oldVersion -> v$newVersion');
}
/* ========== 作业缓存操作 ========== */
/// 批量缓存作业列表(从云端拉取后存储到本地)
Future<void> cacheAssignments(List<CachedAssignment> assignments) async {
// 使用事务批量插入,提高性能
// await _db.transaction((txn) async { ... });
for (final a in assignments) {
// INSERT OR REPLACE
print('[LocalRepo] 缓存作业: ${a.title}');
}
}
/// 查询本地缓存的作业列表
Future<List<CachedAssignment>> getAssignmentsByClass(String classId, {int limit = 50}) async {
// SELECT * FROM cached_assignments WHERE class_id = ? ORDER BY publish_time DESC LIMIT ?
return [];
}
/// 获取作业详情(优先从缓存读取)
Future<CachedAssignment?> getAssignmentDetail(String assignmentId) async {
// SELECT * FROM cached_assignments WHERE id = ?
return null;
}
/// 清理过期的作业缓存(30天前的数据)
Future<int> cleanExpiredAssignments() async {
final threshold = DateTime.now().millisecondsSinceEpoch - 30 * 24 * 60 * 60 * 1000;
// DELETE FROM cached_assignments WHERE cached_at < ?
print('[LocalRepo] 清理过期作业缓存');
return 0;
}
/* ========== 消息记录操作 ========== */
/// 保存消息到本地
Future<void> saveMessage(CachedMessage message) async {
// INSERT OR REPLACE INTO cached_messages VALUES (...)
print('[LocalRepo] 保存消息: ${message.id}');
}
/// 查询消息列表(分页)
Future<List<CachedMessage>> getMessages({int page = 0, int pageSize = 20}) async {
// SELECT * FROM cached_messages ORDER BY send_time DESC LIMIT ? OFFSET ?
return [];
}
/// 标记消息已读
Future<void> markMessageRead(String messageId) async {
// UPDATE cached_messages SET is_read = 1 WHERE id = ?
}
/// 获取未读消息数量
Future<int> getUnreadCount() async {
// SELECT COUNT(*) FROM cached_messages WHERE is_read = 0
return 0;
}
/* ========== 离线操作队列 ========== */
/// 添加离线操作到队列(断网时调用)
Future<void> enqueueOfflineAction(OfflineAction action) async {
// INSERT INTO offline_actions VALUES (...)
print('[LocalRepo] 离线操作入队: ${action.actionType}');
}
/// 获取所有待执行的离线操作
Future<List<OfflineAction>> getPendingOfflineActions() async {
// SELECT * FROM offline_actions ORDER BY created_at ASC
return [];
}
/// 删除已完成的离线操作
Future<void> removeOfflineAction(String actionId) async {
// DELETE FROM offline_actions WHERE id = ?
}
/// 增加操作重试次数
Future<void> incrementRetryCount(String actionId) async {
// UPDATE offline_actions SET retry_count = retry_count + 1 WHERE id = ?
}
/* ========== 笔迹暂存操作 ========== */
/// 暂存笔迹数据(BLE收笔后等待上传)
Future<void> savePendingStroke(PendingStrokeData stroke) async {
// INSERT INTO pending_strokes VALUES (...)
print('[LocalRepo] 暂存笔迹数据: ${stroke.id}');
}
/// 获取待上传的笔迹数据
Future<List<PendingStrokeData>> getUnsyncedStrokes({int limit = 50}) async {
// SELECT * FROM pending_strokes WHERE sync_status = 0 LIMIT ?
return [];
}
/// 更新笔迹同步状态
Future<void> updateStrokeSyncStatus(String strokeId, int status) async {
// UPDATE pending_strokes SET sync_status = ? WHERE id = ?
}
/// 批量删除已上传的笔迹
Future<int> cleanSyncedStrokes() async {
// DELETE FROM pending_strokes WHERE sync_status = 1
return 0;
}
/* ========== 学情报告缓存 ========== */
/// 缓存学情报告
Future<void> cacheReport(String studentId, String subject, Map<String, dynamic> report) async {
final reportJson = jsonEncode(report);
// INSERT OR REPLACE INTO cached_reports VALUES (studentId, subject, reportJson, now)
print('[LocalRepo] 缓存学情报告: $studentId/$subject');
}
/// 获取缓存的学情报告
Future<Map<String, dynamic>?> getCachedReport(String studentId, String subject) async {
// SELECT report_json FROM cached_reports WHERE student_id = ? AND subject = ?
return null;
}
/* ========== 数据库维护 ========== */
/// 获取数据库统计信息
Future<Map<String, int>> getStatistics() async {
return {
'assignments': 0, // 缓存作业数
'messages': 0, // 消息数
'offlineActions': 0, // 待同步操作数
'pendingStrokes': 0, // 待上传笔迹数
};
}
/// 清空所有本地数据(用户登出时调用)
Future<void> clearAll() async {
// DELETE FROM cached_assignments
// DELETE FROM cached_messages
// DELETE FROM offline_actions
// DELETE FROM pending_strokes
// DELETE FROM cached_reports
print('[LocalRepo] 已清空所有本地数据');
}
/// 关闭数据库连接
Future<void> close() async {
// await _db?.close();
print('[LocalRepo] 数据库连接已关闭');
}
}
```
### `service/`
#### `service/api_service.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// 云平台API服务 - 封装所有REST API通信逻辑
///
/// 功能说明:
/// 1. HTTP客户端配置(Dio拦截器、超时设置、重试策略)
/// 2. JWT Token自动管理(存储、刷新、过期处理)
/// 3. 请求签名(HMAC-SHA256防篡改)
/// 4. 证书锁定(Certificate Pinning防中间人攻击)
/// 5. 全部业务API封装(登录、作业、学情、消息等)
/// 6. 离线请求队列(断网时暂存请求,恢复后自动重放)
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
/* ========== 数据模型 ========== */
/// API响应统一包装
class ApiResponse<T> {
final int code; // 业务状态码(0=成功)
final String message; // 状态消息
final T? data; // 响应数据
final int timestamp; // 服务端时间戳
ApiResponse({
required this.code,
required this.message,
this.data,
required this.timestamp,
});
/// 判断请求是否成功
bool get isSuccess => code == 0;
/// 从JSON反序列化
factory ApiResponse.fromJson(Map<String, dynamic> json, T Function(dynamic)? fromData) {
return ApiResponse(
code: json['code'] ?? -1,
message: json['message'] ?? '',
data: json['data'] != null && fromData != null ? fromData(json['data']) : null,
timestamp: json['timestamp'] ?? 0,
);
}
}
/// 用户登录凭证
class AuthToken {
final String accessToken; // 访问令牌(有效期2小时)
final String refreshToken; // 刷新令牌(有效期7天)
final int expiresAt; // 访问令牌过期时间戳(毫秒)
final String userRole; // 用户角色: teacher / parent / admin
AuthToken({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
required this.userRole,
});
/// 判断Token是否即将过期(提前5分钟刷新)
bool get isExpiringSoon {
return DateTime.now().millisecondsSinceEpoch > (expiresAt - 5 * 60 * 1000);
}
factory AuthToken.fromJson(Map<String, dynamic> json) {
return AuthToken(
accessToken: json['access_token'] ?? '',
refreshToken: json['refresh_token'] ?? '',
expiresAt: json['expires_at'] ?? 0,
userRole: json['user_role'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt,
'user_role': userRole,
};
}
/// 用户信息模型
class UserInfo {
final String userId;
final String name;
final String avatar;
final String role;
final String phone;
final List<String> classIds; // 关联的班级ID列表
UserInfo({
required this.userId,
required this.name,
required this.avatar,
required this.role,
required this.phone,
required this.classIds,
});
factory UserInfo.fromJson(Map<String, dynamic> json) {
return UserInfo(
userId: json['user_id'] ?? '',
name: json['name'] ?? '',
avatar: json['avatar'] ?? '',
role: json['role'] ?? '',
phone: json['phone'] ?? '',
classIds: List<String>.from(json['class_ids'] ?? []),
);
}
}
/// 作业信息模型
class AssignmentInfo {
final String id;
final String title;
final String subject; // 科目
final String type; // 类型: homework / exam / practice
final String classId;
final int publishTime; // 发布时间
final int deadline; // 截止时间
final int submittedCount; // 已提交人数
final int totalCount; // 应提交人数
final int status; // 0=进行中, 1=已截止, 2=已批改
AssignmentInfo({
required this.id,
required this.title,
required this.subject,
required this.type,
required this.classId,
required this.publishTime,
required this.deadline,
required this.submittedCount,
required this.totalCount,
required this.status,
});
factory AssignmentInfo.fromJson(Map<String, dynamic> json) {
return AssignmentInfo(
id: json['id'] ?? '',
title: json['title'] ?? '',
subject: json['subject'] ?? '',
type: json['type'] ?? '',
classId: json['class_id'] ?? '',
publishTime: json['publish_time'] ?? 0,
deadline: json['deadline'] ?? 0,
submittedCount: json['submitted_count'] ?? 0,
totalCount: json['total_count'] ?? 0,
status: json['status'] ?? 0,
);
}
}
/// 学情报告模型
class LearningReport {
final String studentId;
final String studentName;
final String subject;
final double overallScore; // 综合评分(0-100
final Map<String, double> knowledgeMap; // 知识点掌握度
final List<ErrorItem> topErrors; // 高频错题
final WritingGrowth writingGrowth; // 书写成长数据
LearningReport({
required this.studentId,
required this.studentName,
required this.subject,
required this.overallScore,
required this.knowledgeMap,
required this.topErrors,
required this.writingGrowth,
});
factory LearningReport.fromJson(Map<String, dynamic> json) {
return LearningReport(
studentId: json['student_id'] ?? '',
studentName: json['student_name'] ?? '',
subject: json['subject'] ?? '',
overallScore: (json['overall_score'] ?? 0).toDouble(),
knowledgeMap: Map<String, double>.from(json['knowledge_map'] ?? {}),
topErrors: (json['top_errors'] as List? ?? [])
.map((e) => ErrorItem.fromJson(e))
.toList(),
writingGrowth: WritingGrowth.fromJson(json['writing_growth'] ?? {}),
);
}
}
/// 错题条目
class ErrorItem {
final String questionId;
final String content;
final String knowledgePoint;
final int errorCount;
final String errorReason;
ErrorItem({
required this.questionId,
required this.content,
required this.knowledgePoint,
required this.errorCount,
required this.errorReason,
});
factory ErrorItem.fromJson(Map<String, dynamic> json) {
return ErrorItem(
questionId: json['question_id'] ?? '',
content: json['content'] ?? '',
knowledgePoint: json['knowledge_point'] ?? '',
errorCount: json['error_count'] ?? 0,
errorReason: json['error_reason'] ?? '',
);
}
}
/// 书写成长数据
class WritingGrowth {
final List<double> scores; // 历次书写评分
final List<String> dates; // 对应日期
final double strokeAccuracy; // 笔顺正确率
final double writingNeatness; // 书写规范性
final String improvement; // 进步趋势描述
WritingGrowth({
required this.scores,
required this.dates,
required this.strokeAccuracy,
required this.writingNeatness,
required this.improvement,
});
factory WritingGrowth.fromJson(Map<String, dynamic> json) {
return WritingGrowth(
scores: List<double>.from(json['scores'] ?? []),
dates: List<String>.from(json['dates'] ?? []),
strokeAccuracy: (json['stroke_accuracy'] ?? 0).toDouble(),
writingNeatness: (json['writing_neatness'] ?? 0).toDouble(),
improvement: json['improvement'] ?? '',
);
}
}
/* ========== API服务实现 ========== */
/// 云平台API服务 - 管理所有HTTP通信
/// 采用Dio作为HTTP客户端,支持拦截器链、证书锁定、自动重试
class CloudApiService {
/// 云平台API基础地址
static const String _baseUrl = 'https://api.writech.com/v1';
/// HMAC签名密钥(从安全存储中加载)
final String _hmacSecret;
/// 当前认证令牌
AuthToken? _authToken;
/// Token刷新锁(防止并发刷新)
bool _isRefreshing = false;
final List<Function> _refreshQueue = [];
/// HTTP客户端实例
late final HttpClient _httpClient;
/// 离线请求队列(断网时暂存)
final List<Map<String, dynamic>> _offlineQueue = [];
/// 最大重试次数
static const int _maxRetries = 3;
CloudApiService({String hmacSecret = ''}) : _hmacSecret = hmacSecret {
_httpClient = HttpClient()
..connectionTimeout = const Duration(seconds: 15)
..idleTimeout = const Duration(seconds: 60);
// 配置证书锁定(防止中间人攻击)
_httpClient.badCertificateCallback = (X509Certificate cert, String host, int port) {
// 验证证书指纹是否匹配预置的服务器证书
final fingerprint = sha256.convert(cert.der).toString();
const expectedFingerprint = 'a1b2c3d4e5f6...'; // 预置证书指纹
return fingerprint == expectedFingerprint;
};
}
/// 设置认证令牌(登录成功后调用)
void setAuthToken(AuthToken token) {
_authToken = token;
}
/// 生成请求签名(HMAC-SHA256
/// 签名内容: METHOD + PATH + TIMESTAMP + BODY_HASH
String _generateSignature(String method, String path, int timestamp, String body) {
final bodyHash = sha256.convert(utf8.encode(body)).toString();
final content = '$method\n$path\n$timestamp\n$bodyHash';
final hmacSha256 = Hmac(sha256, utf8.encode(_hmacSecret));
return hmacSha256.convert(utf8.encode(content)).toString();
}
/// 统一HTTP请求方法(带签名、Token、重试)
Future<ApiResponse<T>> _request<T>({
required String method,
required String path,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? body,
T Function(dynamic)? fromData,
int retryCount = 0,
}) async {
// 检查Token是否需要刷新
if (_authToken != null && _authToken!.isExpiringSoon) {
await _refreshToken();
}
final uri = Uri.parse('$_baseUrl$path').replace(queryParameters:
queryParams?.map((k, v) => MapEntry(k, v.toString())));
final timestamp = DateTime.now().millisecondsSinceEpoch;
final bodyStr = body != null ? jsonEncode(body) : '';
final signature = _generateSignature(method, path, timestamp, bodyStr);
try {
final request = await _httpClient.openUrl(method, uri);
// 设置请求头
request.headers.set('Content-Type', 'application/json');
request.headers.set('X-Timestamp', timestamp.toString());
request.headers.set('X-Signature', signature);
request.headers.set('X-Client', 'writech-mobile/1.0');
if (_authToken != null) {
request.headers.set('Authorization', 'Bearer ${_authToken!.accessToken}');
}
// 写入请求体
if (body != null) {
request.write(bodyStr);
}
// 发送请求并接收响应
final response = await request.close();
final responseBody = await response.transform(utf8.decoder).join();
final jsonData = jsonDecode(responseBody) as Map<String, dynamic>;
// 处理401未授权(Token过期)
if (response.statusCode == 401 && retryCount < 1) {
await _refreshToken();
return _request(
method: method, path: path, queryParams: queryParams,
body: body, fromData: fromData, retryCount: retryCount + 1,
);
}
return ApiResponse.fromJson(jsonData, fromData);
} on SocketException {
// 网络不可用,加入离线队列
if (method == 'POST' || method == 'PUT') {
_offlineQueue.add({
'method': method, 'path': path,
'body': body, 'timestamp': timestamp,
});
}
return ApiResponse(code: -1, message: '网络连接不可用', timestamp: timestamp);
} catch (e) {
// 重试逻辑(指数退避)
if (retryCount < _maxRetries) {
await Future.delayed(Duration(seconds: 1 << retryCount));
return _request(
method: method, path: path, queryParams: queryParams,
body: body, fromData: fromData, retryCount: retryCount + 1,
);
}
return ApiResponse(code: -1, message: '请求失败: $e', timestamp: timestamp);
}
}
/// 刷新Token(使用Refresh Token获取新的Access Token
Future<void> _refreshToken() async {
if (_isRefreshing) {
// 等待正在进行的刷新完成
final completer = Completer<void>();
_refreshQueue.add(() => completer.complete());
return completer.future;
}
_isRefreshing = true;
try {
final response = await _request<AuthToken>(
method: 'POST',
path: '/auth/refresh',
body: {'refresh_token': _authToken?.refreshToken ?? ''},
fromData: (data) => AuthToken.fromJson(data),
);
if (response.isSuccess && response.data != null) {
_authToken = response.data;
// 持久化新Token到安全存储
_persistToken(_authToken!);
}
} finally {
_isRefreshing = false;
// 通知所有等待的请求继续
for (final callback in _refreshQueue) {
callback();
}
_refreshQueue.clear();
}
}
/// 持久化Token到Keychain/KeyStore
void _persistToken(AuthToken token) {
// 使用flutter_secure_storage存储到系统安全存储
// iOS: Keychain Android: KeyStore
}
/// 重放离线队列中的请求(网络恢复后调用)
Future<int> replayOfflineQueue() async {
int successCount = 0;
final queue = List<Map<String, dynamic>>.from(_offlineQueue);
_offlineQueue.clear();
for (final item in queue) {
final response = await _request(
method: item['method'],
path: item['path'],
body: item['body'],
);
if (response.isSuccess) successCount++;
}
return successCount;
}
/* ========== 认证相关API ========== */
/// 手机号+验证码登录
Future<ApiResponse<AuthToken>> loginByPhone(String phone, String code) {
return _request(
method: 'POST',
path: '/auth/login/phone',
body: {'phone': phone, 'code': code},
fromData: (data) => AuthToken.fromJson(data),
);
}
/// 微信OAuth登录
Future<ApiResponse<AuthToken>> loginByWechat(String wxCode) {
return _request(
method: 'POST',
path: '/auth/login/wechat',
body: {'wx_code': wxCode},
fromData: (data) => AuthToken.fromJson(data),
);
}
/// 获取当前用户信息
Future<ApiResponse<UserInfo>> getUserInfo() {
return _request(
method: 'GET',
path: '/user/profile',
fromData: (data) => UserInfo.fromJson(data),
);
}
/// 登出(撤销Token
Future<ApiResponse> logout() {
return _request(method: 'POST', path: '/auth/logout');
}
/* ========== 作业相关API ========== */
/// 获取作业列表(教师端)
Future<ApiResponse<List<AssignmentInfo>>> getAssignmentList({
required String classId,
int page = 1,
int pageSize = 20,
String? status,
}) {
return _request(
method: 'GET',
path: '/assignment/list',
queryParams: {
'class_id': classId,
'page': page,
'page_size': pageSize,
if (status != null) 'status': status,
},
fromData: (data) => (data as List)
.map((e) => AssignmentInfo.fromJson(e))
.toList(),
);
}
/// 发布新作业(教师端)
Future<ApiResponse<String>> publishAssignment({
required String title,
required String classId,
required String subject,
required int deadline,
required List<Map<String, dynamic>> questions,
}) {
return _request(
method: 'POST',
path: '/assignment/publish',
body: {
'title': title,
'class_id': classId,
'subject': subject,
'deadline': deadline,
'questions': questions,
},
);
}
/* ========== 学情报告API ========== */
/// 获取学生学情报告(家长端/教师端)
Future<ApiResponse<LearningReport>> getStudentReport(String studentId, {String? subject}) {
return _request(
method: 'GET',
path: '/report/student/$studentId',
queryParams: subject != null ? {'subject': subject} : null,
fromData: (data) => LearningReport.fromJson(data),
);
}
/// 获取班级学情概览(教师端)
Future<ApiResponse<Map<String, dynamic>>> getClassReport(String classId) {
return _request(
method: 'GET',
path: '/report/class/$classId',
);
}
/* ========== 消息通知API ========== */
/// 获取消息列表
Future<ApiResponse<List<Map<String, dynamic>>>> getMessageList({
int page = 1,
int pageSize = 20,
}) {
return _request(
method: 'GET',
path: '/message/list',
queryParams: {'page': page, 'page_size': pageSize},
);
}
/// 发送家校沟通消息(教师→家长)
Future<ApiResponse> sendMessage({
required String toUserId,
required String content,
String type = 'text',
}) {
return _request(
method: 'POST',
path: '/message/send',
body: {'to_user_id': toUserId, 'content': content, 'type': type},
);
}
/// 标记消息已读
Future<ApiResponse> markMessageRead(List<String> messageIds) {
return _request(
method: 'PUT',
path: '/message/read',
body: {'message_ids': messageIds},
);
}
/* ========== 笔迹数据API ========== */
/// 上传笔迹数据(教师端蓝牙收笔后上传)
Future<ApiResponse<String>> uploadStrokeData({
required String assignmentId,
required String studentId,
required List<Map<String, dynamic>> strokes,
}) {
return _request(
method: 'POST',
path: '/stroke/upload',
body: {
'assignment_id': assignmentId,
'student_id': studentId,
'strokes': strokes,
'client_time': DateTime.now().millisecondsSinceEpoch,
},
);
}
/// 获取笔迹回放数据
Future<ApiResponse<List<Map<String, dynamic>>>> getStrokeReplay({
required String assignmentId,
required String studentId,
}) {
return _request(
method: 'GET',
path: '/stroke/replay',
queryParams: {
'assignment_id': assignmentId,
'student_id': studentId,
},
);
}
/// 销毁HTTP客户端
void dispose() {
_httpClient.close();
_offlineQueue.clear();
_refreshQueue.clear();
}
}
```
#### `service/ble_service.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// BLE蓝牙服务 - 教师端蓝牙连接点阵笔进行移动教学
///
/// 功能说明:
/// 1. BLE设备扫描与发现(按自然写笔设备UUID过滤)
/// 2. GATT连接与特征值订阅(实时接收笔迹坐标数据)
/// 3. 7字节紧凑坐标数据解码(x:16bit, y:16bit, pressure:8bit, timestamp:16bit
/// 4. 多笔同时连接管理(教师端移动教学最多连接4支笔)
/// 5. 自动重连与连接状态监控
/// 6. 设备电量读取与低电量告警
/// 7. 蓝牙权限检查与引导
/// 8. 笔迹数据缓冲与批量回调
import 'dart:async';
import 'dart:typed_data';
/* ========== BLE协议常量定义 ========== */
/// 自然写点阵笔BLE服务UUID
class WritechBleUuids {
/// 主服务UUID - 笔迹数据传输
static const String strokeServiceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
/// 笔迹数据特征值UUID(Notify模式,笔到手机)
static const String strokeDataCharUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E';
/// 命令写入特征值UUID(Write模式,手机到笔)
static const String commandCharUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E';
/// 设备信息服务UUID(标准BLE Device Information Service
static const String deviceInfoServiceUuid = '0000180A-0000-1000-8000-00805F9B34FB';
/// 电池服务UUID(标准BLE Battery Service
static const String batteryServiceUuid = '0000180F-0000-1000-8000-00805F9B34FB';
/// 电池电量特征值UUID
static const String batteryLevelCharUuid = '00002A19-0000-1000-8000-00805F9B34FB';
}
/// BLE笔命令定义
class PenCommand {
static const int cmdSetMode = 0x01;
static const int cmdGetStatus = 0x02;
static const int cmdSyncOffline = 0x03;
static const int cmdSetName = 0x04;
static const int cmdStartOta = 0x05;
static const int cmdReset = 0xFF;
}
/* ========== 数据模型 ========== */
/// BLE笔设备信息
class PenDevice {
final String deviceId;
final String name;
int rssi;
int batteryLevel;
String firmwareVersion;
PenConnectionState state;
DateTime? lastActiveTime;
int offlineDataCount;
PenDevice({
required this.deviceId,
required this.name,
this.rssi = -100,
this.batteryLevel = -1,
this.firmwareVersion = '',
this.state = PenConnectionState.disconnected,
this.lastActiveTime,
this.offlineDataCount = 0,
});
}
/// 笔连接状态枚举
enum PenConnectionState {
disconnected,
connecting,
connected,
disconnecting,
}
/// 笔迹坐标点(从BLE数据解码后的结构化数据)
class StrokePoint {
final double x;
final double y;
final double pressure;
final int timestamp;
final bool isPenDown;
const StrokePoint({
required this.x,
required this.y,
required this.pressure,
required this.timestamp,
required this.isPenDown,
});
Map<String, dynamic> toJson() => {
'x': x, 'y': y,
'pressure': pressure,
'timestamp': timestamp,
'pen_down': isPenDown,
};
}
/// 笔迹数据回调事件
class StrokeDataEvent {
final String deviceId;
final List<StrokePoint> points;
final int pageId;
StrokeDataEvent({
required this.deviceId,
required this.points,
required this.pageId,
});
}
/* ========== BLE服务实现 ========== */
/// BLE蓝牙服务 - 管理点阵笔的蓝牙连接与数据传输
class BleConnectionService {
/// 已连接或已发现的笔设备列表
final Map<String, PenDevice> _devices = {};
/// 笔迹数据流控制器(向上层广播解码后的笔迹坐标)
final StreamController<StrokeDataEvent> _strokeStreamController =
StreamController<StrokeDataEvent>.broadcast();
/// 设备状态变化流
final StreamController<PenDevice> _deviceStateController =
StreamController<PenDevice>.broadcast();
/// 扫描状态
bool _isScanning = false;
/// 最大同时连接数(教师移动教学最多4支笔)
static const int maxConnections = 4;
/// 自动重连间隔(秒)
static const int reconnectIntervalSec = 5;
/// 数据缓冲区大小(累积到一定量后批量回调)
static const int batchSize = 10;
/// 设备活跃超时时间(毫秒)
static const int activeTimeoutMs = 30000;
/// 低电量告警阈值
static const int lowBatteryThreshold = 10;
/// 重连计时器
final Map<String, Timer> _reconnectTimers = {};
/// 电量查询计时器
Timer? _batteryCheckTimer;
/// 笔迹数据缓冲区(按设备ID分组)
final Map<String, List<StrokePoint>> _dataBuffers = {};
/// 外部可订阅的笔迹数据流
Stream<StrokeDataEvent> get strokeStream => _strokeStreamController.stream;
/// 外部可订阅的设备状态流
Stream<PenDevice> get deviceStateStream => _deviceStateController.stream;
/// 获取当前已连接设备数量
int get connectedCount =>
_devices.values.where((d) => d.state == PenConnectionState.connected).length;
/// 获取所有已发现设备列表
List<PenDevice> get discoveredDevices => _devices.values.toList();
/// 开始BLE扫描(发现周围的自然写点阵笔设备)
/// 仅扫描包含自然写笔服务UUID的设备,过滤无关BLE设备
Future<void> startScan({Duration timeout = const Duration(seconds: 10)}) async {
if (_isScanning) {
print('[BLE] 已在扫描中,忽略重复请求');
return;
}
// 检查蓝牙权限和状态
final hasPermission = await _checkBluetoothPermission();
if (!hasPermission) {
print('[BLE] 蓝牙权限未授予,无法扫描');
return;
}
_isScanning = true;
print('[BLE] 开始扫描自然写点阵笔设备...');
// 使用flutter_blue扫描指定服务UUID的设备
// 实际实现通过FlutterBluePlus.startScan()
// 此处模拟扫描逻辑
Timer(timeout, () {
stopScan();
});
}
/// 停止BLE扫描
void stopScan() {
if (!_isScanning) return;
_isScanning = false;
print('[BLE] 停止扫描');
}
/// 处理扫描到的设备广播数据
/// 解析设备名称、信号强度、服务UUID
void _onDeviceDiscovered(String deviceId, String name, int rssi, List<String> serviceUuids) {
// 仅处理包含自然写笔服务UUID的设备
if (!serviceUuids.contains(WritechBleUuids.strokeServiceUuid)) return;
if (_devices.containsKey(deviceId)) {
// 更新已知设备的RSSI
_devices[deviceId]!.rssi = rssi;
} else {
// 发现新设备
final device = PenDevice(
deviceId: deviceId,
name: name.isNotEmpty ? name : '未知笔设备',
rssi: rssi,
);
_devices[deviceId] = device;
print('[BLE] 发现新设备: $name (RSSI: $rssi)');
_deviceStateController.add(device);
}
}
/// 连接指定的点阵笔设备
/// 建立GATT连接,发现服务,订阅笔迹数据特征值
Future<bool> connectDevice(String deviceId) async {
final device = _devices[deviceId];
if (device == null) {
print('[BLE] 未找到设备: $deviceId');
return false;
}
// 检查连接数限制
if (connectedCount >= maxConnections) {
print('[BLE] 已达最大连接数限制 ($maxConnections)');
return false;
}
device.state = PenConnectionState.connecting;
_deviceStateController.add(device);
print('[BLE] 正在连接: ${device.name}');
try {
// 步骤1: 建立BLE GATT连接
// 实际调用: FlutterBluePlus.connect(device, autoConnect: false)
await Future.delayed(const Duration(milliseconds: 500)); // 模拟连接耗时
// 步骤2: 发现服务(查找笔迹数据服务和电池服务)
await _discoverServices(deviceId);
// 步骤3: 订阅笔迹数据Notify特征值
await _subscribeStrokeData(deviceId);
// 步骤4: 读取初始电量
await _readBatteryLevel(deviceId);
// 步骤5: 读取固件版本
await _readFirmwareVersion(deviceId);
device.state = PenConnectionState.connected;
device.lastActiveTime = DateTime.now();
_deviceStateController.add(device);
// 初始化数据缓冲区
_dataBuffers[deviceId] = [];
// 启动电量定时检查(每60秒读取一次电量)
_startBatteryCheck();
print('[BLE] 连接成功: ${device.name}, 固件: ${device.firmwareVersion}, 电量: ${device.batteryLevel}%');
return true;
} catch (e) {
device.state = PenConnectionState.disconnected;
_deviceStateController.add(device);
print('[BLE] 连接失败: ${device.name}, 错误: $e');
// 设置自动重连计时器
_scheduleReconnect(deviceId);
return false;
}
}
/// 发现BLE服务列表
Future<void> _discoverServices(String deviceId) async {
// 实际调用: device.discoverServices()
// 验证是否包含笔迹数据服务UUID
print('[BLE] 服务发现完成: $deviceId');
}
/// 订阅笔迹数据Notify特征值
/// 设置MTU为247字节以支持最大数据包
Future<void> _subscribeStrokeData(String deviceId) async {
// 步骤1: 请求MTU协商(247字节,支持每包最多34个坐标点)
// 实际调用: device.requestMtu(247)
// 步骤2: 启用Notify
// 实际调用: characteristic.setNotifyValue(true)
// 步骤3: 监听Notify数据流
// characteristic.onValueReceived.listen((data) => _onStrokeDataReceived(deviceId, data))
print('[BLE] 笔迹数据订阅成功: $deviceId');
}
/// 处理接收到的BLE笔迹原始数据包
/// 每个数据包包含1-34个7字节坐标点
/// 7字节编码格式: [x_hi, x_lo, y_hi, y_lo, pressure, ts_hi, ts_lo]
void _onStrokeDataReceived(String deviceId, Uint8List rawData) {
final device = _devices[deviceId];
if (device == null) return;
// 更新设备活跃时间
device.lastActiveTime = DateTime.now();
// 数据包最小长度: 3字节头 + 7字节坐标 = 10字节
if (rawData.length < 10) {
print('[BLE] 数据包过短,丢弃: ${rawData.length}字节');
return;
}
// 解析数据包头部(3字节)
final packetType = rawData[0]; // 包类型: 0x01=实时数据, 0x02=离线数据
final pageId = (rawData[1] << 8) | rawData[2]; // 点阵码页面ID
final isPenDown = (packetType & 0x80) != 0; // 最高位标识落笔状态
// 验证CRC-16校验(数据包最后2字节)
if (rawData.length > 5) {
final payloadEnd = rawData.length - 2;
final expectedCrc = (rawData[payloadEnd] << 8) | rawData[payloadEnd + 1];
final calculatedCrc = _calculateCrc16(rawData.sublist(0, payloadEnd));
if (expectedCrc != calculatedCrc) {
print('[BLE] CRC校验失败,丢弃数据包');
return;
}
}
// 解码坐标数据(从第3字节开始,每7字节一个坐标点)
final points = <StrokePoint>[];
final dataEnd = rawData.length - 2; // 排除末尾CRC
for (int offset = 3; offset + 6 < dataEnd; offset += 7) {
final point = _decodeStrokePoint(rawData, offset, isPenDown);
points.add(point);
}
if (points.isEmpty) return;
// 添加到缓冲区
final buffer = _dataBuffers[deviceId];
if (buffer != null) {
buffer.addAll(points);
// 缓冲区达到批量大小时回调
if (buffer.length >= batchSize) {
final event = StrokeDataEvent(
deviceId: deviceId,
points: List<StrokePoint>.from(buffer),
pageId: pageId,
);
_strokeStreamController.add(event);
buffer.clear();
}
}
}
/// 解码单个7字节坐标点
/// 编码格式: x(16bit) + y(16bit) + pressure(8bit) + timestamp(16bit)
StrokePoint _decodeStrokePoint(Uint8List data, int offset, bool isPenDown) {
// X坐标(大端序,单位: 0.01mm,范围: 0-65535 即 0-655.35mm
final rawX = (data[offset] << 8) | data[offset + 1];
final x = rawX * 0.01;
// Y坐标(同上)
final rawY = (data[offset + 2] << 8) | data[offset + 3];
final y = rawY * 0.01;
// 压力值(0-255,归一化到0.0-1.0
final rawPressure = data[offset + 4];
final pressure = rawPressure / 255.0;
// 时间戳(毫秒增量,相对于笔迹起始)
final timestamp = (data[offset + 5] << 8) | data[offset + 6];
return StrokePoint(
x: x, y: y,
pressure: pressure,
timestamp: timestamp,
isPenDown: isPenDown,
);
}
/// CRC-16 CCITT校验计算
int _calculateCrc16(Uint8List data) {
int crc = 0xFFFF;
for (int i = 0; i < data.length; i++) {
crc ^= (data[i] << 8);
for (int j = 0; j < 8; j++) {
if ((crc & 0x8000) != 0) {
crc = ((crc << 1) ^ 0x1021) & 0xFFFF;
} else {
crc = (crc << 1) & 0xFFFF;
}
}
}
return crc;
}
/// 读取设备电量
Future<void> _readBatteryLevel(String deviceId) async {
final device = _devices[deviceId];
if (device == null) return;
// 实际调用: 读取Battery Service的Battery Level特征值
// device.batteryLevel = characteristic.value[0];
device.batteryLevel = 85; // 模拟值
// 低电量告警
if (device.batteryLevel > 0 && device.batteryLevel <= lowBatteryThreshold) {
print('[BLE] 低电量告警: ${device.name} 电量 ${device.batteryLevel}%');
_deviceStateController.add(device);
}
}
/// 读取固件版本号
Future<void> _readFirmwareVersion(String deviceId) async {
final device = _devices[deviceId];
if (device == null) return;
// 读取Device Information Service的Firmware Revision特征值
device.firmwareVersion = '1.2.0';
}
/// 启动电量定时检查
void _startBatteryCheck() {
_batteryCheckTimer?.cancel();
_batteryCheckTimer = Timer.periodic(const Duration(seconds: 60), (_) {
for (final entry in _devices.entries) {
if (entry.value.state == PenConnectionState.connected) {
_readBatteryLevel(entry.key);
}
}
});
}
/// 向笔设备发送命令
Future<void> sendCommand(String deviceId, int command, {Uint8List? payload}) async {
final device = _devices[deviceId];
if (device == null || device.state != PenConnectionState.connected) {
print('[BLE] 设备未连接,无法发送命令');
return;
}
// 构造命令数据包: [cmd, payload_len, ...payload, crc_hi, crc_lo]
final totalLen = 2 + (payload?.length ?? 0) + 2;
final packet = Uint8List(totalLen);
packet[0] = command;
packet[1] = payload?.length ?? 0;
if (payload != null) {
packet.setRange(2, 2 + payload.length, payload);
}
final crc = _calculateCrc16(packet.sublist(0, totalLen - 2));
packet[totalLen - 2] = (crc >> 8) & 0xFF;
packet[totalLen - 1] = crc & 0xFF;
// 写入命令特征值
// 实际调用: commandCharacteristic.write(packet)
print('[BLE] 发送命令: 0x${command.toRadixString(16)} -> ${device.name}');
}
/// 请求同步离线数据(笔断线期间缓存的笔迹)
Future<void> syncOfflineData(String deviceId) async {
await sendCommand(deviceId, PenCommand.cmdSyncOffline);
print('[BLE] 已请求同步离线数据: $deviceId');
}
/// 断开指定设备
Future<void> disconnectDevice(String deviceId) async {
final device = _devices[deviceId];
if (device == null) return;
// 取消重连计时器
_reconnectTimers[deviceId]?.cancel();
_reconnectTimers.remove(deviceId);
device.state = PenConnectionState.disconnecting;
_deviceStateController.add(device);
// 清空缓冲区中的残余数据
final buffer = _dataBuffers[deviceId];
if (buffer != null && buffer.isNotEmpty) {
_strokeStreamController.add(StrokeDataEvent(
deviceId: deviceId, points: List.from(buffer), pageId: 0,
));
buffer.clear();
}
// 断开GATT连接
// 实际调用: device.disconnect()
device.state = PenConnectionState.disconnected;
_deviceStateController.add(device);
_dataBuffers.remove(deviceId);
print('[BLE] 已断开设备: ${device.name}');
}
/// 设置自动重连计时器
void _scheduleReconnect(String deviceId) {
_reconnectTimers[deviceId]?.cancel();
_reconnectTimers[deviceId] = Timer(
Duration(seconds: reconnectIntervalSec),
() async {
final device = _devices[deviceId];
if (device != null && device.state == PenConnectionState.disconnected) {
print('[BLE] 尝试自动重连: ${device.name}');
await connectDevice(deviceId);
}
},
);
}
/// 检查蓝牙权限(Android需要位置权限,iOS需要蓝牙使用描述)
Future<bool> _checkBluetoothPermission() async {
// Android: 检查 BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE_LOCATION
// iOS: 检查 CBManager authorization status
return true;
}
/// 断开所有设备并释放资源
void dispose() {
// 停止扫描
stopScan();
// 取消所有重连计时器
for (final timer in _reconnectTimers.values) {
timer.cancel();
}
_reconnectTimers.clear();
// 停止电量检查
_batteryCheckTimer?.cancel();
// 断开所有设备
for (final deviceId in _devices.keys.toList()) {
disconnectDevice(deviceId);
}
// 关闭流控制器
_strokeStreamController.close();
_deviceStateController.close();
_devices.clear();
_dataBuffers.clear();
print('[BLE] BLE服务已销毁');
}
}
```
#### `service/websocket_service.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// WebSocket实时通信服务 - 接收云端实时推送通知
///
/// 功能说明:
/// 1. WebSocket长连接管理(建立、维持、重连)
/// 2. 心跳机制(30秒间隔,检测连接存活性)
/// 3. 消息类型分发(新作业、批改完成、课堂互动、家校消息)
/// 4. 指数退避重连策略(断线后自动重连,逐步增加间隔)
/// 5. 消息ACK确认(确保重要消息不丢失)
/// 6. 离线消息补发(重连后请求离线期间的消息)
import 'dart:async';
import 'dart:convert';
/* ========== 消息类型定义 ========== */
/// WebSocket消息类型枚举
enum WsMessageType {
heartbeat, // 心跳包
heartbeatAck, // 心跳响应
newAssignment, // 新作业通知
gradeComplete, // 批改完成通知
classroomEvent, // 课堂互动事件(发题/收卷等)
parentMessage, // 家校沟通消息
systemNotice, // 系统公告
strokeRealtime, // 实时笔迹数据(课堂模式)
offlineSync, // 离线消息同步
ack, // 消息确认
}
/// WebSocket消息模型
class WsMessage {
final String id; // 消息唯一ID
final WsMessageType type; // 消息类型
final Map<String, dynamic> data; // 消息内容
final int timestamp; // 服务端时间戳
final bool requireAck; // 是否需要ACK确认
WsMessage({
required this.id,
required this.type,
required this.data,
required this.timestamp,
this.requireAck = false,
});
/// 从JSON反序列化
factory WsMessage.fromJson(Map<String, dynamic> json) {
return WsMessage(
id: json['id'] ?? '',
type: _parseMessageType(json['type'] ?? ''),
data: Map<String, dynamic>.from(json['data'] ?? {}),
timestamp: json['timestamp'] ?? 0,
requireAck: json['require_ack'] ?? false,
);
}
/// 序列化为JSON
Map<String, dynamic> toJson() => {
'id': id,
'type': type.name,
'data': data,
'timestamp': timestamp,
};
/// 解析消息类型字符串
static WsMessageType _parseMessageType(String typeStr) {
switch (typeStr) {
case 'heartbeat': return WsMessageType.heartbeat;
case 'heartbeat_ack': return WsMessageType.heartbeatAck;
case 'new_assignment': return WsMessageType.newAssignment;
case 'grade_complete': return WsMessageType.gradeComplete;
case 'classroom_event': return WsMessageType.classroomEvent;
case 'parent_message': return WsMessageType.parentMessage;
case 'system_notice': return WsMessageType.systemNotice;
case 'stroke_realtime': return WsMessageType.strokeRealtime;
case 'offline_sync': return WsMessageType.offlineSync;
case 'ack': return WsMessageType.ack;
default: return WsMessageType.systemNotice;
}
}
}
/* ========== WebSocket连接状态 ========== */
/// 连接状态枚举
enum WsConnectionState {
disconnected, // 未连接
connecting, // 正在连接
connected, // 已连接
reconnecting, // 重连中
}
/* ========== WebSocket服务实现 ========== */
/// WebSocket实时通信服务
/// 维护与云平台的长连接,接收实时推送通知
class WebSocketService {
/// WebSocket服务器地址
static const String _wsUrl = 'wss://ws.writech.com/v1/notify';
/// 心跳间隔(秒)
static const int heartbeatIntervalSec = 30;
/// 心跳超时时间(秒,超过此时间未收到心跳响应则认为连接断开)
static const int heartbeatTimeoutSec = 45;
/// 最大重连间隔(秒,指数退避上限)
static const int maxReconnectIntervalSec = 60;
/// WebSocket实例
dynamic _webSocket; // WebSocket
/// 连接状态
WsConnectionState _state = WsConnectionState.disconnected;
/// 当前认证Token
String _authToken = '';
/// 心跳定时器
Timer? _heartbeatTimer;
/// 心跳超时定时器
Timer? _heartbeatTimeoutTimer;
/// 重连定时器
Timer? _reconnectTimer;
/// 当前重连尝试次数(用于指数退避计算)
int _reconnectAttempts = 0;
/// 最后收到消息的时间戳(用于离线消息补发)
int _lastMessageTimestamp = 0;
/// 消息分发回调注册表
final Map<WsMessageType, List<Function(WsMessage)>> _handlers = {};
/// 连接状态变化回调
final List<Function(WsConnectionState)> _stateListeners = [];
/// 待ACK的消息队列(消息ID -> 超时Timer
final Map<String, Timer> _pendingAcks = {};
/// 获取当前连接状态
WsConnectionState get state => _state;
/// 设置认证Token(登录成功后调用)
void setAuthToken(String token) {
_authToken = token;
}
/// 注册消息处理器
/// 同一类型可注册多个处理器,按注册顺序依次执行
void on(WsMessageType type, Function(WsMessage) handler) {
_handlers.putIfAbsent(type, () => []);
_handlers[type]!.add(handler);
}
/// 移除消息处理器
void off(WsMessageType type, Function(WsMessage) handler) {
_handlers[type]?.remove(handler);
}
/// 监听连接状态变化
void onStateChange(Function(WsConnectionState) listener) {
_stateListeners.add(listener);
}
/// 建立WebSocket连接
/// 附带认证Token和最后消息时间戳(用于离线消息补发)
Future<void> connect() async {
if (_state == WsConnectionState.connected || _state == WsConnectionState.connecting) {
return;
}
_updateState(WsConnectionState.connecting);
try {
// 构造带认证参数的WebSocket URL
final url = '$_wsUrl?token=$_authToken&last_ts=$_lastMessageTimestamp';
// 建立WebSocket连接
// 实际实现: _webSocket = await WebSocket.connect(url);
print('[WebSocket] 正在连接: $_wsUrl');
// 模拟连接成功
await Future.delayed(const Duration(milliseconds: 300));
_updateState(WsConnectionState.connected);
_reconnectAttempts = 0; // 重置重连计数
// 启动心跳机制
_startHeartbeat();
// 监听消息流
// _webSocket.listen(_onMessage, onDone: _onDisconnected, onError: _onError);
print('[WebSocket] 连接成功');
} catch (e) {
print('[WebSocket] 连接失败: $e');
_updateState(WsConnectionState.disconnected);
_scheduleReconnect();
}
}
/// 处理接收到的WebSocket消息
void _onMessage(dynamic rawData) {
try {
final json = jsonDecode(rawData as String) as Map<String, dynamic>;
final message = WsMessage.fromJson(json);
// 更新最后消息时间戳
if (message.timestamp > _lastMessageTimestamp) {
_lastMessageTimestamp = message.timestamp;
}
// 处理心跳响应
if (message.type == WsMessageType.heartbeatAck) {
_onHeartbeatAck();
return;
}
// 处理ACK确认
if (message.type == WsMessageType.ack) {
_onAckReceived(message.data['ack_id'] ?? '');
return;
}
// 如果消息需要ACK,发送确认
if (message.requireAck) {
_sendAck(message.id);
}
// 分发消息到注册的处理器
_dispatchMessage(message);
} catch (e) {
print('[WebSocket] 消息解析失败: $e');
}
}
/// 分发消息到对应类型的处理器
void _dispatchMessage(WsMessage message) {
final handlers = _handlers[message.type];
if (handlers != null && handlers.isNotEmpty) {
for (final handler in handlers) {
try {
handler(message);
} catch (e) {
print('[WebSocket] 消息处理器异常: $e');
}
}
} else {
print('[WebSocket] 未注册的消息类型: ${message.type}');
}
}
/// 发送消息确认(ACK
void _sendAck(String messageId) {
_send({
'type': 'ack',
'data': {'ack_id': messageId},
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
}
/// 处理收到的ACK确认
void _onAckReceived(String messageId) {
_pendingAcks[messageId]?.cancel();
_pendingAcks.remove(messageId);
}
/// 启动心跳机制
/// 每30秒发送一次心跳包,45秒内未收到响应则断开重连
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(
Duration(seconds: heartbeatIntervalSec),
(_) => _sendHeartbeat(),
);
}
/// 发送心跳包
void _sendHeartbeat() {
_send({
'type': 'heartbeat',
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
// 设置心跳超时检测
_heartbeatTimeoutTimer?.cancel();
_heartbeatTimeoutTimer = Timer(
Duration(seconds: heartbeatTimeoutSec),
() {
print('[WebSocket] 心跳超时,断开连接');
_onDisconnected();
},
);
}
/// 收到心跳响应,取消超时计时器
void _onHeartbeatAck() {
_heartbeatTimeoutTimer?.cancel();
}
/// 停止心跳
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_heartbeatTimeoutTimer?.cancel();
_heartbeatTimeoutTimer = null;
}
/// 发送JSON数据
void _send(Map<String, dynamic> data) {
if (_state != WsConnectionState.connected) return;
try {
final jsonStr = jsonEncode(data);
// 实际调用: _webSocket.add(jsonStr);
print('[WebSocket] 发送: ${data['type']}');
} catch (e) {
print('[WebSocket] 发送失败: $e');
}
}
/// 连接断开处理
void _onDisconnected() {
_stopHeartbeat();
_updateState(WsConnectionState.disconnected);
print('[WebSocket] 连接已断开');
_scheduleReconnect();
}
/// 连接错误处理
void _onError(dynamic error) {
print('[WebSocket] 连接错误: $error');
_onDisconnected();
}
/// 安排自动重连(指数退避策略)
/// 间隔: 1s, 2s, 4s, 8s, 16s, 32s, 60s(上限)
void _scheduleReconnect() {
_reconnectTimer?.cancel();
final interval = _calculateReconnectInterval();
_updateState(WsConnectionState.reconnecting);
print('[WebSocket] ${interval}秒后尝试重连 (第${_reconnectAttempts + 1}次)');
_reconnectTimer = Timer(Duration(seconds: interval), () {
_reconnectAttempts++;
connect();
});
}
/// 计算重连间隔(指数退避,上限60秒)
int _calculateReconnectInterval() {
final interval = 1 << _reconnectAttempts; // 2^n
return interval > maxReconnectIntervalSec ? maxReconnectIntervalSec : interval;
}
/// 更新连接状态并通知监听器
void _updateState(WsConnectionState newState) {
if (_state == newState) return;
_state = newState;
for (final listener in _stateListeners) {
try {
listener(newState);
} catch (e) {
print('[WebSocket] 状态监听器异常: $e');
}
}
}
/// 主动重连(应用前台恢复时调用)
void reconnect() {
if (_state == WsConnectionState.connected) return;
_reconnectAttempts = 0;
connect();
}
/// 断开连接并释放资源
void disconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
_stopHeartbeat();
// 取消所有待ACK的超时计时器
for (final timer in _pendingAcks.values) {
timer.cancel();
}
_pendingAcks.clear();
// 关闭WebSocket连接
// 实际调用: _webSocket?.close();
_webSocket = null;
_updateState(WsConnectionState.disconnected);
print('[WebSocket] 已主动断开连接');
}
/// 销毁服务(释放所有资源和回调)
void dispose() {
disconnect();
_handlers.clear();
_stateListeners.clear();
}
}
```
### `ui/common/`
#### `ui/common/stroke_canvas.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// 笔迹渲染组件 - CustomPainter实现高性能笔迹绘制与回放
///
/// 功能说明:
/// 1. 自定义CustomPainter实现60fps笔迹渲染
/// 2. 贝塞尔曲线平滑算法(消除锯齿)
/// 3. 压力感应笔锋效果(笔画粗细随压力变化)
/// 4. 笔迹回放动画(逐点重放书写过程)
/// 5. 多种笔迹颜色和宽度支持
/// 6. 笔迹缩放与平移(手势操作)
/// 7. 双缓冲渲染优化(离屏缓存已绘制内容)
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/* ========== 笔迹数据结构 ========== */
/// 笔迹点数据
class StrokePointData {
final double x;
final double y;
final double pressure;
final int timestamp;
const StrokePointData({
required this.x,
required this.y,
this.pressure = 0.5,
required this.timestamp,
});
}
/// 笔画数据(一次落笔到抬笔的完整路径)
class StrokeData {
final List<StrokePointData> points;
final Color color;
final double baseWidth;
StrokeData({
required this.points,
this.color = Colors.black,
this.baseWidth = 2.0,
});
}
/* ========== 笔迹渲染Widget ========== */
/// 笔迹画布Widget - 展示笔迹渲染与回放
class StrokeCanvasWidget extends StatefulWidget {
/// 笔迹数据列表
final List<StrokeData> strokes;
/// 是否启用回放模式
final bool enableReplay;
/// 回放速度倍率(1.0=原速,2.0=两倍速)
final double replaySpeed;
/// 画布背景色
final Color backgroundColor;
/// 是否显示坐标网格
final bool showGrid;
const StrokeCanvasWidget({
super.key,
required this.strokes,
this.enableReplay = false,
this.replaySpeed = 1.0,
this.backgroundColor = Colors.white,
this.showGrid = false,
});
@override
State<StrokeCanvasWidget> createState() => _StrokeCanvasWidgetState();
}
class _StrokeCanvasWidgetState extends State<StrokeCanvasWidget>
with SingleTickerProviderStateMixin {
/// 回放动画控制器
AnimationController? _replayController;
/// 当前回放进度(0.0-1.0
double _replayProgress = 0.0;
/// 缩放比例
double _scale = 1.0;
/// 平移偏移量
Offset _offset = Offset.zero;
/// 缩放手势起始比例
double _previousScale = 1.0;
/// 离屏缓存(已绘制的静态笔迹)
ui.Image? _cachedImage;
/// 是否需要重建缓存
bool _needsRebuildCache = true;
@override
void initState() {
super.initState();
if (widget.enableReplay) {
_startReplay();
}
}
@override
void didUpdateWidget(covariant StrokeCanvasWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.strokes != oldWidget.strokes) {
_needsRebuildCache = true;
}
if (widget.enableReplay && !oldWidget.enableReplay) {
_startReplay();
}
}
@override
void dispose() {
_replayController?.dispose();
_cachedImage?.dispose();
super.dispose();
}
/// 启动笔迹回放动画
void _startReplay() {
// 计算总回放时长(基于笔迹时间跨度)
if (widget.strokes.isEmpty) return;
int totalDuration = 0;
for (final stroke in widget.strokes) {
if (stroke.points.isNotEmpty) {
totalDuration = max(totalDuration,
stroke.points.last.timestamp - stroke.points.first.timestamp);
}
}
// 根据回放速度调整时长
final durationMs = (totalDuration / widget.replaySpeed).round();
_replayController = AnimationController(
vsync: this,
duration: Duration(milliseconds: max(durationMs, 1000)),
);
_replayController!.addListener(() {
setState(() {
_replayProgress = _replayController!.value;
});
});
_replayController!.forward();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
// 缩放手势
onScaleStart: (details) {
_previousScale = _scale;
},
onScaleUpdate: (details) {
setState(() {
_scale = (_previousScale * details.scale).clamp(0.5, 5.0);
_offset += details.focalPointDelta;
});
},
// 双击重置缩放
onDoubleTap: () {
setState(() {
_scale = 1.0;
_offset = Offset.zero;
});
},
child: ClipRect(
child: CustomPaint(
painter: StrokePainter(
strokes: widget.strokes,
replayProgress: widget.enableReplay ? _replayProgress : 1.0,
scale: _scale,
offset: _offset,
backgroundColor: widget.backgroundColor,
showGrid: widget.showGrid,
),
size: Size.infinite,
),
),
);
}
}
/* ========== 笔迹渲染Painter ========== */
/// CustomPainter实现 - 高性能笔迹绘制
class StrokePainter extends CustomPainter {
final List<StrokeData> strokes;
final double replayProgress;
final double scale;
final Offset offset;
final Color backgroundColor;
final bool showGrid;
StrokePainter({
required this.strokes,
this.replayProgress = 1.0,
this.scale = 1.0,
this.offset = Offset.zero,
this.backgroundColor = Colors.white,
this.showGrid = false,
});
@override
void paint(Canvas canvas, Size size) {
// 绘制背景
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = backgroundColor,
);
// 绘制网格(可选)
if (showGrid) {
_drawGrid(canvas, size);
}
// 保存画布状态,应用变换
canvas.save();
canvas.translate(offset.dx, offset.dy);
canvas.scale(scale);
// 计算当前回放应显示的总点数
int totalPoints = 0;
for (final stroke in strokes) {
totalPoints += stroke.points.length;
}
final visiblePoints = (totalPoints * replayProgress).round();
// 逐笔画渲染
int pointCounter = 0;
for (final stroke in strokes) {
if (pointCounter >= visiblePoints) break;
final strokeVisibleCount = min(
stroke.points.length,
visiblePoints - pointCounter,
);
if (strokeVisibleCount > 1) {
_drawStroke(canvas, stroke, strokeVisibleCount);
}
pointCounter += stroke.points.length;
}
canvas.restore();
}
/// 绘制单个笔画(贝塞尔曲线平滑 + 压力笔锋)
void _drawStroke(Canvas canvas, StrokeData stroke, int visibleCount) {
if (visibleCount < 2) return;
final paint = Paint()
..color = stroke.color
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..isAntiAlias = true;
// 使用压力感应笔锋渲染
for (int i = 1; i < visibleCount; i++) {
final prev = stroke.points[i - 1];
final curr = stroke.points[i];
// 根据压力值计算笔画宽度
// 压力越大,笔画越粗;落笔和抬笔时笔画变细(模拟笔锋效果)
final pressureWidth = _calculatePressureWidth(
stroke.baseWidth, prev.pressure, curr.pressure,
i, visibleCount,
);
paint.strokeWidth = pressureWidth;
if (i >= 2 && i < visibleCount) {
// 三次贝塞尔曲线平滑(消除折线锯齿)
final prevPrev = stroke.points[i - 2];
final cp1x = prev.x + (curr.x - prevPrev.x) / 6.0;
final cp1y = prev.y + (curr.y - prevPrev.y) / 6.0;
final cp2x = curr.x - (curr.x - prev.x) / 6.0;
final cp2y = curr.y - (curr.y - prev.y) / 6.0;
final path = Path()
..moveTo(prev.x, prev.y)
..cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x, curr.y);
canvas.drawPath(path, paint);
} else {
// 前两个点使用直线连接
canvas.drawLine(
ui.Offset(prev.x, prev.y),
ui.Offset(curr.x, curr.y),
paint,
);
}
}
}
/// 根据压力值计算笔画宽度(模拟笔锋效果)
/// 落笔时宽度从细变粗,行笔中根据压力变化,抬笔时由粗变细
double _calculatePressureWidth(
double baseWidth,
double prevPressure,
double currPressure,
int index,
int totalPoints,
) {
// 压力插值
final avgPressure = (prevPressure + currPressure) / 2.0;
// 基础宽度根据压力缩放(0.3x - 2.0x)
double width = baseWidth * (0.3 + avgPressure * 1.7);
// 落笔效果:前5个点逐渐增加宽度
if (index < 5) {
width *= (index / 5.0);
}
// 抬笔效果:最后5个点逐渐减小宽度
final remaining = totalPoints - index;
if (remaining < 5) {
width *= (remaining / 5.0);
}
return max(width, 0.5); // 最小宽度0.5
}
/// 绘制辅助网格
void _drawGrid(Canvas canvas, Size size) {
final gridPaint = Paint()
..color = Colors.grey.withValues(alpha: 0.2)
..strokeWidth = 0.5;
const gridSize = 20.0;
// 竖线
for (double x = 0; x < size.width; x += gridSize) {
canvas.drawLine(
ui.Offset(x, 0),
ui.Offset(x, size.height),
gridPaint,
);
}
// 横线
for (double y = 0; y < size.height; y += gridSize) {
canvas.drawLine(
ui.Offset(0, y),
ui.Offset(size.width, y),
gridPaint,
);
}
}
@override
bool shouldRepaint(covariant StrokePainter oldDelegate) {
return oldDelegate.replayProgress != replayProgress ||
oldDelegate.strokes != strokes ||
oldDelegate.scale != scale ||
oldDelegate.offset != offset;
}
}
/* ========== 笔迹工具函数 ========== */
/// 笔迹数据工具类
class StrokeUtils {
/// 道格拉斯-普克算法简化笔迹点(减少数据量)
/// epsilon: 简化阈值(越大简化越多)
static List<StrokePointData> simplifyStroke(
List<StrokePointData> points, {
double epsilon = 1.0,
}) {
if (points.length <= 2) return points;
// 找到距离首尾连线最远的点
double maxDistance = 0;
int maxIndex = 0;
final first = points.first;
final last = points.last;
for (int i = 1; i < points.length - 1; i++) {
final d = _perpendicularDistance(points[i], first, last);
if (d > maxDistance) {
maxDistance = d;
maxIndex = i;
}
}
// 如果最大距离大于阈值,递归简化
if (maxDistance > epsilon) {
final left = simplifyStroke(points.sublist(0, maxIndex + 1), epsilon: epsilon);
final right = simplifyStroke(points.sublist(maxIndex), epsilon: epsilon);
return [...left.sublist(0, left.length - 1), ...right];
} else {
return [first, last];
}
}
/// 计算点到线段的垂直距离
static double _perpendicularDistance(
StrokePointData point,
StrokePointData lineStart,
StrokePointData lineEnd,
) {
final dx = lineEnd.x - lineStart.x;
final dy = lineEnd.y - lineStart.y;
if (dx == 0 && dy == 0) {
return sqrt(pow(point.x - lineStart.x, 2) + pow(point.y - lineStart.y, 2));
}
final t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
(dx * dx + dy * dy);
final clampedT = t.clamp(0.0, 1.0);
final closestX = lineStart.x + clampedT * dx;
final closestY = lineStart.y + clampedT * dy;
return sqrt(pow(point.x - closestX, 2) + pow(point.y - closestY, 2));
}
/// 计算笔迹边界框(用于视窗适配)
static Rect calculateBounds(List<StrokeData> strokes) {
double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final stroke in strokes) {
for (final point in stroke.points) {
minX = min(minX, point.x);
minY = min(minY, point.y);
maxX = max(maxX, point.x);
maxY = max(maxY, point.y);
}
}
if (minX == double.infinity) return Rect.zero;
return Rect.fromLTRB(minX, minY, maxX, maxY);
}
/// 计算笔迹书写速度(像素/毫秒)
static double calculateWritingSpeed(List<StrokePointData> points) {
if (points.length < 2) return 0;
double totalDistance = 0;
for (int i = 1; i < points.length; i++) {
totalDistance += sqrt(
pow(points[i].x - points[i - 1].x, 2) +
pow(points[i].y - points[i - 1].y, 2),
);
}
final totalTime = points.last.timestamp - points.first.timestamp;
return totalTime > 0 ? totalDistance / totalTime : 0;
}
}
```
### `util/`
#### `util/encryption_util.dart`
```dart
/// 自然写互动课堂手机端应用软件 V1.0
/// 加密工具 - 数据加密、签名、安全存储辅助类
///
/// 功能说明:
/// 1. AES-256-GCM对称加密(本地敏感数据加密)
/// 2. HMAC-SHA256请求签名(API防篡改)
/// 3. RSA非对称加密(密钥交换/设备验证)
/// 4. 安全随机数生成
/// 5. Base64编码/解码工具
/// 6. 密钥派生函数(PBKDF2
/// 7. 证书指纹验证(Certificate Pinning辅助)
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
/// 加密工具类 - 提供通用加密/签名/哈希功能
class EncryptionUtil {
/// AES-256密钥长度(字节)
static const int aesKeyLength = 32;
/// AES-GCM IV/Nonce长度(字节)
static const int aesIvLength = 12;
/// AES-GCM认证标签长度(字节)
static const int aesTagLength = 16;
/// PBKDF2迭代次数
static const int pbkdf2Iterations = 100000;
/// 安全随机数生成器
static final Random _secureRandom = Random.secure();
/* ========== HMAC签名 ========== */
/// HMAC-SHA256签名
/// 用于API请求签名,防止数据被篡改
/// [key] 签名密钥
/// [data] 待签名数据
static String hmacSha256(String key, String data) {
final hmac = Hmac(sha256, utf8.encode(key));
final digest = hmac.convert(utf8.encode(data));
return digest.toString();
}
/// 生成API请求签名
/// 签名格式: HMAC-SHA256(secret, "METHOD\nPATH\nTIMESTAMP\nBODY_SHA256")
static String signApiRequest({
required String secret,
required String method,
required String path,
required int timestamp,
String body = '',
}) {
final bodyHash = sha256.convert(utf8.encode(body)).toString();
final signContent = '$method\n$path\n$timestamp\n$bodyHash';
return hmacSha256(secret, signContent);
}
/// 验证API响应签名
static bool verifyApiSignature({
required String secret,
required String signature,
required String responseBody,
required int timestamp,
}) {
final expected = hmacSha256(secret, '$timestamp\n$responseBody');
return _constantTimeEquals(signature, expected);
}
/* ========== 哈希函数 ========== */
/// SHA-256哈希
static String sha256Hash(String data) {
return sha256.convert(utf8.encode(data)).toString();
}
/// SHA-256哈希(字节数据)
static String sha256HashBytes(Uint8List data) {
return sha256.convert(data).toString();
}
/// MD5哈希(仅用于非安全场景,如文件校验)
static String md5Hash(String data) {
return md5.convert(utf8.encode(data)).toString();
}
/* ========== AES加密 ========== */
/// AES-256-GCM加密
/// 返回格式: Base64(IV + CipherText + AuthTag)
/// [key] 32字节密钥
/// [plaintext] 明文
/// [aad] 附加认证数据(可选,用于绑定上下文)
static String aesEncrypt(Uint8List key, String plaintext, {String? aad}) {
if (key.length != aesKeyLength) {
throw ArgumentError('AES-256密钥长度必须为32字节');
}
// 生成随机IV12字节)
final iv = generateRandomBytes(aesIvLength);
// AES-GCM加密(使用平台原生实现)
// 实际实现需通过MethodChannel调用原生iOS/Android加密API
// iOS: CommonCrypto / CryptoKit
// Android: javax.crypto.Cipher with GCM
final plaintextBytes = utf8.encode(plaintext);
// 模拟加密输出格式: IV(12) + CipherText(n) + Tag(16)
final output = Uint8List(iv.length + plaintextBytes.length + aesTagLength);
output.setRange(0, iv.length, iv);
// 此处为示意,实际需调用原生加密
return base64Encode(output);
}
/// AES-256-GCM解密
/// [key] 32字节密钥
/// [cipherBase64] Base64编码的密文(包含IV+CipherText+Tag
static String aesDecrypt(Uint8List key, String cipherBase64, {String? aad}) {
if (key.length != aesKeyLength) {
throw ArgumentError('AES-256密钥长度必须为32字节');
}
final cipherData = base64Decode(cipherBase64);
if (cipherData.length < aesIvLength + aesTagLength) {
throw ArgumentError('密文数据长度不足');
}
// 分离IV、密文、认证标签
final iv = cipherData.sublist(0, aesIvLength);
final cipherText = cipherData.sublist(aesIvLength, cipherData.length - aesTagLength);
final tag = cipherData.sublist(cipherData.length - aesTagLength);
// 调用原生AES-GCM解密
// 返回解密后的明文
return ''; // 占位返回
}
/* ========== 密钥派生 ========== */
/// PBKDF2密钥派生(从用户密码派生加密密钥)
/// [password] 用户密码
/// [salt] 盐值(至少16字节随机数据)
/// [keyLength] 输出密钥长度(字节)
static Uint8List deriveKey(String password, Uint8List salt, {int keyLength = 32}) {
// PBKDF2-HMAC-SHA256实现
final passwordBytes = utf8.encode(password);
final hmacFunc = Hmac(sha256, passwordBytes);
final blocks = (keyLength / 32).ceil(); // SHA-256输出32字节
final result = Uint8List(keyLength);
int offset = 0;
for (int blockIndex = 1; blockIndex <= blocks; blockIndex++) {
// U1 = HMAC(password, salt || INT_32_BE(blockIndex))
final blockInput = Uint8List(salt.length + 4);
blockInput.setRange(0, salt.length, salt);
blockInput[salt.length] = (blockIndex >> 24) & 0xFF;
blockInput[salt.length + 1] = (blockIndex >> 16) & 0xFF;
blockInput[salt.length + 2] = (blockIndex >> 8) & 0xFF;
blockInput[salt.length + 3] = blockIndex & 0xFF;
var u = Uint8List.fromList(hmacFunc.convert(blockInput).bytes);
var xorResult = Uint8List.fromList(u);
// 迭代计算 U2, U3, ..., UcXOR累加
for (int i = 1; i < pbkdf2Iterations; i++) {
u = Uint8List.fromList(hmacFunc.convert(u).bytes);
for (int j = 0; j < xorResult.length; j++) {
xorResult[j] ^= u[j];
}
}
// 截取需要的字节数
final copyLen = min(32, keyLength - offset);
result.setRange(offset, offset + copyLen, xorResult);
offset += copyLen;
}
return result;
}
/* ========== 随机数生成 ========== */
/// 生成指定长度的安全随机字节
static Uint8List generateRandomBytes(int length) {
final bytes = Uint8List(length);
for (int i = 0; i < length; i++) {
bytes[i] = _secureRandom.nextInt(256);
}
return bytes;
}
/// 生成随机UUID v4
static String generateUuidV4() {
final bytes = generateRandomBytes(16);
// 设置版本位(第7字节高4位 = 0100)
bytes[6] = (bytes[6] & 0x0F) | 0x40;
// 设置变体位(第9字节高2位 = 10)
bytes[8] = (bytes[8] & 0x3F) | 0x80;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
/// 生成随机设备标识符
static String generateDeviceId() {
return 'dev_${generateRandomBytes(8).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
}
/* ========== 证书验证 ========== */
/// 计算证书SHA-256指纹
/// 用于Certificate Pinning验证
static String certificateFingerprint(Uint8List derCertificate) {
return sha256HashBytes(derCertificate);
}
/// 验证证书指纹是否在信任列表中
static bool verifyCertificatePin(
Uint8List derCertificate,
List<String> trustedFingerprints,
) {
final fingerprint = certificateFingerprint(derCertificate);
return trustedFingerprints.any(
(trusted) => _constantTimeEquals(fingerprint, trusted),
);
}
/* ========== 辅助方法 ========== */
/// 常量时间字符串比较(防止时序攻击)
static bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
int result = 0;
for (int i = 0; i < a.length; i++) {
result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return result == 0;
}
/// Base64 URL安全编码
static String base64UrlEncode(Uint8List data) {
return base64Url.encode(data).replaceAll('=', '');
}
/// Base64 URL安全解码
static Uint8List base64UrlDecode(String encoded) {
// 补齐padding
String padded = encoded;
final remainder = padded.length % 4;
if (remainder == 2) padded += '==';
if (remainder == 3) padded += '=';
return base64Url.decode(padded);
}
/// 安全擦除字节数组(防止密钥残留在内存中)
static void secureWipe(Uint8List data) {
for (int i = 0; i < data.length; i++) {
data[i] = 0;
}
}
/// 将十六进制字符串转换为字节数组
static Uint8List hexToBytes(String hex) {
final result = Uint8List(hex.length ~/ 2);
for (int i = 0; i < result.length; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return result;
}
/// 将字节数组转换为十六进制字符串
static String bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
```