96 KiB
96 KiB
自然写互动课堂平板端应用软件 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
/// 自然写互动课堂平板端应用软件 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
// 自然写互动课堂平板端应用软件 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
/// 自然写互动课堂平板端应用软件 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
/// 自然写互动课堂平板端应用软件 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
// 自然写互动课堂平板端应用软件 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
// 自然写互动课堂平板端应用软件 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
// 自然写互动课堂平板端应用软件 V1.0
// service/ble_service.dart - BLE蓝牙点阵笔连接服务
import 'dart:async';
import 'dart:typed_data';
/// BLE服务UUID常量定义
/// 基于自然写点阵笔自定义GATT Service规范
class PadBleConstants {
/// 点阵笔主服务UUID
static const String penServiceUuid = '0000ffe0-0000-1000-8000-00805f9b34fb';
/// 笔迹坐标数据特征值UUID(Notify)
static const String strokeCharUuid = '0000ffe1-0000-1000-8000-00805f9b34fb';
/// 笔控制指令特征值UUID(Write)
static const String controlCharUuid = '0000ffe2-0000-1000-8000-00805f9b34fb';
/// 电量信息特征值UUID(Read/Notify)
static const String batteryCharUuid = '0000ffe3-0000-1000-8000-00805f9b34fb';
/// 设备信息服务UUID
static const String deviceInfoUuid = '0000180a-0000-1000-8000-00805f9b34fb';
/// 扫描超时时间(秒)
static const int scanTimeoutSeconds = 15;
/// 自动重连延迟(秒)
static const int reconnectDelaySeconds = 3;
/// 最大重连次数
static const int maxReconnectAttempts = 10;
/// MTU协商大小
static const int requestedMtu = 247;
/// 笔迹数据缓冲批量回调阈值
static const int strokeBatchSize = 8;
/// 电量读取间隔(秒)
static const int batteryReadInterval = 60;
}
/// 单个笔迹坐标点数据
class PadPenPoint {
/// X坐标(0.01mm精度,16位无符号)
final double x;
/// Y坐标(0.01mm精度,16位无符号)
final double y;
/// 压力值(0-255,8位无符号)
final int pressure;
/// 时间戳(相对值,16位无符号,单位ms)
final int timestamp;
/// 是否为落笔点
final bool isPenDown;
PadPenPoint({
required this.x,
required this.y,
required this.pressure,
required this.timestamp,
this.isPenDown = false,
});
@override
String toString() =>
'PadPenPoint(x: ${x.toStringAsFixed(2)}, y: ${y.toStringAsFixed(2)}, '
'p: $pressure, t: $timestamp)';
}
/// 点阵笔设备信息
class PadPenDevice {
/// 设备蓝牙MAC地址
final String macAddress;
/// 设备名称
final String name;
/// 信号强度(RSSI)
int rssi;
/// 当前连接状态
PenConnectionState connectionState;
/// 电量百分比(0-100)
int batteryLevel;
/// 固件版本号
String? firmwareVersion;
/// 当前所在点阵码页面ID
String? currentPageId;
PadPenDevice({
required this.macAddress,
required this.name,
this.rssi = -100,
this.connectionState = PenConnectionState.disconnected,
this.batteryLevel = -1,
this.firmwareVersion,
this.currentPageId,
});
}
/// 笔连接状态枚举
enum PenConnectionState {
/// 未连接
disconnected,
/// 正在扫描
scanning,
/// 正在连接
connecting,
/// 已连接
connected,
/// 正在断开
disconnecting,
/// 自动重连中
reconnecting,
}
/// 笔迹数据事件(批量坐标点回调)
class PenStrokeEvent {
/// 来源笔的MAC地址
final String penMac;
/// 坐标点列表
final List<PadPenPoint> points;
/// 所在页面ID(点阵码识别)
final String? pageId;
PenStrokeEvent({
required this.penMac,
required this.points,
this.pageId,
});
}
/// BLE蓝牙点阵笔连接服务
/// 负责扫描、连接、数据接收、电量监控、自动重连等功能
/// 平板端支持同时连接1支笔(学生个人使用场景)
class PadBleService {
/// 已发现的设备列表
final List<PadPenDevice> _discoveredDevices = [];
/// 当前已连接的笔
PadPenDevice? _connectedPen;
/// 笔迹数据缓冲区(累积到阈值后批量回调)
final List<PadPenPoint> _strokeBuffer = [];
/// 扫描结果流
final StreamController<List<PadPenDevice>> _scanController =
StreamController<List<PadPenDevice>>.broadcast();
/// 笔迹数据事件流
final StreamController<PenStrokeEvent> _strokeController =
StreamController<PenStrokeEvent>.broadcast();
/// 连接状态变化流
final StreamController<PenConnectionState> _connectionController =
StreamController<PenConnectionState>.broadcast();
/// 电量变化流
final StreamController<int> _batteryController =
StreamController<int>.broadcast();
/// 自动重连计数器
int _reconnectAttempts = 0;
/// 重连定时器
Timer? _reconnectTimer;
/// 电量读取定时器
Timer? _batteryTimer;
/// 是否正在扫描
bool _isScanning = false;
/// 公开的流
Stream<List<PadPenDevice>> get scanStream => _scanController.stream;
Stream<PenStrokeEvent> get strokeStream => _strokeController.stream;
Stream<PenConnectionState> get connectionStream =>
_connectionController.stream;
Stream<int> get batteryStream => _batteryController.stream;
/// 获取当前连接的笔
PadPenDevice? get connectedPen => _connectedPen;
/// 开始扫描附近的点阵笔设备
/// 按服务UUID过滤,仅发现自然写点阵笔
Future<void> startScan() async {
if (_isScanning) return;
_isScanning = true;
_discoveredDevices.clear();
// 通知扫描状态
_connectionController.add(PenConnectionState.scanning);
// 模拟BLE扫描(实际使用flutter_blue_plus库)
// 过滤条件:仅发现包含pen_service_uuid的设备
// scanFilters: [ScanFilter(serviceUuid: PadBleConstants.penServiceUuid)]
// 设置扫描超时
Timer(Duration(seconds: PadBleConstants.scanTimeoutSeconds), () {
stopScan();
});
}
/// 停止扫描
Future<void> stopScan() async {
_isScanning = false;
// 实际调用: FlutterBluePlus.stopScan()
}
/// 处理扫描结果回调
void _onScanResult(String mac, String name, int rssi) {
// 检查是否已发现过
final existingIndex = _discoveredDevices.indexWhere(
(d) => d.macAddress == mac,
);
if (existingIndex >= 0) {
// 更新已有设备的RSSI
_discoveredDevices[existingIndex].rssi = rssi;
} else {
// 添加新发现的设备
_discoveredDevices.add(PadPenDevice(
macAddress: mac,
name: name,
rssi: rssi,
));
}
// 按信号强度降序排列
_discoveredDevices.sort((a, b) => b.rssi.compareTo(a.rssi));
_scanController.add(List.from(_discoveredDevices));
}
/// 连接指定的点阵笔
/// [device] 要连接的笔设备信息
Future<bool> connectPen(PadPenDevice device) async {
// 先断开已有连接
if (_connectedPen != null) {
await disconnectPen();
}
device.connectionState = PenConnectionState.connecting;
_connectionController.add(PenConnectionState.connecting);
try {
// 停止扫描
await stopScan();
// 执行BLE连接
// 实际调用: device.connect(timeout: Duration(seconds: 10))
// 协商MTU
// await device.requestMtu(PadBleConstants.requestedMtu);
// 发现服务和特征值
// final services = await device.discoverServices();
// 查找笔迹数据特征值并订阅Notify
// 设置连接成功状态
device.connectionState = PenConnectionState.connected;
_connectedPen = device;
_reconnectAttempts = 0;
_connectionController.add(PenConnectionState.connected);
// 启动电量定时读取
_startBatteryMonitor();
// 订阅笔迹数据特征值
_subscribeStrokeData();
return true;
} catch (e) {
device.connectionState = PenConnectionState.disconnected;
_connectionController.add(PenConnectionState.disconnected);
return false;
}
}
/// 订阅笔迹坐标数据Notify特征值
void _subscribeStrokeData() {
// 实际调用:
// characteristic.setNotifyValue(true);
// characteristic.onValueReceived.listen(_onStrokeDataReceived);
}
/// 处理接收到的笔迹原始数据(7字节紧凑编码)
/// 数据格式:[X_H, X_L, Y_H, Y_L, Pressure, TS_H, TS_L]
/// X: 16位无符号(0.01mm精度)
/// Y: 16位无符号(0.01mm精度)
/// Pressure: 8位无符号(0-255)
/// Timestamp: 16位无符号(相对毫秒)
void _onStrokeDataReceived(Uint8List rawData) {
if (rawData.length < 7) return;
// 可能包含多个坐标点(每7字节一个)
int offset = 0;
while (offset + 7 <= rawData.length) {
// 解码X坐标(大端序16位)
final int rawX = (rawData[offset] << 8) | rawData[offset + 1];
final double x = rawX * 0.01; // 转换为毫米
// 解码Y坐标
final int rawY = (rawData[offset + 2] << 8) | rawData[offset + 3];
final double y = rawY * 0.01;
// 解码压力值
final int pressure = rawData[offset + 4];
// 解码时间戳
final int timestamp =
(rawData[offset + 5] << 8) | rawData[offset + 6];
// 判断落笔/抬笔(压力值>0为落笔)
final bool isPenDown = pressure > 0;
final point = PadPenPoint(
x: x,
y: y,
pressure: pressure,
timestamp: timestamp,
isPenDown: isPenDown,
);
_strokeBuffer.add(point);
offset += 7;
}
// CRC-16 CCITT校验(如果数据包尾部有2字节CRC)
if (rawData.length > offset + 1) {
final int receivedCrc = (rawData[offset] << 8) | rawData[offset + 1];
final int calculatedCrc = _calculateCrc16(
rawData.sublist(0, offset),
);
if (receivedCrc != calculatedCrc) {
// CRC校验失败,丢弃本批数据
_strokeBuffer.clear();
return;
}
}
// 达到批量阈值后回调
if (_strokeBuffer.length >= PadBleConstants.strokeBatchSize) {
_flushStrokeBuffer();
}
}
/// 将缓冲区中的笔迹数据批量回调
void _flushStrokeBuffer() {
if (_strokeBuffer.isEmpty || _connectedPen == null) return;
final event = PenStrokeEvent(
penMac: _connectedPen!.macAddress,
points: List.from(_strokeBuffer),
pageId: _connectedPen!.currentPageId,
);
_strokeController.add(event);
_strokeBuffer.clear();
}
/// CRC-16 CCITT校验算法
/// 多项式: 0x1021, 初始值: 0xFFFF
int _calculateCrc16(Uint8List data) {
int crc = 0xFFFF;
for (int i = 0; i < data.length; i++) {
crc ^= (data[i] << 8);
for (int j = 0; j < 8; j++) {
if ((crc & 0x8000) != 0) {
crc = ((crc << 1) ^ 0x1021) & 0xFFFF;
} else {
crc = (crc << 1) & 0xFFFF;
}
}
}
return crc;
}
/// 启动电量定时读取
void _startBatteryMonitor() {
_batteryTimer?.cancel();
_batteryTimer = Timer.periodic(
Duration(seconds: PadBleConstants.batteryReadInterval),
(_) => _readBatteryLevel(),
);
// 立即读取一次
_readBatteryLevel();
}
/// 读取笔电量
Future<void> _readBatteryLevel() async {
if (_connectedPen == null) return;
try {
// 实际调用: 读取battery特征值
// final value = await batteryCharacteristic.read();
// _connectedPen!.batteryLevel = value[0];
// _batteryController.add(_connectedPen!.batteryLevel);
} catch (e) {
// 读取失败,忽略
}
}
/// 向笔发送控制指令
/// [command] 指令类型(如:LED闪烁、蜂鸣提示、固件信息查询)
Future<void> sendCommand(int command, [Uint8List? payload]) async {
if (_connectedPen == null) return;
// 构建指令包:[CMD, LEN, PAYLOAD..., CRC_H, CRC_L]
final List<int> packet = [command];
if (payload != null) {
packet.add(payload.length);
packet.addAll(payload);
} else {
packet.add(0);
}
// 追加CRC校验
final crc = _calculateCrc16(Uint8List.fromList(packet));
packet.add((crc >> 8) & 0xFF);
packet.add(crc & 0xFF);
// 实际调用: controlCharacteristic.write(Uint8List.fromList(packet));
}
/// 断开当前笔连接
Future<void> disconnectPen() async {
_batteryTimer?.cancel();
_reconnectTimer?.cancel();
if (_connectedPen != null) {
_connectedPen!.connectionState = PenConnectionState.disconnecting;
_connectionController.add(PenConnectionState.disconnecting);
// 实际调用: device.disconnect();
_connectedPen!.connectionState = PenConnectionState.disconnected;
_connectedPen = null;
_connectionController.add(PenConnectionState.disconnected);
}
// 清空缓冲区
_flushStrokeBuffer();
}
/// 处理连接意外断开,启动自动重连
void _onDisconnected(PadPenDevice device) {
if (_reconnectAttempts >= PadBleConstants.maxReconnectAttempts) {
// 超过最大重连次数,放弃重连
_connectionController.add(PenConnectionState.disconnected);
return;
}
_connectionController.add(PenConnectionState.reconnecting);
_reconnectAttempts++;
// 指数退避延迟重连
final delay = PadBleConstants.reconnectDelaySeconds * _reconnectAttempts;
final clampedDelay = delay > 30 ? 30 : delay;
_reconnectTimer = Timer(Duration(seconds: clampedDelay), () async {
final success = await connectPen(device);
if (!success) {
_onDisconnected(device);
}
});
}
/// 释放所有资源
void dispose() {
_batteryTimer?.cancel();
_reconnectTimer?.cancel();
_scanController.close();
_strokeController.close();
_connectionController.close();
_batteryController.close();
_strokeBuffer.clear();
}
}