software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -0,0 +1,607 @@
/// 自然写互动课堂手机端应用软件 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();
}
}
@@ -0,0 +1,552 @@
/// 自然写互动课堂手机端应用软件 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服务已销毁');
}
}
@@ -0,0 +1,406 @@
/// 自然写互动课堂手机端应用软件 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();
}
}