software copyright
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
// 自然写互动课堂平板端应用软件 V1.0
|
||||
// service/api_service.dart - 云平台API服务(Dio HTTP客户端)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// 云平台API基础路径配置
|
||||
class ApiConfig {
|
||||
/// 生产环境API地址
|
||||
static const String productionBaseUrl = 'https://api.writech.com/v1';
|
||||
|
||||
/// 测试环境API地址
|
||||
static const String stagingBaseUrl = 'https://staging-api.writech.com/v1';
|
||||
|
||||
/// 连接超时时间(毫秒)
|
||||
static const int connectTimeout = 15000;
|
||||
|
||||
/// 接收超时时间(毫秒)
|
||||
static const int receiveTimeout = 30000;
|
||||
|
||||
/// Token刷新路径
|
||||
static const String refreshTokenPath = '/auth/refresh';
|
||||
|
||||
/// 最大重试次数
|
||||
static const int maxRetryCount = 3;
|
||||
|
||||
/// HMAC签名密钥标识
|
||||
static const String hmacKeyId = 'writech-pad-v1';
|
||||
}
|
||||
|
||||
/// API响应数据统一封装
|
||||
class ApiResponse<T> {
|
||||
final int code;
|
||||
final String message;
|
||||
final T? data;
|
||||
final String? requestId;
|
||||
|
||||
ApiResponse({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.data,
|
||||
this.requestId,
|
||||
});
|
||||
|
||||
/// 判断请求是否成功
|
||||
bool get isSuccess => code == 0 || code == 200;
|
||||
|
||||
/// 从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'])
|
||||
: json['data'] as T?,
|
||||
requestId: json['request_id'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 离线请求队列项
|
||||
class OfflineRequest {
|
||||
final String id;
|
||||
final String method;
|
||||
final String path;
|
||||
final Map<String, dynamic>? data;
|
||||
final DateTime createdAt;
|
||||
int retryCount;
|
||||
|
||||
OfflineRequest({
|
||||
required this.id,
|
||||
required this.method,
|
||||
required this.path,
|
||||
this.data,
|
||||
required this.createdAt,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
|
||||
/// 序列化为JSON用于本地持久化
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'method': method,
|
||||
'path': path,
|
||||
'data': data,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'retry_count': retryCount,
|
||||
};
|
||||
|
||||
/// 从JSON反序列化
|
||||
factory OfflineRequest.fromJson(Map<String, dynamic> json) {
|
||||
return OfflineRequest(
|
||||
id: json['id'],
|
||||
method: json['method'],
|
||||
path: json['path'],
|
||||
data: json['data'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
retryCount: json['retry_count'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 平板端云平台API服务
|
||||
/// 负责与云平台的所有HTTP通信,包括:
|
||||
/// - JWT双令牌认证与自动刷新
|
||||
/// - HMAC-SHA256请求签名
|
||||
/// - 离线请求队列暂存
|
||||
/// - 学生简化登录(班级+姓名/学号)
|
||||
class PadApiService {
|
||||
late final Dio _dio;
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
/// 当前访问令牌
|
||||
String? _accessToken;
|
||||
|
||||
/// 刷新令牌
|
||||
String? _refreshToken;
|
||||
|
||||
/// Token刷新锁,防止并发刷新
|
||||
Completer<bool>? _refreshCompleter;
|
||||
|
||||
/// 离线请求队列
|
||||
final List<OfflineRequest> _offlineQueue = [];
|
||||
|
||||
/// 网络状态标志
|
||||
bool _isOnline = true;
|
||||
|
||||
/// API事件流控制器(登录状态变化等)
|
||||
final StreamController<String> _eventController =
|
||||
StreamController<String>.broadcast();
|
||||
|
||||
/// API事件流
|
||||
Stream<String> get eventStream => _eventController.stream;
|
||||
|
||||
/// 单例实例
|
||||
static PadApiService? _instance;
|
||||
|
||||
/// 获取单例
|
||||
static PadApiService get instance {
|
||||
_instance ??= PadApiService._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 私有构造函数,初始化Dio客户端
|
||||
PadApiService._internal() {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: ApiConfig.productionBaseUrl,
|
||||
connectTimeout: Duration(milliseconds: ApiConfig.connectTimeout),
|
||||
receiveTimeout: Duration(milliseconds: ApiConfig.receiveTimeout),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-Platform': 'pad',
|
||||
'X-Client-Version': '1.0.0',
|
||||
},
|
||||
));
|
||||
|
||||
// 添加请求拦截器:自动附加Token和HMAC签名
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: _onRequest,
|
||||
onResponse: _onResponse,
|
||||
onError: _onError,
|
||||
));
|
||||
|
||||
// 从安全存储恢复令牌
|
||||
_restoreTokens();
|
||||
}
|
||||
|
||||
/// 从安全存储恢复上次保存的令牌
|
||||
Future<void> _restoreTokens() async {
|
||||
_accessToken = await _secureStorage.read(key: 'access_token');
|
||||
_refreshToken = await _secureStorage.read(key: 'refresh_token');
|
||||
}
|
||||
|
||||
/// 请求拦截器:附加Authorization头和HMAC签名
|
||||
void _onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) {
|
||||
// 附加JWT访问令牌
|
||||
if (_accessToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer $_accessToken';
|
||||
}
|
||||
|
||||
// 生成HMAC-SHA256请求签名
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
options.headers['X-Timestamp'] = timestamp;
|
||||
final signature = _generateSignature(
|
||||
options.method,
|
||||
options.path,
|
||||
timestamp,
|
||||
options.data,
|
||||
);
|
||||
options.headers['X-Signature'] = signature;
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
/// 响应拦截器:统一处理响应
|
||||
void _onResponse(
|
||||
Response response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
/// 错误拦截器:处理401自动刷新Token、离线暂存等
|
||||
Future<void> _onError(
|
||||
DioException error,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// 网络不可用时,将请求加入离线队列
|
||||
if (error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.connectionTimeout) {
|
||||
_isOnline = false;
|
||||
_enqueueOfflineRequest(error.requestOptions);
|
||||
handler.reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 401未授权:尝试刷新Token后重试
|
||||
if (error.response?.statusCode == 401) {
|
||||
final refreshSuccess = await _refreshAccessToken();
|
||||
if (refreshSuccess) {
|
||||
// Token刷新成功,使用新Token重试原请求
|
||||
final retryOptions = error.requestOptions;
|
||||
retryOptions.headers['Authorization'] = 'Bearer $_accessToken';
|
||||
try {
|
||||
final response = await _dio.fetch(retryOptions);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
} catch (retryError) {
|
||||
// 重试也失败了
|
||||
}
|
||||
} else {
|
||||
// Token刷新失败,通知登出
|
||||
_eventController.add('token_expired');
|
||||
}
|
||||
}
|
||||
|
||||
handler.reject(error);
|
||||
}
|
||||
|
||||
/// 生成HMAC-SHA256请求签名
|
||||
/// 签名内容: METHOD\nPATH\nTIMESTAMP\nBODY_SHA256
|
||||
String _generateSignature(
|
||||
String method,
|
||||
String path,
|
||||
String timestamp,
|
||||
dynamic body,
|
||||
) {
|
||||
// 计算请求体SHA256哈希
|
||||
String bodyHash = '';
|
||||
if (body != null) {
|
||||
final bodyStr = body is String ? body : jsonEncode(body);
|
||||
bodyHash = sha256.convert(utf8.encode(bodyStr)).toString();
|
||||
}
|
||||
|
||||
// 拼接签名原文
|
||||
final signContent = '$method\n$path\n$timestamp\n$bodyHash';
|
||||
final hmacKey = utf8.encode(ApiConfig.hmacKeyId);
|
||||
final hmac = Hmac(sha256, hmacKey);
|
||||
final digest = hmac.convert(utf8.encode(signContent));
|
||||
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
/// 刷新访问令牌
|
||||
/// 使用Completer防止并发多次刷新
|
||||
Future<bool> _refreshAccessToken() async {
|
||||
// 如果已经在刷新中,等待结果
|
||||
if (_refreshCompleter != null) {
|
||||
return _refreshCompleter!.future;
|
||||
}
|
||||
|
||||
_refreshCompleter = Completer<bool>();
|
||||
|
||||
try {
|
||||
if (_refreshToken == null) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 发送刷新请求(不经过拦截器避免死循环)
|
||||
final response = await Dio().post(
|
||||
'${ApiConfig.productionBaseUrl}${ApiConfig.refreshTokenPath}',
|
||||
data: {'refresh_token': _refreshToken},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data['code'] == 0) {
|
||||
_accessToken = response.data['data']['access_token'];
|
||||
_refreshToken = response.data['data']['refresh_token'];
|
||||
|
||||
// 持久化新令牌到安全存储
|
||||
await _secureStorage.write(
|
||||
key: 'access_token',
|
||||
value: _accessToken,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: 'refresh_token',
|
||||
value: _refreshToken,
|
||||
);
|
||||
|
||||
_refreshCompleter!.complete(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
} catch (e) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
} finally {
|
||||
_refreshCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将失败的请求加入离线队列
|
||||
void _enqueueOfflineRequest(RequestOptions options) {
|
||||
final offlineReq = OfflineRequest(
|
||||
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
data: options.data is Map ? options.data : null,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_offlineQueue.add(offlineReq);
|
||||
}
|
||||
|
||||
/// 网络恢复后,批量重发离线队列中的请求
|
||||
Future<void> flushOfflineQueue() async {
|
||||
if (_offlineQueue.isEmpty) return;
|
||||
|
||||
_isOnline = true;
|
||||
final pendingRequests = List<OfflineRequest>.from(_offlineQueue);
|
||||
_offlineQueue.clear();
|
||||
|
||||
for (final req in pendingRequests) {
|
||||
try {
|
||||
if (req.retryCount >= ApiConfig.maxRetryCount) continue;
|
||||
req.retryCount++;
|
||||
|
||||
switch (req.method.toUpperCase()) {
|
||||
case 'POST':
|
||||
await _dio.post(req.path, data: req.data);
|
||||
break;
|
||||
case 'PUT':
|
||||
await _dio.put(req.path, data: req.data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
await _dio.delete(req.path);
|
||||
break;
|
||||
default:
|
||||
await _dio.get(req.path);
|
||||
}
|
||||
} catch (e) {
|
||||
// 重发失败的请求重新加入队列
|
||||
if (req.retryCount < ApiConfig.maxRetryCount) {
|
||||
_offlineQueue.add(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 学生登录接口(简化登录,班级+姓名/学号)
|
||||
// ============================================================
|
||||
|
||||
/// 学生简化登录(无需手机号,仅班级+姓名或学号)
|
||||
Future<ApiResponse<Map<String, dynamic>>> studentLogin({
|
||||
required String schoolCode,
|
||||
required String classId,
|
||||
required String studentName,
|
||||
String? studentNo,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/auth/student/login', data: {
|
||||
'school_code': schoolCode,
|
||||
'class_id': classId,
|
||||
'student_name': studentName,
|
||||
'student_no': studentNo,
|
||||
'device_type': 'pad',
|
||||
});
|
||||
|
||||
final result = ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
// 保存登录令牌
|
||||
if (result.isSuccess && result.data != null) {
|
||||
_accessToken = result.data!['access_token'];
|
||||
_refreshToken = result.data!['refresh_token'];
|
||||
await _secureStorage.write(
|
||||
key: 'access_token',
|
||||
value: _accessToken,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: 'refresh_token',
|
||||
value: _refreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '网络请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 教师登录(手机号+验证码)
|
||||
Future<ApiResponse<Map<String, dynamic>>> teacherLogin({
|
||||
required String phone,
|
||||
required String verifyCode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/auth/teacher/login', data: {
|
||||
'phone': phone,
|
||||
'verify_code': verifyCode,
|
||||
'device_type': 'pad',
|
||||
});
|
||||
|
||||
final result = ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.data != null) {
|
||||
_accessToken = result.data!['access_token'];
|
||||
_refreshToken = result.data!['refresh_token'];
|
||||
await _secureStorage.write(
|
||||
key: 'access_token',
|
||||
value: _accessToken,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: 'refresh_token',
|
||||
value: _refreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '网络请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 作业相关接口
|
||||
// ============================================================
|
||||
|
||||
/// 获取学生待完成作业列表
|
||||
Future<ApiResponse<List<dynamic>>> getHomeworkList({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? status,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get('/homework/list', queryParameters: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (status != null) 'status': status,
|
||||
});
|
||||
return ApiResponse<List<dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as List<dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '获取作业列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载作业详情(含题目内容,支持离线作答)
|
||||
Future<ApiResponse<Map<String, dynamic>>> downloadHomework(
|
||||
String homeworkId,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.get('/homework/detail/$homeworkId');
|
||||
return ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '下载作业失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交作业(含笔迹数据)
|
||||
Future<ApiResponse<Map<String, dynamic>>> submitHomework({
|
||||
required String homeworkId,
|
||||
required List<Map<String, dynamic>> strokePages,
|
||||
int? timeCostSeconds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/homework/submit', data: {
|
||||
'homework_id': homeworkId,
|
||||
'stroke_pages': strokePages,
|
||||
'time_cost': timeCostSeconds,
|
||||
'submit_time': DateTime.now().toIso8601String(),
|
||||
});
|
||||
return ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
// 离线时暂存提交请求
|
||||
if (!_isOnline) {
|
||||
_enqueueOfflineRequest(e.requestOptions);
|
||||
}
|
||||
return ApiResponse(code: -1, message: e.message ?? '提交作业失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取作业批改结果
|
||||
Future<ApiResponse<Map<String, dynamic>>> getHomeworkResult(
|
||||
String homeworkId,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.get('/homework/result/$homeworkId');
|
||||
return ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '获取批改结果失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 字帖练习接口
|
||||
// ============================================================
|
||||
|
||||
/// 获取字帖模板列表(按年级/学科分类)
|
||||
Future<ApiResponse<List<dynamic>>> getCopybookTemplates({
|
||||
required String grade,
|
||||
String? subject,
|
||||
int page = 1,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get('/copybook/templates', queryParameters: {
|
||||
'grade': grade,
|
||||
'subject': subject,
|
||||
'page': page,
|
||||
});
|
||||
return ApiResponse<List<dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as List<dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '获取字帖失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传练字笔迹评分
|
||||
Future<ApiResponse<Map<String, dynamic>>> submitPracticeStroke({
|
||||
required String templateId,
|
||||
required String character,
|
||||
required List<Map<String, dynamic>> strokes,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/copybook/evaluate', data: {
|
||||
'template_id': templateId,
|
||||
'character': character,
|
||||
'strokes': strokes,
|
||||
});
|
||||
return ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '提交练字评分失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 错题本接口
|
||||
// ============================================================
|
||||
|
||||
/// 获取错题列表(按知识点/科目分类)
|
||||
Future<ApiResponse<List<dynamic>>> getErrorBookList({
|
||||
String? subject,
|
||||
String? knowledgePoint,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get('/error-book/list', queryParameters: {
|
||||
if (subject != null) 'subject': subject,
|
||||
if (knowledgePoint != null) 'knowledge_point': knowledgePoint,
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
});
|
||||
return ApiResponse<List<dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as List<dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '获取错题本失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 学情与学习计划接口
|
||||
// ============================================================
|
||||
|
||||
/// 获取学生个人学情概览
|
||||
Future<ApiResponse<Map<String, dynamic>>> getStudentProfile() async {
|
||||
try {
|
||||
final response = await _dio.get('/profile/student/overview');
|
||||
return ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '获取学情失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取学习计划列表
|
||||
Future<ApiResponse<List<dynamic>>> getStudyPlans() async {
|
||||
try {
|
||||
final response = await _dio.get('/study-plan/list');
|
||||
return ApiResponse<List<dynamic>>.fromJson(
|
||||
response.data,
|
||||
(data) => data as List<dynamic>,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '获取学习计划失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新学习计划进度
|
||||
Future<ApiResponse<void>> updateStudyPlanProgress({
|
||||
required String planId,
|
||||
required String taskId,
|
||||
required double progress,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put('/study-plan/progress', data: {
|
||||
'plan_id': planId,
|
||||
'task_id': taskId,
|
||||
'progress': progress,
|
||||
'update_time': DateTime.now().toIso8601String(),
|
||||
});
|
||||
return ApiResponse<void>.fromJson(response.data, null);
|
||||
} on DioException catch (e) {
|
||||
return ApiResponse(code: -1, message: e.message ?? '更新进度失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出,清除本地令牌
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _dio.post('/auth/logout');
|
||||
} catch (_) {
|
||||
// 忽略登出请求失败
|
||||
}
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
await _secureStorage.delete(key: 'access_token');
|
||||
await _secureStorage.delete(key: 'refresh_token');
|
||||
_eventController.add('logged_out');
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
_eventController.close();
|
||||
_dio.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
// 自然写互动课堂平板端应用软件 V1.0
|
||||
// service/ble_service.dart - BLE蓝牙点阵笔连接服务
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// BLE服务UUID常量定义
|
||||
/// 基于自然写点阵笔自定义GATT Service规范
|
||||
class PadBleConstants {
|
||||
/// 点阵笔主服务UUID
|
||||
static const String penServiceUuid = '0000ffe0-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 笔迹坐标数据特征值UUID(Notify)
|
||||
static const String strokeCharUuid = '0000ffe1-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 笔控制指令特征值UUID(Write)
|
||||
static const String controlCharUuid = '0000ffe2-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 电量信息特征值UUID(Read/Notify)
|
||||
static const String batteryCharUuid = '0000ffe3-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 设备信息服务UUID
|
||||
static const String deviceInfoUuid = '0000180a-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 扫描超时时间(秒)
|
||||
static const int scanTimeoutSeconds = 15;
|
||||
|
||||
/// 自动重连延迟(秒)
|
||||
static const int reconnectDelaySeconds = 3;
|
||||
|
||||
/// 最大重连次数
|
||||
static const int maxReconnectAttempts = 10;
|
||||
|
||||
/// MTU协商大小
|
||||
static const int requestedMtu = 247;
|
||||
|
||||
/// 笔迹数据缓冲批量回调阈值
|
||||
static const int strokeBatchSize = 8;
|
||||
|
||||
/// 电量读取间隔(秒)
|
||||
static const int batteryReadInterval = 60;
|
||||
}
|
||||
|
||||
/// 单个笔迹坐标点数据
|
||||
class PadPenPoint {
|
||||
/// X坐标(0.01mm精度,16位无符号)
|
||||
final double x;
|
||||
|
||||
/// Y坐标(0.01mm精度,16位无符号)
|
||||
final double y;
|
||||
|
||||
/// 压力值(0-255,8位无符号)
|
||||
final int pressure;
|
||||
|
||||
/// 时间戳(相对值,16位无符号,单位ms)
|
||||
final int timestamp;
|
||||
|
||||
/// 是否为落笔点
|
||||
final bool isPenDown;
|
||||
|
||||
PadPenPoint({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.pressure,
|
||||
required this.timestamp,
|
||||
this.isPenDown = false,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PadPenPoint(x: ${x.toStringAsFixed(2)}, y: ${y.toStringAsFixed(2)}, '
|
||||
'p: $pressure, t: $timestamp)';
|
||||
}
|
||||
|
||||
/// 点阵笔设备信息
|
||||
class PadPenDevice {
|
||||
/// 设备蓝牙MAC地址
|
||||
final String macAddress;
|
||||
|
||||
/// 设备名称
|
||||
final String name;
|
||||
|
||||
/// 信号强度(RSSI)
|
||||
int rssi;
|
||||
|
||||
/// 当前连接状态
|
||||
PenConnectionState connectionState;
|
||||
|
||||
/// 电量百分比(0-100)
|
||||
int batteryLevel;
|
||||
|
||||
/// 固件版本号
|
||||
String? firmwareVersion;
|
||||
|
||||
/// 当前所在点阵码页面ID
|
||||
String? currentPageId;
|
||||
|
||||
PadPenDevice({
|
||||
required this.macAddress,
|
||||
required this.name,
|
||||
this.rssi = -100,
|
||||
this.connectionState = PenConnectionState.disconnected,
|
||||
this.batteryLevel = -1,
|
||||
this.firmwareVersion,
|
||||
this.currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
/// 笔连接状态枚举
|
||||
enum PenConnectionState {
|
||||
/// 未连接
|
||||
disconnected,
|
||||
|
||||
/// 正在扫描
|
||||
scanning,
|
||||
|
||||
/// 正在连接
|
||||
connecting,
|
||||
|
||||
/// 已连接
|
||||
connected,
|
||||
|
||||
/// 正在断开
|
||||
disconnecting,
|
||||
|
||||
/// 自动重连中
|
||||
reconnecting,
|
||||
}
|
||||
|
||||
/// 笔迹数据事件(批量坐标点回调)
|
||||
class PenStrokeEvent {
|
||||
/// 来源笔的MAC地址
|
||||
final String penMac;
|
||||
|
||||
/// 坐标点列表
|
||||
final List<PadPenPoint> points;
|
||||
|
||||
/// 所在页面ID(点阵码识别)
|
||||
final String? pageId;
|
||||
|
||||
PenStrokeEvent({
|
||||
required this.penMac,
|
||||
required this.points,
|
||||
this.pageId,
|
||||
});
|
||||
}
|
||||
|
||||
/// BLE蓝牙点阵笔连接服务
|
||||
/// 负责扫描、连接、数据接收、电量监控、自动重连等功能
|
||||
/// 平板端支持同时连接1支笔(学生个人使用场景)
|
||||
class PadBleService {
|
||||
/// 已发现的设备列表
|
||||
final List<PadPenDevice> _discoveredDevices = [];
|
||||
|
||||
/// 当前已连接的笔
|
||||
PadPenDevice? _connectedPen;
|
||||
|
||||
/// 笔迹数据缓冲区(累积到阈值后批量回调)
|
||||
final List<PadPenPoint> _strokeBuffer = [];
|
||||
|
||||
/// 扫描结果流
|
||||
final StreamController<List<PadPenDevice>> _scanController =
|
||||
StreamController<List<PadPenDevice>>.broadcast();
|
||||
|
||||
/// 笔迹数据事件流
|
||||
final StreamController<PenStrokeEvent> _strokeController =
|
||||
StreamController<PenStrokeEvent>.broadcast();
|
||||
|
||||
/// 连接状态变化流
|
||||
final StreamController<PenConnectionState> _connectionController =
|
||||
StreamController<PenConnectionState>.broadcast();
|
||||
|
||||
/// 电量变化流
|
||||
final StreamController<int> _batteryController =
|
||||
StreamController<int>.broadcast();
|
||||
|
||||
/// 自动重连计数器
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
/// 重连定时器
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
/// 电量读取定时器
|
||||
Timer? _batteryTimer;
|
||||
|
||||
/// 是否正在扫描
|
||||
bool _isScanning = false;
|
||||
|
||||
/// 公开的流
|
||||
Stream<List<PadPenDevice>> get scanStream => _scanController.stream;
|
||||
Stream<PenStrokeEvent> get strokeStream => _strokeController.stream;
|
||||
Stream<PenConnectionState> get connectionStream =>
|
||||
_connectionController.stream;
|
||||
Stream<int> get batteryStream => _batteryController.stream;
|
||||
|
||||
/// 获取当前连接的笔
|
||||
PadPenDevice? get connectedPen => _connectedPen;
|
||||
|
||||
/// 开始扫描附近的点阵笔设备
|
||||
/// 按服务UUID过滤,仅发现自然写点阵笔
|
||||
Future<void> startScan() async {
|
||||
if (_isScanning) return;
|
||||
_isScanning = true;
|
||||
_discoveredDevices.clear();
|
||||
|
||||
// 通知扫描状态
|
||||
_connectionController.add(PenConnectionState.scanning);
|
||||
|
||||
// 模拟BLE扫描(实际使用flutter_blue_plus库)
|
||||
// 过滤条件:仅发现包含pen_service_uuid的设备
|
||||
// scanFilters: [ScanFilter(serviceUuid: PadBleConstants.penServiceUuid)]
|
||||
|
||||
// 设置扫描超时
|
||||
Timer(Duration(seconds: PadBleConstants.scanTimeoutSeconds), () {
|
||||
stopScan();
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止扫描
|
||||
Future<void> stopScan() async {
|
||||
_isScanning = false;
|
||||
// 实际调用: FlutterBluePlus.stopScan()
|
||||
}
|
||||
|
||||
/// 处理扫描结果回调
|
||||
void _onScanResult(String mac, String name, int rssi) {
|
||||
// 检查是否已发现过
|
||||
final existingIndex = _discoveredDevices.indexWhere(
|
||||
(d) => d.macAddress == mac,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新已有设备的RSSI
|
||||
_discoveredDevices[existingIndex].rssi = rssi;
|
||||
} else {
|
||||
// 添加新发现的设备
|
||||
_discoveredDevices.add(PadPenDevice(
|
||||
macAddress: mac,
|
||||
name: name,
|
||||
rssi: rssi,
|
||||
));
|
||||
}
|
||||
|
||||
// 按信号强度降序排列
|
||||
_discoveredDevices.sort((a, b) => b.rssi.compareTo(a.rssi));
|
||||
_scanController.add(List.from(_discoveredDevices));
|
||||
}
|
||||
|
||||
/// 连接指定的点阵笔
|
||||
/// [device] 要连接的笔设备信息
|
||||
Future<bool> connectPen(PadPenDevice device) async {
|
||||
// 先断开已有连接
|
||||
if (_connectedPen != null) {
|
||||
await disconnectPen();
|
||||
}
|
||||
|
||||
device.connectionState = PenConnectionState.connecting;
|
||||
_connectionController.add(PenConnectionState.connecting);
|
||||
|
||||
try {
|
||||
// 停止扫描
|
||||
await stopScan();
|
||||
|
||||
// 执行BLE连接
|
||||
// 实际调用: device.connect(timeout: Duration(seconds: 10))
|
||||
// 协商MTU
|
||||
// await device.requestMtu(PadBleConstants.requestedMtu);
|
||||
|
||||
// 发现服务和特征值
|
||||
// final services = await device.discoverServices();
|
||||
// 查找笔迹数据特征值并订阅Notify
|
||||
|
||||
// 设置连接成功状态
|
||||
device.connectionState = PenConnectionState.connected;
|
||||
_connectedPen = device;
|
||||
_reconnectAttempts = 0;
|
||||
_connectionController.add(PenConnectionState.connected);
|
||||
|
||||
// 启动电量定时读取
|
||||
_startBatteryMonitor();
|
||||
|
||||
// 订阅笔迹数据特征值
|
||||
_subscribeStrokeData();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
device.connectionState = PenConnectionState.disconnected;
|
||||
_connectionController.add(PenConnectionState.disconnected);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅笔迹坐标数据Notify特征值
|
||||
void _subscribeStrokeData() {
|
||||
// 实际调用:
|
||||
// characteristic.setNotifyValue(true);
|
||||
// characteristic.onValueReceived.listen(_onStrokeDataReceived);
|
||||
}
|
||||
|
||||
/// 处理接收到的笔迹原始数据(7字节紧凑编码)
|
||||
/// 数据格式:[X_H, X_L, Y_H, Y_L, Pressure, TS_H, TS_L]
|
||||
/// X: 16位无符号(0.01mm精度)
|
||||
/// Y: 16位无符号(0.01mm精度)
|
||||
/// Pressure: 8位无符号(0-255)
|
||||
/// Timestamp: 16位无符号(相对毫秒)
|
||||
void _onStrokeDataReceived(Uint8List rawData) {
|
||||
if (rawData.length < 7) return;
|
||||
|
||||
// 可能包含多个坐标点(每7字节一个)
|
||||
int offset = 0;
|
||||
while (offset + 7 <= rawData.length) {
|
||||
// 解码X坐标(大端序16位)
|
||||
final int rawX = (rawData[offset] << 8) | rawData[offset + 1];
|
||||
final double x = rawX * 0.01; // 转换为毫米
|
||||
|
||||
// 解码Y坐标
|
||||
final int rawY = (rawData[offset + 2] << 8) | rawData[offset + 3];
|
||||
final double y = rawY * 0.01;
|
||||
|
||||
// 解码压力值
|
||||
final int pressure = rawData[offset + 4];
|
||||
|
||||
// 解码时间戳
|
||||
final int timestamp =
|
||||
(rawData[offset + 5] << 8) | rawData[offset + 6];
|
||||
|
||||
// 判断落笔/抬笔(压力值>0为落笔)
|
||||
final bool isPenDown = pressure > 0;
|
||||
|
||||
final point = PadPenPoint(
|
||||
x: x,
|
||||
y: y,
|
||||
pressure: pressure,
|
||||
timestamp: timestamp,
|
||||
isPenDown: isPenDown,
|
||||
);
|
||||
|
||||
_strokeBuffer.add(point);
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
// CRC-16 CCITT校验(如果数据包尾部有2字节CRC)
|
||||
if (rawData.length > offset + 1) {
|
||||
final int receivedCrc = (rawData[offset] << 8) | rawData[offset + 1];
|
||||
final int calculatedCrc = _calculateCrc16(
|
||||
rawData.sublist(0, offset),
|
||||
);
|
||||
if (receivedCrc != calculatedCrc) {
|
||||
// CRC校验失败,丢弃本批数据
|
||||
_strokeBuffer.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 达到批量阈值后回调
|
||||
if (_strokeBuffer.length >= PadBleConstants.strokeBatchSize) {
|
||||
_flushStrokeBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/// 将缓冲区中的笔迹数据批量回调
|
||||
void _flushStrokeBuffer() {
|
||||
if (_strokeBuffer.isEmpty || _connectedPen == null) return;
|
||||
|
||||
final event = PenStrokeEvent(
|
||||
penMac: _connectedPen!.macAddress,
|
||||
points: List.from(_strokeBuffer),
|
||||
pageId: _connectedPen!.currentPageId,
|
||||
);
|
||||
|
||||
_strokeController.add(event);
|
||||
_strokeBuffer.clear();
|
||||
}
|
||||
|
||||
/// CRC-16 CCITT校验算法
|
||||
/// 多项式: 0x1021, 初始值: 0xFFFF
|
||||
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;
|
||||
}
|
||||
|
||||
/// 启动电量定时读取
|
||||
void _startBatteryMonitor() {
|
||||
_batteryTimer?.cancel();
|
||||
_batteryTimer = Timer.periodic(
|
||||
Duration(seconds: PadBleConstants.batteryReadInterval),
|
||||
(_) => _readBatteryLevel(),
|
||||
);
|
||||
// 立即读取一次
|
||||
_readBatteryLevel();
|
||||
}
|
||||
|
||||
/// 读取笔电量
|
||||
Future<void> _readBatteryLevel() async {
|
||||
if (_connectedPen == null) return;
|
||||
|
||||
try {
|
||||
// 实际调用: 读取battery特征值
|
||||
// final value = await batteryCharacteristic.read();
|
||||
// _connectedPen!.batteryLevel = value[0];
|
||||
// _batteryController.add(_connectedPen!.batteryLevel);
|
||||
} catch (e) {
|
||||
// 读取失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/// 向笔发送控制指令
|
||||
/// [command] 指令类型(如:LED闪烁、蜂鸣提示、固件信息查询)
|
||||
Future<void> sendCommand(int command, [Uint8List? payload]) async {
|
||||
if (_connectedPen == null) return;
|
||||
|
||||
// 构建指令包:[CMD, LEN, PAYLOAD..., CRC_H, CRC_L]
|
||||
final List<int> packet = [command];
|
||||
if (payload != null) {
|
||||
packet.add(payload.length);
|
||||
packet.addAll(payload);
|
||||
} else {
|
||||
packet.add(0);
|
||||
}
|
||||
|
||||
// 追加CRC校验
|
||||
final crc = _calculateCrc16(Uint8List.fromList(packet));
|
||||
packet.add((crc >> 8) & 0xFF);
|
||||
packet.add(crc & 0xFF);
|
||||
|
||||
// 实际调用: controlCharacteristic.write(Uint8List.fromList(packet));
|
||||
}
|
||||
|
||||
/// 断开当前笔连接
|
||||
Future<void> disconnectPen() async {
|
||||
_batteryTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
if (_connectedPen != null) {
|
||||
_connectedPen!.connectionState = PenConnectionState.disconnecting;
|
||||
_connectionController.add(PenConnectionState.disconnecting);
|
||||
|
||||
// 实际调用: device.disconnect();
|
||||
_connectedPen!.connectionState = PenConnectionState.disconnected;
|
||||
_connectedPen = null;
|
||||
_connectionController.add(PenConnectionState.disconnected);
|
||||
}
|
||||
|
||||
// 清空缓冲区
|
||||
_flushStrokeBuffer();
|
||||
}
|
||||
|
||||
/// 处理连接意外断开,启动自动重连
|
||||
void _onDisconnected(PadPenDevice device) {
|
||||
if (_reconnectAttempts >= PadBleConstants.maxReconnectAttempts) {
|
||||
// 超过最大重连次数,放弃重连
|
||||
_connectionController.add(PenConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
_connectionController.add(PenConnectionState.reconnecting);
|
||||
_reconnectAttempts++;
|
||||
|
||||
// 指数退避延迟重连
|
||||
final delay = PadBleConstants.reconnectDelaySeconds * _reconnectAttempts;
|
||||
final clampedDelay = delay > 30 ? 30 : delay;
|
||||
|
||||
_reconnectTimer = Timer(Duration(seconds: clampedDelay), () async {
|
||||
final success = await connectPen(device);
|
||||
if (!success) {
|
||||
_onDisconnected(device);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 释放所有资源
|
||||
void dispose() {
|
||||
_batteryTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_scanController.close();
|
||||
_strokeController.close();
|
||||
_connectionController.close();
|
||||
_batteryController.close();
|
||||
_strokeBuffer.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user