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

3508 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自然写互动课堂平板端应用软件 V1.0
## 软件著作权鉴别材料 — 源程序
> **权利人**:深圳自然写科技有限公司
> **版本号**V1.0
---
## 源程序目录结构
```
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<void> _initServices() async {
debugPrint('[App] 服务初始化开始');
// 初始化数据库、网络、BLE、护眼模块
debugPrint('[App] 服务初始化完成');
}
/// 平板端应用根Widget
class WritechPadApp extends StatefulWidget {
const WritechPadApp({super.key});
@override
State<WritechPadApp> createState() => _WritechPadAppState();
}
class _WritechPadAppState extends State<WritechPadApp>
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<double> _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<String, dynamic> 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<String>? options;
final String? correctAnswer;
final String? studentAnswer;
final List<Map<String, dynamic>>? 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<String, dynamic> 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<String>.from(json['options'])
: null,
correctAnswer: json['correct_answer'],
studentAnswer: json['student_answer'],
studentStrokes: json['student_strokes'] != null
? List<Map<String, dynamic>>.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<Map<String, dynamic>>? 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<HomeworkItem> homeworks;
final bool hasMore;
final int currentPage;
final HomeworkStatus? currentFilter;
/// 各状态的作业计数统计
final Map<HomeworkStatus, int> 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<HomeworkQuestion> 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<HomeworkQuestion> 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<HomeworkState> _stateController =
StreamController<HomeworkState>.broadcast();
/// 本地缓存的作业列表
List<HomeworkItem> _cachedHomeworks = [];
/// 本地缓存的作答进度 {homeworkId: {questionId: answerData}}
final Map<String, Map<String, dynamic>> _answerCache = {};
/// 获取当前状态
HomeworkState get state => _state;
/// 状态流
Stream<HomeworkState> 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<void> _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 = <HomeworkStatus, int>{};
for (final hw in _cachedHomeworks) {
statusCounts[hw.status] = (statusCounts[hw.status] ?? 0) + 1;
}
// 根据筛选条件过滤
List<HomeworkItem> 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<void> _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<void> _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<void> _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<void> _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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
'date': date, 'category': category,
'duration': durationMinutes, 'sessions': sessionCount,
};
}
/// 护眼事件类型
enum EyeCareEvent {
restReminder, // 休息提醒
dailyLimitReached, // 每日时长上限
outsideAllowedTime, // 超出允许使用时段
tooCloseWarning, // 用眼距离过近
nightModeOn, // 夜间模式开启
nightModeOff, // 夜间模式关闭
}
/// 护眼事件回调
typedef EyeCareEventCallback = void Function(EyeCareEvent event, Map<String, dynamic> data);
/// 护眼管理器
class EyeCareManager {
/// 护眼配置
EyeCareConfig _config = EyeCareConfig();
/// 事件回调列表
final List<EyeCareEventCallback> _callbacks = [];
/// 当前会话开始时间
DateTime? _sessionStartTime;
/// 今日累计使用时长(秒)
int _todayUsageSeconds = 0;
/// 当前连续使用时长(秒)
int _continuousUsageSeconds = 0;
/// 今日使用记录
final Map<String, int> _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<UsageRecord> 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<String, dynamic> 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<String, dynamic> toJson() => {
'x': x, 'y': y, 'pressure': pressure, 'timestamp': timestamp,
};
}
/// 笔画
class PadStroke {
final List<PadStrokePoint> points;
final Color color;
final double baseWidth;
final String source; // 'touch'=触屏, 'ble'=点阵笔
PadStroke({
List<PadStrokePoint>? 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<PadStroke> clearedStrokes;
ClearAction(this.clearedStrokes);
}
/* ========== 笔迹画布Widget ========== */
/// 平板端笔迹渲染画布
/// 支持触屏直写和BLE点阵笔两种输入方式
class PadStrokeCanvas extends StatefulWidget {
/// 初始笔画数据(如加载已有作业笔迹)
final List<PadStroke>? initialStrokes;
/// 辅助线类型
final GuideLineType guideLineType;
/// 是否只读模式(查看已提交的作业)
final bool readOnly;
/// 笔迹颜色
final Color strokeColor;
/// 笔画宽度
final double strokeWidth;
/// 笔迹变化回调
final Function(List<PadStroke>)? 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<PadStrokeCanvas> createState() => _PadStrokeCanvasState();
}
class _PadStrokeCanvasState extends State<PadStrokeCanvas> {
/// 已完成的笔画列表
final List<PadStroke> _strokes = [];
/// 当前正在绘制的笔画
PadStroke? _currentStroke;
/// 撤销栈
final List<CanvasAction> _undoStack = [];
/// 重做栈
final List<CanvasAction> _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<PadStroke>.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<PadStroke> 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<PadStroke> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<void> initialize() async {
// 实际调用: openDatabase(path, version: padDbVersion, ...)
// 以下为建表SQL
// V1: 基础表
await _createTablesV1();
// V2: 增加学习计划表
await _createTablesV2();
// V3: 增加使用时长记录表
await _createTablesV3();
// V4: 增加练字记录表和索引优化
await _createTablesV4();
}
/// V1建表:作业缓存、作答进度、错题本、离线操作队列
Future<void> _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<void> _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<void> _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<void> _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<void> cacheHomework(CachedHomework homework) async {
// await _db.insert(
// PadDbTables.homework,
// homework.toMap(),
// conflictAlgorithm: ConflictAlgorithm.replace,
// );
}
/// 获取本地缓存的作业列表
Future<List<CachedHomework>> getCachedHomeworks({
String? status,
int limit = 50,
}) async {
// String where = '';
// List<dynamic> 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<void> 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<Map<String, Map<String, dynamic>>> getAnswerProgress(
String homeworkId,
) async {
// final maps = await _db.query(
// PadDbTables.answerProgress,
// where: 'homework_id = ?',
// whereArgs: [homeworkId],
// );
// final result = <String, Map<String, dynamic>>{};
// for (final m in maps) {
// result[m['question_id'] as String] = m;
// }
// return result;
return {};
}
// ============================================================
// 错题本 CRUD
// ============================================================
/// 添加错题记录
Future<void> addErrorEntry(ErrorBookEntry entry) async {
// await _db.insert(
// PadDbTables.errorBook,
// entry.toMap(),
// conflictAlgorithm: ConflictAlgorithm.replace,
// );
}
/// 获取错题列表(支持按科目/知识点筛选)
Future<List<ErrorBookEntry>> getErrorEntries({
String? subject,
String? knowledgePoint,
int limit = 50,
int offset = 0,
}) async {
// final conditions = <String>[];
// final args = <dynamic>[];
// 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<void> 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<Map<String, int>> 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<void> saveStudyPlan(StudyPlanEntry plan) async {
// await _db.insert(
// PadDbTables.studyPlan,
// plan.toMap(),
// conflictAlgorithm: ConflictAlgorithm.replace,
// );
}
/// 获取活跃的学习计划列表
Future<List<Map<String, dynamic>>> getActiveStudyPlans() async {
// return await _db.query(
// PadDbTables.studyPlan,
// where: 'is_active = 1',
// orderBy: 'start_date ASC',
// );
return [];
}
/// 更新学习计划进度
Future<void> updatePlanProgress(
String planId,
double progress,
int completedTasks,
) async {
// await _db.update(
// PadDbTables.studyPlan,
// {'progress': progress, 'completed_tasks': completedTasks},
// where: 'id = ?',
// whereArgs: [planId],
// );
}
// ============================================================
// 练字记录
// ============================================================
/// 保存练字记录
Future<void> savePracticeRecord(PracticeRecord record) async {
// await _db.insert(
// PadDbTables.practiceRecord,
// record.toMap(),
// conflictAlgorithm: ConflictAlgorithm.replace,
// );
}
/// 获取某字的练习历史(查看进步轨迹)
Future<List<Map<String, dynamic>>> 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<void> enqueueOfflineAction(
String actionType,
Map<String, dynamic> payload,
) async {
// await _db.insert(PadDbTables.offlineAction, {
// 'action_type': actionType,
// 'payload': jsonEncode(payload),
// 'created_at': DateTime.now().toIso8601String(),
// 'status': 'pending',
// });
}
/// 获取待执行的离线操作
Future<List<Map<String, dynamic>>> getPendingOfflineActions() async {
// return await _db.query(
// PadDbTables.offlineAction,
// where: 'status = ? AND retry_count < 5',
// whereArgs: ['pending'],
// orderBy: 'created_at ASC',
// );
return [];
}
/// 标记离线操作完成
Future<void> markOfflineActionDone(int actionId) async {
// await _db.update(
// PadDbTables.offlineAction,
// {'status': 'done'},
// where: 'id = ?',
// whereArgs: [actionId],
// );
}
// ============================================================
// 使用时长记录
// ============================================================
/// 记录使用时长
Future<void> 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<int> 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<void> 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<int> getDatabaseSize() async {
// final dbPath = await getDatabasesPath();
// final file = File('$dbPath/writech_pad.db');
// return file.existsSync() ? file.lengthSync() : 0;
return 0;
}
/// 关闭数据库
Future<void> 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<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();
}
}
```
#### `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';
/// 笔迹坐标数据特征值UUIDNotify)
static const String strokeCharUuid = '0000ffe1-0000-1000-8000-00805f9b34fb';
/// 笔控制指令特征值UUIDWrite
static const String controlCharUuid = '0000ffe2-0000-1000-8000-00805f9b34fb';
/// 电量信息特征值UUIDRead/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();
}
}
```