# 自然写互动课堂平板端应用软件 V1.0 ## 软件著作权鉴别材料 — 源程序 > **权利人**:深圳自然写科技有限公司 > **版本号**:V1.0 --- ## 源程序目录结构 ``` 10-writech-app-pad/ ├── main.dart ├── bloc/ │ └── homework_bloc.dart ├── eye_care/ │ └── eye_care_manager.dart ├── renderer/ │ └── stroke_painter.dart ├── repository/ │ └── local_repository.dart └── service/ ├── api_service.dart └── ble_service.dart ``` --- ## 源程序文件清单 ### (根目录) #### `main.dart` ```dart /// 自然写互动课堂平板端应用软件 V1.0 /// APP入口 - Flutter平板端应用初始化 /// /// 功能说明: /// 1. 平板端应用初始化(Pad自适应布局配置) /// 2. 学生端/教师端双模式切换 /// 3. 护眼模式初始化(色温调节、使用时长监控) /// 4. 全局Bloc状态管理注入 /// 5. 离线模式支持(断网时可继续作答) import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; /// 应用入口 void main() async { WidgetsFlutterBinding.ensureInitialized(); // 全局错误处理 FlutterError.onError = (FlutterErrorDetails details) { FlutterError.presentError(details); debugPrint('[CrashReport] ${details.exception}'); }; // 设置系统UI(平板端支持横屏+竖屏) await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); // 初始化全局服务 await _initServices(); runZonedGuarded(() { runApp(const WritechPadApp()); }, (error, stack) { debugPrint('[CrashReport] $error\n$stack'); }); } /// 初始化全局服务 Future _initServices() async { debugPrint('[App] 服务初始化开始'); // 初始化数据库、网络、BLE、护眼模块 debugPrint('[App] 服务初始化完成'); } /// 平板端应用根Widget class WritechPadApp extends StatefulWidget { const WritechPadApp({super.key}); @override State createState() => _WritechPadAppState(); } class _WritechPadAppState extends State with WidgetsBindingObserver { /// 当前用户模式(学生/教师) String _userMode = 'student'; /// 护眼模式是否开启 bool _eyeCareEnabled = false; /// 色温滤镜值(0.0=正常,1.0=最暖) double _colorTemperature = 0.0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugPrint('[App] 应用恢复前台'); } else if (state == AppLifecycleState.paused) { debugPrint('[App] 应用进入后台'); } } @override Widget build(BuildContext context) { return MaterialApp( title: '自然写互动课堂', debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF4CAF50), brightness: Brightness.light, ), fontFamily: 'NotoSansSC', ), // 护眼色温滤镜叠加 builder: (context, child) { if (_eyeCareEnabled && _colorTemperature > 0) { return ColorFiltered( colorFilter: ColorFilter.matrix(_buildWarmMatrix(_colorTemperature)), child: child, ); } return child ?? const SizedBox(); }, initialRoute: '/splash', routes: { '/splash': (_) => const _SplashPage(), '/login': (_) => const _LoginPage(), '/student_home': (_) => const _StudentHomePage(), '/teacher_home': (_) => const _TeacherHomePage(), '/homework': (_) => const _HomeworkPage(), '/practice': (_) => const _PracticePage(), '/error_book': (_) => const _ErrorBookPage(), '/settings': (_) => const _SettingsPage(), }, ); } /// 构建暖色温矩阵(护眼模式) List _buildWarmMatrix(double intensity) { final r = 1.0; final g = 1.0 - intensity * 0.1; final b = 1.0 - intensity * 0.3; return [ r, 0, 0, 0, 0, 0, g, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, 1, 0, ]; } } // 占位页面声明 class _SplashPage extends StatelessWidget { const _SplashPage(); @override Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('自然写'))); } class _LoginPage extends StatelessWidget { const _LoginPage(); @override Widget build(BuildContext context) => const Scaffold(); } class _StudentHomePage extends StatelessWidget { const _StudentHomePage(); @override Widget build(BuildContext context) => const Scaffold(); } class _TeacherHomePage extends StatelessWidget { const _TeacherHomePage(); @override Widget build(BuildContext context) => const Scaffold(); } class _HomeworkPage extends StatelessWidget { const _HomeworkPage(); @override Widget build(BuildContext context) => const Scaffold(); } class _PracticePage extends StatelessWidget { const _PracticePage(); @override Widget build(BuildContext context) => const Scaffold(); } class _ErrorBookPage extends StatelessWidget { const _ErrorBookPage(); @override Widget build(BuildContext context) => const Scaffold(); } class _SettingsPage extends StatelessWidget { const _SettingsPage(); @override Widget build(BuildContext context) => const Scaffold(); } ``` ### `bloc/` #### `bloc/homework_bloc.dart` ```dart // 自然写互动课堂平板端应用软件 V1.0 // bloc/homework_bloc.dart - 作业状态管理(Bloc模式) import 'dart:async'; /// 作业状态枚举 enum HomeworkStatus { /// 待完成 pending, /// 进行中(已开始作答) inProgress, /// 已提交 submitted, /// 已批改 graded, /// 已过期 expired, } /// 作业数据模型 class HomeworkItem { final String id; final String title; final String subject; final String teacherName; final HomeworkStatus status; final DateTime? assignedAt; final DateTime? deadline; final DateTime? submittedAt; final int? score; final int totalQuestions; final int answeredQuestions; final String? coverImageUrl; HomeworkItem({ required this.id, required this.title, required this.subject, required this.teacherName, this.status = HomeworkStatus.pending, this.assignedAt, this.deadline, this.submittedAt, this.score, this.totalQuestions = 0, this.answeredQuestions = 0, this.coverImageUrl, }); /// 是否已过截止时间 bool get isOverdue => deadline != null && DateTime.now().isAfter(deadline!); /// 作答进度百分比 double get progress => totalQuestions > 0 ? answeredQuestions / totalQuestions : 0.0; /// 从JSON解析 factory HomeworkItem.fromJson(Map json) { return HomeworkItem( id: json['id'] ?? '', title: json['title'] ?? '', subject: json['subject'] ?? '', teacherName: json['teacher_name'] ?? '', status: _parseStatus(json['status']), assignedAt: json['assigned_at'] != null ? DateTime.tryParse(json['assigned_at']) : null, deadline: json['deadline'] != null ? DateTime.tryParse(json['deadline']) : null, submittedAt: json['submitted_at'] != null ? DateTime.tryParse(json['submitted_at']) : null, score: json['score'], totalQuestions: json['total_questions'] ?? 0, answeredQuestions: json['answered_questions'] ?? 0, coverImageUrl: json['cover_image_url'], ); } /// 解析状态字符串 static HomeworkStatus _parseStatus(String? status) { switch (status) { case 'pending': return HomeworkStatus.pending; case 'in_progress': return HomeworkStatus.inProgress; case 'submitted': return HomeworkStatus.submitted; case 'graded': return HomeworkStatus.graded; case 'expired': return HomeworkStatus.expired; default: return HomeworkStatus.pending; } } } /// 作业详情中的题目数据 class HomeworkQuestion { final String id; final int index; final String type; final String content; final String? imageUrl; final List? options; final String? correctAnswer; final String? studentAnswer; final List>? studentStrokes; final int? questionScore; final int? earnedScore; final String? teacherComment; HomeworkQuestion({ required this.id, required this.index, required this.type, required this.content, this.imageUrl, this.options, this.correctAnswer, this.studentAnswer, this.studentStrokes, this.questionScore, this.earnedScore, this.teacherComment, }); /// 从JSON解析 factory HomeworkQuestion.fromJson(Map json) { return HomeworkQuestion( id: json['id'] ?? '', index: json['index'] ?? 0, type: json['type'] ?? 'write', content: json['content'] ?? '', imageUrl: json['image_url'], options: json['options'] != null ? List.from(json['options']) : null, correctAnswer: json['correct_answer'], studentAnswer: json['student_answer'], studentStrokes: json['student_strokes'] != null ? List>.from(json['student_strokes']) : null, questionScore: json['question_score'], earnedScore: json['earned_score'], teacherComment: json['teacher_comment'], ); } } // ============================================================ // Bloc Events(作业相关事件定义) // ============================================================ /// 作业事件基类 abstract class HomeworkEvent {} /// 加载作业列表事件 class LoadHomeworkListEvent extends HomeworkEvent { final HomeworkStatus? filterStatus; final int page; final bool refresh; LoadHomeworkListEvent({ this.filterStatus, this.page = 1, this.refresh = false, }); } /// 下载作业详情事件(用于离线作答) class DownloadHomeworkEvent extends HomeworkEvent { final String homeworkId; DownloadHomeworkEvent(this.homeworkId); } /// 保存作答进度事件(本地暂存) class SaveAnswerProgressEvent extends HomeworkEvent { final String homeworkId; final String questionId; final String? textAnswer; final List>? strokeData; SaveAnswerProgressEvent({ required this.homeworkId, required this.questionId, this.textAnswer, this.strokeData, }); } /// 提交作业事件 class SubmitHomeworkEvent extends HomeworkEvent { final String homeworkId; SubmitHomeworkEvent(this.homeworkId); } /// 查看批改结果事件 class ViewGradeResultEvent extends HomeworkEvent { final String homeworkId; ViewGradeResultEvent(this.homeworkId); } // ============================================================ // Bloc States(作业相关状态定义) // ============================================================ /// 作业状态基类 abstract class HomeworkState {} /// 初始状态 class HomeworkInitialState extends HomeworkState {} /// 加载中状态 class HomeworkLoadingState extends HomeworkState { final String? message; HomeworkLoadingState({this.message}); } /// 作业列表加载成功状态 class HomeworkListLoadedState extends HomeworkState { final List homeworks; final bool hasMore; final int currentPage; final HomeworkStatus? currentFilter; /// 各状态的作业计数统计 final Map statusCounts; HomeworkListLoadedState({ required this.homeworks, this.hasMore = false, this.currentPage = 1, this.currentFilter, this.statusCounts = const {}, }); } /// 作业详情加载成功状态 class HomeworkDetailLoadedState extends HomeworkState { final HomeworkItem homework; final List questions; final bool isOfflineAvailable; HomeworkDetailLoadedState({ required this.homework, required this.questions, this.isOfflineAvailable = false, }); } /// 作答进度保存成功状态 class AnswerSavedState extends HomeworkState { final String homeworkId; final String questionId; final int answeredCount; final int totalCount; AnswerSavedState({ required this.homeworkId, required this.questionId, required this.answeredCount, required this.totalCount, }); } /// 作业提交成功状态 class HomeworkSubmittedState extends HomeworkState { final String homeworkId; final DateTime submittedAt; HomeworkSubmittedState({ required this.homeworkId, required this.submittedAt, }); } /// 批改结果状态 class GradeResultState extends HomeworkState { final HomeworkItem homework; final List questions; final int totalScore; final int earnedScore; final String? overallComment; GradeResultState({ required this.homework, required this.questions, required this.totalScore, required this.earnedScore, this.overallComment, }); } /// 错误状态 class HomeworkErrorState extends HomeworkState { final String message; final String? actionType; HomeworkErrorState({ required this.message, this.actionType, }); } // ============================================================ // HomeworkBloc 实现 // ============================================================ /// 作业状态管理Bloc /// 管理作业列表加载、下载、作答、提交、查看批改结果等完整流程 class HomeworkBloc { /// 当前状态 HomeworkState _state = HomeworkInitialState(); /// 状态流控制器 final StreamController _stateController = StreamController.broadcast(); /// 本地缓存的作业列表 List _cachedHomeworks = []; /// 本地缓存的作答进度 {homeworkId: {questionId: answerData}} final Map> _answerCache = {}; /// 获取当前状态 HomeworkState get state => _state; /// 状态流 Stream get stateStream => _stateController.stream; /// 发射新状态 void _emit(HomeworkState newState) { _state = newState; _stateController.add(newState); } /// 处理事件分发 void add(HomeworkEvent event) { if (event is LoadHomeworkListEvent) { _handleLoadList(event); } else if (event is DownloadHomeworkEvent) { _handleDownload(event); } else if (event is SaveAnswerProgressEvent) { _handleSaveAnswer(event); } else if (event is SubmitHomeworkEvent) { _handleSubmit(event); } else if (event is ViewGradeResultEvent) { _handleViewGrade(event); } } /// 处理加载作业列表 Future _handleLoadList(LoadHomeworkListEvent event) async { try { _emit(HomeworkLoadingState(message: '正在加载作业列表...')); // 调用API获取作业列表 // final response = await PadApiService.instance.getHomeworkList( // page: event.page, // status: event.filterStatus?.name, // ); // 模拟数据处理逻辑 if (event.refresh) { _cachedHomeworks.clear(); } // 统计各状态作业数量 final statusCounts = {}; for (final hw in _cachedHomeworks) { statusCounts[hw.status] = (statusCounts[hw.status] ?? 0) + 1; } // 根据筛选条件过滤 List filtered = _cachedHomeworks; if (event.filterStatus != null) { filtered = _cachedHomeworks .where((hw) => hw.status == event.filterStatus) .toList(); } _emit(HomeworkListLoadedState( homeworks: filtered, hasMore: false, currentPage: event.page, currentFilter: event.filterStatus, statusCounts: statusCounts, )); } catch (e) { _emit(HomeworkErrorState( message: '加载作业列表失败: $e', actionType: 'load_list', )); } } /// 处理下载作业详情(支持离线作答) Future _handleDownload(DownloadHomeworkEvent event) async { try { _emit(HomeworkLoadingState(message: '正在下载作业内容...')); // 调用API下载作业详情 // final response = await PadApiService.instance.downloadHomework( // event.homeworkId, // ); // 将作业内容缓存到本地SQLite(支持离线作答) // await LocalRepository.instance.cacheHomework(...) // _emit(HomeworkDetailLoadedState(...)); } catch (e) { _emit(HomeworkErrorState( message: '下载作业失败: $e', actionType: 'download', )); } } /// 处理保存作答进度(本地暂存,支持断点续答) Future _handleSaveAnswer(SaveAnswerProgressEvent event) async { try { // 更新内存缓存 _answerCache.putIfAbsent(event.homeworkId, () => {}); _answerCache[event.homeworkId]![event.questionId] = { 'text_answer': event.textAnswer, 'stroke_data': event.strokeData, 'saved_at': DateTime.now().toIso8601String(), }; // 持久化到本地数据库 // await LocalRepository.instance.saveAnswerProgress(...) // 计算已作答题目数 final answeredCount = _answerCache[event.homeworkId]?.length ?? 0; _emit(AnswerSavedState( homeworkId: event.homeworkId, questionId: event.questionId, answeredCount: answeredCount, totalCount: 0, // 从缓存的作业详情中获取 )); } catch (e) { _emit(HomeworkErrorState( message: '保存作答进度失败: $e', actionType: 'save_answer', )); } } /// 处理提交作业 Future _handleSubmit(SubmitHomeworkEvent event) async { try { _emit(HomeworkLoadingState(message: '正在提交作业...')); // 收集所有作答数据 final answers = _answerCache[event.homeworkId] ?? {}; // 构建提交数据(含笔迹页面数据) final strokePages = answers.entries.map((entry) { return { 'question_id': entry.key, 'answer': entry.value, }; }).toList(); // 调用API提交 // final response = await PadApiService.instance.submitHomework( // homeworkId: event.homeworkId, // strokePages: strokePages, // ); // 提交成功后清除本地缓存 _answerCache.remove(event.homeworkId); _emit(HomeworkSubmittedState( homeworkId: event.homeworkId, submittedAt: DateTime.now(), )); } catch (e) { _emit(HomeworkErrorState( message: '提交作业失败: $e', actionType: 'submit', )); } } /// 处理查看批改结果 Future _handleViewGrade(ViewGradeResultEvent event) async { try { _emit(HomeworkLoadingState(message: '正在加载批改结果...')); // 调用API获取批改结果 // final response = await PadApiService.instance.getHomeworkResult( // event.homeworkId, // ); // _emit(GradeResultState(...)); } catch (e) { _emit(HomeworkErrorState( message: '加载批改结果失败: $e', actionType: 'view_grade', )); } } /// 释放资源 void dispose() { _stateController.close(); _cachedHomeworks.clear(); _answerCache.clear(); } } ``` ### `eye_care/` #### `eye_care/eye_care_manager.dart` ```dart /// 自然写互动课堂平板端应用软件 V1.0 /// 护眼管理器 - 色温调节、使用时长监控、距离检测 /// /// 功能说明: /// 1. 色温调节(暖色滤镜,减少蓝光对眼睛的刺激) /// 2. 使用时长监控(按应用/科目统计,超时提醒休息) /// 3. 距离检测(前置摄像头检测用眼距离,过近时提醒) /// 4. 定时提醒(每30分钟提醒休息,远眺放松) /// 5. 家长远程管控(接收家长设置的时段/时长限制) /// 6. 护眼数据统计(每日使用时长报告) import 'dart:async'; /// 护眼模式配置 class EyeCareConfig { /// 是否启用护眼模式 bool enabled; /// 色温强度(0.0=关闭, 1.0=最暖) double colorTemperature; /// 连续使用提醒间隔(分钟) int reminderIntervalMinutes; /// 每日使用时长上限(分钟,0=不限制) int dailyLimitMinutes; /// 允许使用的时段(开始小时, 结束小时) int allowedStartHour; int allowedEndHour; /// 是否启用距离检测 bool distanceDetectionEnabled; /// 安全用眼距离(厘米) int safeDistanceCm; /// 夜间模式自动开启时间(小时) int nightModeStartHour; int nightModeEndHour; EyeCareConfig({ this.enabled = true, this.colorTemperature = 0.3, this.reminderIntervalMinutes = 30, this.dailyLimitMinutes = 120, this.allowedStartHour = 7, this.allowedEndHour = 21, this.distanceDetectionEnabled = false, this.safeDistanceCm = 30, this.nightModeStartHour = 20, this.nightModeEndHour = 7, }); Map toJson() => { 'enabled': enabled, 'color_temperature': colorTemperature, 'reminder_interval': reminderIntervalMinutes, 'daily_limit': dailyLimitMinutes, 'allowed_start': allowedStartHour, 'allowed_end': allowedEndHour, 'distance_enabled': distanceDetectionEnabled, 'safe_distance': safeDistanceCm, 'night_start': nightModeStartHour, 'night_end': nightModeEndHour, }; factory EyeCareConfig.fromJson(Map json) { return EyeCareConfig( enabled: json['enabled'] ?? true, colorTemperature: (json['color_temperature'] ?? 0.3).toDouble(), reminderIntervalMinutes: json['reminder_interval'] ?? 30, dailyLimitMinutes: json['daily_limit'] ?? 120, allowedStartHour: json['allowed_start'] ?? 7, allowedEndHour: json['allowed_end'] ?? 21, distanceDetectionEnabled: json['distance_enabled'] ?? false, safeDistanceCm: json['safe_distance'] ?? 30, nightModeStartHour: json['night_start'] ?? 20, nightModeEndHour: json['night_end'] ?? 7, ); } } /// 使用时长记录 class UsageRecord { final String date; // 日期 (yyyy-MM-dd) final String category; // 分类 (homework/practice/reading) final int durationMinutes; // 使用时长(分钟) final int sessionCount; // 使用次数 UsageRecord({ required this.date, required this.category, required this.durationMinutes, required this.sessionCount, }); Map toJson() => { 'date': date, 'category': category, 'duration': durationMinutes, 'sessions': sessionCount, }; } /// 护眼事件类型 enum EyeCareEvent { restReminder, // 休息提醒 dailyLimitReached, // 每日时长上限 outsideAllowedTime, // 超出允许使用时段 tooCloseWarning, // 用眼距离过近 nightModeOn, // 夜间模式开启 nightModeOff, // 夜间模式关闭 } /// 护眼事件回调 typedef EyeCareEventCallback = void Function(EyeCareEvent event, Map data); /// 护眼管理器 class EyeCareManager { /// 护眼配置 EyeCareConfig _config = EyeCareConfig(); /// 事件回调列表 final List _callbacks = []; /// 当前会话开始时间 DateTime? _sessionStartTime; /// 今日累计使用时长(秒) int _todayUsageSeconds = 0; /// 当前连续使用时长(秒) int _continuousUsageSeconds = 0; /// 今日使用记录 final Map _categoryUsage = {}; /// 计时器(每秒更新使用时长) Timer? _usageTimer; /// 距离检测计时器 Timer? _distanceTimer; /// 夜间模式检查计时器 Timer? _nightModeTimer; /// 当前是否在夜间模式 bool _isNightMode = false; /// 当前色温值(供外部读取) double get currentColorTemperature { if (!_config.enabled) return 0.0; if (_isNightMode) return _config.colorTemperature * 1.5; // 夜间加强 return _config.colorTemperature; } /// 今日总使用时长(分钟) int get todayUsageMinutes => _todayUsageSeconds ~/ 60; /// 剩余可用时长(分钟,-1表示不限制) int get remainingMinutes { if (_config.dailyLimitMinutes <= 0) return -1; return _config.dailyLimitMinutes - todayUsageMinutes; } /// 注册事件回调 void addCallback(EyeCareEventCallback callback) { _callbacks.add(callback); } /// 移除事件回调 void removeCallback(EyeCareEventCallback callback) { _callbacks.remove(callback); } /// 更新配置(家长远程设置后调用) void updateConfig(EyeCareConfig newConfig) { _config = newConfig; if (_config.enabled) { _startMonitoring(); } else { _stopMonitoring(); } } /// 开始使用(进入学习功能时调用) void startSession({String category = 'default'}) { _sessionStartTime = DateTime.now(); _continuousUsageSeconds = 0; // 检查是否在允许时段内 final now = DateTime.now(); if (_config.enabled && !_isWithinAllowedTime(now)) { _notifyEvent(EyeCareEvent.outsideAllowedTime, { 'allowed_start': _config.allowedStartHour, 'allowed_end': _config.allowedEndHour, }); } // 启动使用时长计时器 _usageTimer?.cancel(); _usageTimer = Timer.periodic(const Duration(seconds: 1), (_) { _todayUsageSeconds++; _continuousUsageSeconds++; // 检查连续使用时长提醒 if (_config.reminderIntervalMinutes > 0 && _continuousUsageSeconds > 0 && _continuousUsageSeconds % (_config.reminderIntervalMinutes * 60) == 0) { _notifyEvent(EyeCareEvent.restReminder, { 'continuous_minutes': _continuousUsageSeconds ~/ 60, 'total_minutes': todayUsageMinutes, }); } // 检查每日使用上限 if (_config.dailyLimitMinutes > 0 && todayUsageMinutes >= _config.dailyLimitMinutes) { _notifyEvent(EyeCareEvent.dailyLimitReached, { 'limit_minutes': _config.dailyLimitMinutes, 'used_minutes': todayUsageMinutes, }); } }); // 启动距离检测 if (_config.distanceDetectionEnabled) { _startDistanceDetection(); } // 启动夜间模式检查 _startNightModeCheck(); } /// 结束使用(退出学习功能时调用) void endSession({String category = 'default'}) { _usageTimer?.cancel(); _usageTimer = null; if (_sessionStartTime != null) { final duration = DateTime.now().difference(_sessionStartTime!).inMinutes; _categoryUsage[category] = (_categoryUsage[category] ?? 0) + duration; } _sessionStartTime = null; _continuousUsageSeconds = 0; _distanceTimer?.cancel(); _distanceTimer = null; } /// 用户休息后重置连续使用计时 void acknowledgeRest() { _continuousUsageSeconds = 0; } /// 检查当前时间是否在允许使用时段内 bool _isWithinAllowedTime(DateTime time) { final hour = time.hour; if (_config.allowedStartHour <= _config.allowedEndHour) { return hour >= _config.allowedStartHour && hour < _config.allowedEndHour; } else { // 跨午夜的情况 return hour >= _config.allowedStartHour || hour < _config.allowedEndHour; } } /// 启动监控 void _startMonitoring() { _startNightModeCheck(); } /// 停止监控 void _stopMonitoring() { _usageTimer?.cancel(); _distanceTimer?.cancel(); _nightModeTimer?.cancel(); } /// 启动距离检测(通过前置摄像头估算用眼距离) void _startDistanceDetection() { _distanceTimer?.cancel(); _distanceTimer = Timer.periodic(const Duration(seconds: 10), (_) { // 调用前置摄像头进行人脸检测 // 通过人脸框大小估算距离(人脸越大=距离越近) _checkEyeDistance(); }); } /// 检查用眼距离(基于前置摄像头人脸检测) void _checkEyeDistance() { // 实际实现: // 1. 使用CameraController获取前置摄像头预览帧 // 2. 使用MLKit/TFLite进行人脸检测 // 3. 根据人脸框宽度估算距离: distance = (focal_length * real_face_width) / face_width_in_pixels // 4. 本地处理,不上传图像数据(隐私保护) // 模拟距离检测结果 final estimatedDistanceCm = 35; // 实际从摄像头计算 if (estimatedDistanceCm < _config.safeDistanceCm) { _notifyEvent(EyeCareEvent.tooCloseWarning, { 'current_distance': estimatedDistanceCm, 'safe_distance': _config.safeDistanceCm, }); } } /// 启动夜间模式检查 void _startNightModeCheck() { _nightModeTimer?.cancel(); _nightModeTimer = Timer.periodic(const Duration(minutes: 1), (_) { final hour = DateTime.now().hour; final shouldBeNightMode = _isNightTimeHour(hour); if (shouldBeNightMode && !_isNightMode) { _isNightMode = true; _notifyEvent(EyeCareEvent.nightModeOn, {}); } else if (!shouldBeNightMode && _isNightMode) { _isNightMode = false; _notifyEvent(EyeCareEvent.nightModeOff, {}); } }); // 立即检查一次 final hour = DateTime.now().hour; _isNightMode = _isNightTimeHour(hour); } /// 判断是否为夜间时段 bool _isNightTimeHour(int hour) { if (_config.nightModeStartHour <= _config.nightModeEndHour) { return hour >= _config.nightModeStartHour && hour < _config.nightModeEndHour; } else { return hour >= _config.nightModeStartHour || hour < _config.nightModeEndHour; } } /// 获取今日使用统计 List getTodayUsageRecords() { final today = DateTime.now().toString().substring(0, 10); return _categoryUsage.entries.map((e) => UsageRecord( date: today, category: e.key, durationMinutes: e.value, sessionCount: 1, )).toList(); } /// 通知事件到所有回调 void _notifyEvent(EyeCareEvent event, Map data) { for (final callback in _callbacks) { try { callback(event, data); } catch (e) { // 忽略回调异常 } } } /// 释放资源 void dispose() { _usageTimer?.cancel(); _distanceTimer?.cancel(); _nightModeTimer?.cancel(); _callbacks.clear(); } } ``` ### `renderer/` #### `renderer/stroke_painter.dart` ```dart /// 自然写互动课堂平板端应用软件 V1.0 /// Skia笔迹渲染器 - CustomPainter实现触屏直写与点阵笔笔迹渲染 /// /// 功能说明: /// 1. CustomPainter高性能笔迹绘制(Skia引擎) /// 2. 触屏直写支持(手指/触控笔Pointer事件处理) /// 3. 点阵笔BLE数据渲染(从BLE服务接收坐标数据) /// 4. 压力感应笔锋效果(触控笔ActiveStylus压力数据) /// 5. 贝塞尔曲线平滑算法 /// 6. 字帖练习辅助线(田字格/米字格/四线三格) /// 7. 撤销/重做操作栈 /// 8. 笔迹导出(SVG/PNG格式) import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; /* ========== 数据模型 ========== */ /// 笔迹点 class PadStrokePoint { final double x; final double y; final double pressure; final int timestamp; const PadStrokePoint({ required this.x, required this.y, this.pressure = 0.5, required this.timestamp, }); Map toJson() => { 'x': x, 'y': y, 'pressure': pressure, 'timestamp': timestamp, }; } /// 笔画 class PadStroke { final List points; final Color color; final double baseWidth; final String source; // 'touch'=触屏, 'ble'=点阵笔 PadStroke({ List? points, this.color = Colors.black, this.baseWidth = 2.5, this.source = 'touch', }) : points = points ?? []; void addPoint(PadStrokePoint point) => points.add(point); } /// 辅助线类型 enum GuideLineType { none, // 无辅助线 tianZiGe, // 田字格 miZiGe, // 米字格 siXianSanGe, // 四线三格(英文/拼音) } /// 撤销/重做操作 sealed class CanvasAction {} class AddStrokeAction extends CanvasAction { final PadStroke stroke; AddStrokeAction(this.stroke); } class ClearAction extends CanvasAction { final List clearedStrokes; ClearAction(this.clearedStrokes); } /* ========== 笔迹画布Widget ========== */ /// 平板端笔迹渲染画布 /// 支持触屏直写和BLE点阵笔两种输入方式 class PadStrokeCanvas extends StatefulWidget { /// 初始笔画数据(如加载已有作业笔迹) final List? initialStrokes; /// 辅助线类型 final GuideLineType guideLineType; /// 是否只读模式(查看已提交的作业) final bool readOnly; /// 笔迹颜色 final Color strokeColor; /// 笔画宽度 final double strokeWidth; /// 笔迹变化回调 final Function(List)? onStrokesChanged; const PadStrokeCanvas({ super.key, this.initialStrokes, this.guideLineType = GuideLineType.none, this.readOnly = false, this.strokeColor = Colors.black, this.strokeWidth = 2.5, this.onStrokesChanged, }); @override State createState() => _PadStrokeCanvasState(); } class _PadStrokeCanvasState extends State { /// 已完成的笔画列表 final List _strokes = []; /// 当前正在绘制的笔画 PadStroke? _currentStroke; /// 撤销栈 final List _undoStack = []; /// 重做栈 final List _redoStack = []; /// 最大撤销步数 static const int maxUndoSteps = 50; @override void initState() { super.initState(); if (widget.initialStrokes != null) { _strokes.addAll(widget.initialStrokes!); } } /// 撤销最后一个操作 void undo() { if (_undoStack.isEmpty) return; final action = _undoStack.removeLast(); if (action is AddStrokeAction) { _strokes.remove(action.stroke); _redoStack.add(action); } else if (action is ClearAction) { _strokes.addAll(action.clearedStrokes); _redoStack.add(action); } setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 重做上一个撤销的操作 void redo() { if (_redoStack.isEmpty) return; final action = _redoStack.removeLast(); if (action is AddStrokeAction) { _strokes.add(action.stroke); _undoStack.add(action); } else if (action is ClearAction) { _strokes.clear(); _undoStack.add(action); } setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 清除所有笔迹 void clearAll() { if (_strokes.isEmpty) return; final cleared = List.from(_strokes); _undoStack.add(ClearAction(cleared)); _strokes.clear(); _redoStack.clear(); setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 从BLE点阵笔添加笔画(外部调用) void addBleStroke(PadStroke stroke) { _strokes.add(stroke); _undoStack.add(AddStrokeAction(stroke)); _redoStack.clear(); setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 获取所有笔画数据(用于提交) List getStrokes() => List.unmodifiable(_strokes); @override Widget build(BuildContext context) { return Listener( // 使用Listener而非GestureDetector,以获取精确的Pointer事件 onPointerDown: widget.readOnly ? null : _onPointerDown, onPointerMove: widget.readOnly ? null : _onPointerMove, onPointerUp: widget.readOnly ? null : _onPointerUp, child: ClipRect( child: CustomPaint( painter: _PadStrokePainter( strokes: _strokes, currentStroke: _currentStroke, guideLineType: widget.guideLineType, ), size: Size.infinite, ), ), ); } /// 触屏落笔 void _onPointerDown(PointerDownEvent event) { final pressure = event.pressure > 0 ? event.pressure : 0.5; _currentStroke = PadStroke( color: widget.strokeColor, baseWidth: widget.strokeWidth, source: event.kind == PointerDeviceKind.stylus ? 'stylus' : 'touch', ); _currentStroke!.addPoint(PadStrokePoint( x: event.localPosition.dx, y: event.localPosition.dy, pressure: pressure, timestamp: DateTime.now().millisecondsSinceEpoch, )); setState(() {}); } /// 触屏移动 void _onPointerMove(PointerMoveEvent event) { if (_currentStroke == null) return; final pressure = event.pressure > 0 ? event.pressure : 0.5; _currentStroke!.addPoint(PadStrokePoint( x: event.localPosition.dx, y: event.localPosition.dy, pressure: pressure, timestamp: DateTime.now().millisecondsSinceEpoch, )); setState(() {}); } /// 触屏抬笔 void _onPointerUp(PointerUpEvent event) { if (_currentStroke == null) return; if (_currentStroke!.points.length >= 2) { _strokes.add(_currentStroke!); _undoStack.add(AddStrokeAction(_currentStroke!)); _redoStack.clear(); // 限制撤销栈大小 if (_undoStack.length > maxUndoSteps) { _undoStack.removeAt(0); } widget.onStrokesChanged?.call(_strokes); } _currentStroke = null; setState(() {}); } } /* ========== Painter实现 ========== */ /// 笔迹绘制Painter class _PadStrokePainter extends CustomPainter { final List strokes; final PadStroke? currentStroke; final GuideLineType guideLineType; _PadStrokePainter({ required this.strokes, this.currentStroke, this.guideLineType = GuideLineType.none, }); @override void paint(Canvas canvas, Size size) { // 绘制背景 canvas.drawRect( Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = Colors.white, ); // 绘制辅助线 if (guideLineType != GuideLineType.none) { _drawGuideLines(canvas, size); } // 绘制已完成的笔画 for (final stroke in strokes) { _drawStroke(canvas, stroke); } // 绘制当前活跃笔画 if (currentStroke != null) { _drawStroke(canvas, currentStroke!); } } /// 绘制辅助线 void _drawGuideLines(Canvas canvas, Size size) { final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.5; switch (guideLineType) { case GuideLineType.tianZiGe: _drawTianZiGe(canvas, size, paint); break; case GuideLineType.miZiGe: _drawMiZiGe(canvas, size, paint); break; case GuideLineType.siXianSanGe: _drawSiXianSanGe(canvas, size, paint); break; default: break; } } /// 绘制田字格 void _drawTianZiGe(Canvas canvas, Size size, Paint paint) { const cellSize = 80.0; paint.color = Colors.red.withValues(alpha: 0.3); // 外框(实线) for (double x = 0; x <= size.width; x += cellSize) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = 0; y <= size.height; y += cellSize) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } // 中心十字线(虚线效果用半透明) paint.color = Colors.red.withValues(alpha: 0.15); final halfCell = cellSize / 2; for (double x = halfCell; x < size.width; x += cellSize) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = halfCell; y < size.height; y += cellSize) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } } /// 绘制米字格 void _drawMiZiGe(Canvas canvas, Size size, Paint paint) { const cellSize = 80.0; paint.color = Colors.red.withValues(alpha: 0.3); for (double x = 0; x <= size.width; x += cellSize) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = 0; y <= size.height; y += cellSize) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } // 对角线 + 十字线 paint.color = Colors.red.withValues(alpha: 0.15); for (double x = 0; x < size.width; x += cellSize) { for (double y = 0; y < size.height; y += cellSize) { // 对角线 canvas.drawLine(Offset(x, y), Offset(x + cellSize, y + cellSize), paint); canvas.drawLine(Offset(x + cellSize, y), Offset(x, y + cellSize), paint); // 十字线 canvas.drawLine(Offset(x + cellSize / 2, y), Offset(x + cellSize / 2, y + cellSize), paint); canvas.drawLine(Offset(x, y + cellSize / 2), Offset(x + cellSize, y + cellSize / 2), paint); } } } /// 绘制四线三格(拼音/英文) void _drawSiXianSanGe(Canvas canvas, Size size, Paint paint) { const lineSpacing = 15.0; const groupHeight = lineSpacing * 3; const groupGap = 20.0; paint.color = Colors.green.withValues(alpha: 0.3); double y = 20; while (y < size.height - groupHeight) { // 四条横线 for (int i = 0; i < 4; i++) { final lineY = y + i * lineSpacing; // 第二条线(中线)用虚线表示 if (i == 1 || i == 2) { paint.color = Colors.green.withValues(alpha: 0.15); } else { paint.color = Colors.green.withValues(alpha: 0.3); } canvas.drawLine(Offset(0, lineY), Offset(size.width, lineY), paint); } y += groupHeight + groupGap; } } /// 绘制单个笔画(贝塞尔平滑 + 压力笔锋) void _drawStroke(Canvas canvas, PadStroke stroke) { if (stroke.points.length < 2) return; final paint = Paint() ..color = stroke.color ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke ..isAntiAlias = true; for (int i = 1; i < stroke.points.length; i++) { final prev = stroke.points[i - 1]; final curr = stroke.points[i]; // 压力笔锋宽度计算 final avgPressure = (prev.pressure + curr.pressure) / 2.0; var width = stroke.baseWidth * (0.3 + avgPressure * 1.7); // 落笔过渡 if (i < 5) width *= (i / 5.0); // 抬笔过渡 final remaining = stroke.points.length - i; if (remaining < 5) width *= (remaining / 5.0); width = max(width, 0.5); paint.strokeWidth = width; if (i >= 2) { // 贝塞尔曲线平滑 final pp = stroke.points[i - 2]; final cp1x = prev.x + (curr.x - pp.x) * 0.2; final cp1y = prev.y + (curr.y - pp.y) * 0.2; final cp2x = curr.x - (curr.x - prev.x) * 0.2; final cp2y = curr.y - (curr.y - prev.y) * 0.2; final path = Path() ..moveTo(prev.x, prev.y) ..cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x, curr.y); canvas.drawPath(path, paint); } else { canvas.drawLine(Offset(prev.x, prev.y), Offset(curr.x, curr.y), paint); } } } @override bool shouldRepaint(covariant _PadStrokePainter oldDelegate) { return oldDelegate.strokes.length != strokes.length || oldDelegate.currentStroke != currentStroke; } } ``` ### `repository/` #### `repository/local_repository.dart` ```dart // 自然写互动课堂平板端应用软件 V1.0 // repository/local_repository.dart - SQLite + Hive本地数据存储 import 'dart:async'; import 'dart:convert'; /// 数据库表名常量 class PadDbTables { static const String homework = 'pad_homework'; static const String homeworkQuestion = 'pad_homework_question'; static const String answerProgress = 'pad_answer_progress'; static const String errorBook = 'pad_error_book'; static const String studyPlan = 'pad_study_plan'; static const String studyTask = 'pad_study_task'; static const String practiceRecord = 'pad_practice_record'; static const String strokeCache = 'pad_stroke_cache'; static const String offlineAction = 'pad_offline_action'; static const String usageRecord = 'pad_usage_record'; } /// 数据库版本 const int padDbVersion = 4; /// 作业缓存模型 class CachedHomework { final String id; final String title; final String subject; final String teacherName; final String status; final String? deadline; final String? content; final int totalQuestions; final int answeredQuestions; final DateTime cachedAt; CachedHomework({ required this.id, required this.title, required this.subject, required this.teacherName, required this.status, this.deadline, this.content, this.totalQuestions = 0, this.answeredQuestions = 0, required this.cachedAt, }); Map toMap() => { 'id': id, 'title': title, 'subject': subject, 'teacher_name': teacherName, 'status': status, 'deadline': deadline, 'content': content, 'total_questions': totalQuestions, 'answered_questions': answeredQuestions, 'cached_at': cachedAt.toIso8601String(), }; factory CachedHomework.fromMap(Map map) { return CachedHomework( id: map['id'], title: map['title'] ?? '', subject: map['subject'] ?? '', teacherName: map['teacher_name'] ?? '', status: map['status'] ?? 'pending', deadline: map['deadline'], content: map['content'], totalQuestions: map['total_questions'] ?? 0, answeredQuestions: map['answered_questions'] ?? 0, cachedAt: DateTime.parse(map['cached_at']), ); } } /// 错题记录模型 class ErrorBookEntry { final String id; final String homeworkId; final String questionId; final String subject; final String? knowledgePoint; final String questionContent; final String? questionImageUrl; final String? studentAnswer; final String? correctAnswer; final String? errorReason; final int reviewCount; final DateTime createdAt; final DateTime? lastReviewAt; ErrorBookEntry({ required this.id, required this.homeworkId, required this.questionId, required this.subject, this.knowledgePoint, required this.questionContent, this.questionImageUrl, this.studentAnswer, this.correctAnswer, this.errorReason, this.reviewCount = 0, required this.createdAt, this.lastReviewAt, }); Map toMap() => { 'id': id, 'homework_id': homeworkId, 'question_id': questionId, 'subject': subject, 'knowledge_point': knowledgePoint, 'question_content': questionContent, 'question_image_url': questionImageUrl, 'student_answer': studentAnswer, 'correct_answer': correctAnswer, 'error_reason': errorReason, 'review_count': reviewCount, 'created_at': createdAt.toIso8601String(), 'last_review_at': lastReviewAt?.toIso8601String(), }; factory ErrorBookEntry.fromMap(Map map) { return ErrorBookEntry( id: map['id'], homeworkId: map['homework_id'] ?? '', questionId: map['question_id'] ?? '', subject: map['subject'] ?? '', knowledgePoint: map['knowledge_point'], questionContent: map['question_content'] ?? '', questionImageUrl: map['question_image_url'], studentAnswer: map['student_answer'], correctAnswer: map['correct_answer'], errorReason: map['error_reason'], reviewCount: map['review_count'] ?? 0, createdAt: DateTime.parse(map['created_at']), lastReviewAt: map['last_review_at'] != null ? DateTime.parse(map['last_review_at']) : null, ); } } /// 学习计划模型 class StudyPlanEntry { final String id; final String title; final String type; final String? subject; final DateTime startDate; final DateTime endDate; final double progress; final int totalTasks; final int completedTasks; final bool isActive; StudyPlanEntry({ required this.id, required this.title, required this.type, this.subject, required this.startDate, required this.endDate, this.progress = 0.0, this.totalTasks = 0, this.completedTasks = 0, this.isActive = true, }); Map toMap() => { 'id': id, 'title': title, 'type': type, 'subject': subject, 'start_date': startDate.toIso8601String(), 'end_date': endDate.toIso8601String(), 'progress': progress, 'total_tasks': totalTasks, 'completed_tasks': completedTasks, 'is_active': isActive ? 1 : 0, }; } /// 练字练习记录模型 class PracticeRecord { final String id; final String templateId; final String character; final int strokeScore; final int structureScore; final int overallScore; final String? strokeDataJson; final DateTime practiceAt; PracticeRecord({ required this.id, required this.templateId, required this.character, this.strokeScore = 0, this.structureScore = 0, this.overallScore = 0, this.strokeDataJson, required this.practiceAt, }); Map toMap() => { 'id': id, 'template_id': templateId, 'character': character, 'stroke_score': strokeScore, 'structure_score': structureScore, 'overall_score': overallScore, 'stroke_data_json': strokeDataJson, 'practice_at': practiceAt.toIso8601String(), }; } /// 平板端本地数据存储仓库 /// 使用SQLite持久化存储 + Hive内存级KV缓存 /// 支持:作业缓存、错题本、学习计划、练字记录、离线操作队列、使用时长记录 class PadLocalRepository { /// 数据库实例(实际使用sqflite库) // late final Database _db; /// 单例 static PadLocalRepository? _instance; static PadLocalRepository get instance { _instance ??= PadLocalRepository._internal(); return _instance!; } PadLocalRepository._internal(); /// 初始化数据库,创建表结构并执行版本迁移 Future initialize() async { // 实际调用: openDatabase(path, version: padDbVersion, ...) // 以下为建表SQL // V1: 基础表 await _createTablesV1(); // V2: 增加学习计划表 await _createTablesV2(); // V3: 增加使用时长记录表 await _createTablesV3(); // V4: 增加练字记录表和索引优化 await _createTablesV4(); } /// V1建表:作业缓存、作答进度、错题本、离线操作队列 Future _createTablesV1() async { // 作业缓存表 const createHomework = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.homework} ( id TEXT PRIMARY KEY, title TEXT NOT NULL, subject TEXT NOT NULL, teacher_name TEXT, status TEXT DEFAULT 'pending', deadline TEXT, content TEXT, total_questions INTEGER DEFAULT 0, answered_questions INTEGER DEFAULT 0, cached_at TEXT NOT NULL ) '''; // 作业题目缓存表 const createQuestion = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.homeworkQuestion} ( id TEXT PRIMARY KEY, homework_id TEXT NOT NULL, question_index INTEGER, type TEXT DEFAULT 'write', content TEXT, image_url TEXT, options TEXT, correct_answer TEXT, FOREIGN KEY (homework_id) REFERENCES ${PadDbTables.homework}(id) ) '''; // 作答进度暂存表 const createProgress = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.answerProgress} ( id INTEGER PRIMARY KEY AUTOINCREMENT, homework_id TEXT NOT NULL, question_id TEXT NOT NULL, text_answer TEXT, stroke_data TEXT, saved_at TEXT NOT NULL, UNIQUE(homework_id, question_id) ) '''; // 错题本表 const createErrorBook = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.errorBook} ( id TEXT PRIMARY KEY, homework_id TEXT, question_id TEXT, subject TEXT NOT NULL, knowledge_point TEXT, question_content TEXT NOT NULL, question_image_url TEXT, student_answer TEXT, correct_answer TEXT, error_reason TEXT, review_count INTEGER DEFAULT 0, created_at TEXT NOT NULL, last_review_at TEXT ) '''; // 离线操作队列表 const createOffline = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.offlineAction} ( id INTEGER PRIMARY KEY AUTOINCREMENT, action_type TEXT NOT NULL, payload TEXT NOT NULL, retry_count INTEGER DEFAULT 0, created_at TEXT NOT NULL, status TEXT DEFAULT 'pending' ) '''; // 笔迹暂存表 const createStroke = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.strokeCache} ( id INTEGER PRIMARY KEY AUTOINCREMENT, homework_id TEXT, question_id TEXT, page_id TEXT, stroke_json TEXT NOT NULL, pen_mac TEXT, created_at TEXT NOT NULL ) '''; // 实际执行建表SQL // await _db.execute(createHomework); // await _db.execute(createQuestion); // await _db.execute(createProgress); // await _db.execute(createErrorBook); // await _db.execute(createOffline); // await _db.execute(createStroke); } /// V2建表:学习计划与任务 Future _createTablesV2() async { const createPlan = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.studyPlan} ( id TEXT PRIMARY KEY, title TEXT NOT NULL, type TEXT NOT NULL, subject TEXT, start_date TEXT NOT NULL, end_date TEXT NOT NULL, progress REAL DEFAULT 0.0, total_tasks INTEGER DEFAULT 0, completed_tasks INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1 ) '''; const createTask = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.studyTask} ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT, target_date TEXT, is_completed INTEGER DEFAULT 0, completed_at TEXT, FOREIGN KEY (plan_id) REFERENCES ${PadDbTables.studyPlan}(id) ) '''; // await _db.execute(createPlan); // await _db.execute(createTask); } /// V3建表:使用时长记录 Future _createTablesV3() async { const createUsage = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.usageRecord} ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, app_name TEXT DEFAULT 'writech', subject TEXT, duration_seconds INTEGER DEFAULT 0, start_time TEXT NOT NULL, end_time TEXT ) '''; // await _db.execute(createUsage); } /// V4建表:练字记录 + 索引 Future _createTablesV4() async { const createPractice = ''' CREATE TABLE IF NOT EXISTS ${PadDbTables.practiceRecord} ( id TEXT PRIMARY KEY, template_id TEXT NOT NULL, character TEXT NOT NULL, stroke_score INTEGER DEFAULT 0, structure_score INTEGER DEFAULT 0, overall_score INTEGER DEFAULT 0, stroke_data_json TEXT, practice_at TEXT NOT NULL ) '''; // 索引优化 const indexHomeworkStatus = ''' CREATE INDEX IF NOT EXISTS idx_homework_status ON ${PadDbTables.homework}(status) '''; const indexErrorSubject = ''' CREATE INDEX IF NOT EXISTS idx_error_subject ON ${PadDbTables.errorBook}(subject) '''; const indexPracticeChar = ''' CREATE INDEX IF NOT EXISTS idx_practice_char ON ${PadDbTables.practiceRecord}(character) '''; // await _db.execute(createPractice); // await _db.execute(indexHomeworkStatus); // await _db.execute(indexErrorSubject); // await _db.execute(indexPracticeChar); } // ============================================================ // 作业缓存 CRUD // ============================================================ /// 缓存作业到本地(用于离线作答) Future cacheHomework(CachedHomework homework) async { // await _db.insert( // PadDbTables.homework, // homework.toMap(), // conflictAlgorithm: ConflictAlgorithm.replace, // ); } /// 获取本地缓存的作业列表 Future> getCachedHomeworks({ String? status, int limit = 50, }) async { // String where = ''; // List whereArgs = []; // if (status != null) { // where = 'status = ?'; // whereArgs = [status]; // } // final maps = await _db.query( // PadDbTables.homework, // where: where.isNotEmpty ? where : null, // whereArgs: whereArgs.isNotEmpty ? whereArgs : null, // orderBy: 'cached_at DESC', // limit: limit, // ); // return maps.map((m) => CachedHomework.fromMap(m)).toList(); return []; } /// 保存作答进度到本地 Future saveAnswerProgress({ required String homeworkId, required String questionId, String? textAnswer, String? strokeDataJson, }) async { // await _db.insert( // PadDbTables.answerProgress, // { // 'homework_id': homeworkId, // 'question_id': questionId, // 'text_answer': textAnswer, // 'stroke_data': strokeDataJson, // 'saved_at': DateTime.now().toIso8601String(), // }, // conflictAlgorithm: ConflictAlgorithm.replace, // ); } /// 获取某作业的所有作答进度 Future>> getAnswerProgress( String homeworkId, ) async { // final maps = await _db.query( // PadDbTables.answerProgress, // where: 'homework_id = ?', // whereArgs: [homeworkId], // ); // final result = >{}; // for (final m in maps) { // result[m['question_id'] as String] = m; // } // return result; return {}; } // ============================================================ // 错题本 CRUD // ============================================================ /// 添加错题记录 Future addErrorEntry(ErrorBookEntry entry) async { // await _db.insert( // PadDbTables.errorBook, // entry.toMap(), // conflictAlgorithm: ConflictAlgorithm.replace, // ); } /// 获取错题列表(支持按科目/知识点筛选) Future> getErrorEntries({ String? subject, String? knowledgePoint, int limit = 50, int offset = 0, }) async { // final conditions = []; // final args = []; // if (subject != null) { // conditions.add('subject = ?'); // args.add(subject); // } // if (knowledgePoint != null) { // conditions.add('knowledge_point = ?'); // args.add(knowledgePoint); // } // final maps = await _db.query( // PadDbTables.errorBook, // where: conditions.isNotEmpty ? conditions.join(' AND ') : null, // whereArgs: args.isNotEmpty ? args : null, // orderBy: 'created_at DESC', // limit: limit, // offset: offset, // ); // return maps.map((m) => ErrorBookEntry.fromMap(m)).toList(); return []; } /// 更新错题复习次数 Future updateErrorReviewCount(String entryId) async { // await _db.rawUpdate(''' // UPDATE ${PadDbTables.errorBook} // SET review_count = review_count + 1, // last_review_at = ? // WHERE id = ? // ''', [DateTime.now().toIso8601String(), entryId]); } /// 获取错题统计(按科目分组计数) Future> getErrorStatsBySubject() async { // final maps = await _db.rawQuery(''' // SELECT subject, COUNT(*) as count // FROM ${PadDbTables.errorBook} // GROUP BY subject // '''); // return {for (var m in maps) m['subject'] as String: m['count'] as int}; return {}; } // ============================================================ // 学习计划 CRUD // ============================================================ /// 保存学习计划 Future saveStudyPlan(StudyPlanEntry plan) async { // await _db.insert( // PadDbTables.studyPlan, // plan.toMap(), // conflictAlgorithm: ConflictAlgorithm.replace, // ); } /// 获取活跃的学习计划列表 Future>> getActiveStudyPlans() async { // return await _db.query( // PadDbTables.studyPlan, // where: 'is_active = 1', // orderBy: 'start_date ASC', // ); return []; } /// 更新学习计划进度 Future updatePlanProgress( String planId, double progress, int completedTasks, ) async { // await _db.update( // PadDbTables.studyPlan, // {'progress': progress, 'completed_tasks': completedTasks}, // where: 'id = ?', // whereArgs: [planId], // ); } // ============================================================ // 练字记录 // ============================================================ /// 保存练字记录 Future savePracticeRecord(PracticeRecord record) async { // await _db.insert( // PadDbTables.practiceRecord, // record.toMap(), // conflictAlgorithm: ConflictAlgorithm.replace, // ); } /// 获取某字的练习历史(查看进步轨迹) Future>> getPracticeHistory( String character, { int limit = 20, }) async { // return await _db.query( // PadDbTables.practiceRecord, // where: 'character = ?', // whereArgs: [character], // orderBy: 'practice_at DESC', // limit: limit, // ); return []; } // ============================================================ // 离线操作队列 // ============================================================ /// 添加离线操作到队列 Future enqueueOfflineAction( String actionType, Map payload, ) async { // await _db.insert(PadDbTables.offlineAction, { // 'action_type': actionType, // 'payload': jsonEncode(payload), // 'created_at': DateTime.now().toIso8601String(), // 'status': 'pending', // }); } /// 获取待执行的离线操作 Future>> getPendingOfflineActions() async { // return await _db.query( // PadDbTables.offlineAction, // where: 'status = ? AND retry_count < 5', // whereArgs: ['pending'], // orderBy: 'created_at ASC', // ); return []; } /// 标记离线操作完成 Future markOfflineActionDone(int actionId) async { // await _db.update( // PadDbTables.offlineAction, // {'status': 'done'}, // where: 'id = ?', // whereArgs: [actionId], // ); } // ============================================================ // 使用时长记录 // ============================================================ /// 记录使用时长 Future recordUsage({ required String date, required int durationSeconds, required String startTime, String? endTime, String? subject, }) async { // await _db.insert(PadDbTables.usageRecord, { // 'date': date, // 'duration_seconds': durationSeconds, // 'start_time': startTime, // 'end_time': endTime, // 'subject': subject, // }); } /// 获取某日使用总时长(秒) Future getDailyUsage(String date) async { // final result = await _db.rawQuery(''' // SELECT COALESCE(SUM(duration_seconds), 0) as total // FROM ${PadDbTables.usageRecord} // WHERE date = ? // ''', [date]); // return result.first['total'] as int? ?? 0; return 0; } // ============================================================ // 数据库维护 // ============================================================ /// 清理过期缓存数据(30天前的作业缓存、90天前的笔迹暂存) Future cleanExpiredData() async { final thirtyDaysAgo = DateTime.now() .subtract(const Duration(days: 30)) .toIso8601String(); final ninetyDaysAgo = DateTime.now() .subtract(const Duration(days: 90)) .toIso8601String(); // await _db.delete( // PadDbTables.homework, // where: 'cached_at < ? AND status IN (?, ?)', // whereArgs: [thirtyDaysAgo, 'graded', 'expired'], // ); // await _db.delete( // PadDbTables.strokeCache, // where: 'created_at < ?', // whereArgs: [ninetyDaysAgo], // ); // await _db.delete( // PadDbTables.offlineAction, // where: 'status = ? AND created_at < ?', // whereArgs: ['done', thirtyDaysAgo], // ); } /// 获取本地数据库存储大小(字节) Future getDatabaseSize() async { // final dbPath = await getDatabasesPath(); // final file = File('$dbPath/writech_pad.db'); // return file.existsSync() ? file.lengthSync() : 0; return 0; } /// 关闭数据库 Future close() async { // await _db.close(); } } ``` ### `service/` #### `service/api_service.dart` ```dart // 自然写互动课堂平板端应用软件 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 { 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 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? 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 toJson() => { 'id': id, 'method': method, 'path': path, 'data': data, 'created_at': createdAt.toIso8601String(), 'retry_count': retryCount, }; /// 从JSON反序列化 factory OfflineRequest.fromJson(Map 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? _refreshCompleter; /// 离线请求队列 final List _offlineQueue = []; /// 网络状态标志 bool _isOnline = true; /// API事件流控制器(登录状态变化等) final StreamController _eventController = StreamController.broadcast(); /// API事件流 Stream 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 _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 _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 _refreshAccessToken() async { // 如果已经在刷新中,等待结果 if (_refreshCompleter != null) { return _refreshCompleter!.future; } _refreshCompleter = Completer(); 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 flushOfflineQueue() async { if (_offlineQueue.isEmpty) return; _isOnline = true; final pendingRequests = List.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>> 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>.fromJson( response.data, (data) => data as Map, ); // 保存登录令牌 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>> 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>.fromJson( response.data, (data) => data as Map, ); 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>> 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>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取作业列表失败'); } } /// 下载作业详情(含题目内容,支持离线作答) Future>> downloadHomework( String homeworkId, ) async { try { final response = await _dio.get('/homework/detail/$homeworkId'); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '下载作业失败'); } } /// 提交作业(含笔迹数据) Future>> submitHomework({ required String homeworkId, required List> 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>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { // 离线时暂存提交请求 if (!_isOnline) { _enqueueOfflineRequest(e.requestOptions); } return ApiResponse(code: -1, message: e.message ?? '提交作业失败'); } } /// 获取作业批改结果 Future>> getHomeworkResult( String homeworkId, ) async { try { final response = await _dio.get('/homework/result/$homeworkId'); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取批改结果失败'); } } // ============================================================ // 字帖练习接口 // ============================================================ /// 获取字帖模板列表(按年级/学科分类) Future>> 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>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取字帖失败'); } } /// 上传练字笔迹评分 Future>> submitPracticeStroke({ required String templateId, required String character, required List> strokes, }) async { try { final response = await _dio.post('/copybook/evaluate', data: { 'template_id': templateId, 'character': character, 'strokes': strokes, }); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '提交练字评分失败'); } } // ============================================================ // 错题本接口 // ============================================================ /// 获取错题列表(按知识点/科目分类) Future>> 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>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取错题本失败'); } } // ============================================================ // 学情与学习计划接口 // ============================================================ /// 获取学生个人学情概览 Future>> getStudentProfile() async { try { final response = await _dio.get('/profile/student/overview'); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取学情失败'); } } /// 获取学习计划列表 Future>> getStudyPlans() async { try { final response = await _dio.get('/study-plan/list'); return ApiResponse>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取学习计划失败'); } } /// 更新学习计划进度 Future> 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.fromJson(response.data, null); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '更新进度失败'); } } /// 登出,清除本地令牌 Future 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(); } } ``` #### `service/ble_service.dart` ```dart // 自然写互动课堂平板端应用软件 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 points; /// 所在页面ID(点阵码识别) final String? pageId; PenStrokeEvent({ required this.penMac, required this.points, this.pageId, }); } /// BLE蓝牙点阵笔连接服务 /// 负责扫描、连接、数据接收、电量监控、自动重连等功能 /// 平板端支持同时连接1支笔(学生个人使用场景) class PadBleService { /// 已发现的设备列表 final List _discoveredDevices = []; /// 当前已连接的笔 PadPenDevice? _connectedPen; /// 笔迹数据缓冲区(累积到阈值后批量回调) final List _strokeBuffer = []; /// 扫描结果流 final StreamController> _scanController = StreamController>.broadcast(); /// 笔迹数据事件流 final StreamController _strokeController = StreamController.broadcast(); /// 连接状态变化流 final StreamController _connectionController = StreamController.broadcast(); /// 电量变化流 final StreamController _batteryController = StreamController.broadcast(); /// 自动重连计数器 int _reconnectAttempts = 0; /// 重连定时器 Timer? _reconnectTimer; /// 电量读取定时器 Timer? _batteryTimer; /// 是否正在扫描 bool _isScanning = false; /// 公开的流 Stream> get scanStream => _scanController.stream; Stream get strokeStream => _strokeController.stream; Stream get connectionStream => _connectionController.stream; Stream get batteryStream => _batteryController.stream; /// 获取当前连接的笔 PadPenDevice? get connectedPen => _connectedPen; /// 开始扫描附近的点阵笔设备 /// 按服务UUID过滤,仅发现自然写点阵笔 Future 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 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 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 _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 sendCommand(int command, [Uint8List? payload]) async { if (_connectedPen == null) return; // 构建指令包:[CMD, LEN, PAYLOAD..., CRC_H, CRC_L] final List 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 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(); } } ```