3185 lines
92 KiB
Markdown
3185 lines
92 KiB
Markdown
# 自然写互动课堂手机端应用软件 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使用APNs,Android使用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字节');
|
||
}
|
||
|
||
// 生成随机IV(12字节)
|
||
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, ..., Uc,XOR累加
|
||
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();
|
||
}
|
||
}
|
||
```
|
||
|