442 lines
14 KiB
Dart
442 lines
14 KiB
Dart
|
|
import 'dart:convert';
|
|||
|
|
import '../../../core/network/api_client.dart';
|
|||
|
|
import '../../../core/services/storage_service.dart';
|
|||
|
|
import '../models/study_plan_model.dart';
|
|||
|
|
import '../models/vocabulary_book_model.dart';
|
|||
|
|
|
|||
|
|
/// 学习计划服务
|
|||
|
|
class StudyPlanService {
|
|||
|
|
final ApiClient _apiClient;
|
|||
|
|
final StorageService _storageService;
|
|||
|
|
|
|||
|
|
static const String _studyPlansKey = 'study_plans';
|
|||
|
|
static const String _dailyRecordsKey = 'daily_study_records';
|
|||
|
|
static const String _templatesKey = 'study_plan_templates';
|
|||
|
|
|
|||
|
|
StudyPlanService({
|
|||
|
|
required ApiClient apiClient,
|
|||
|
|
required StorageService storageService,
|
|||
|
|
}) : _apiClient = apiClient,
|
|||
|
|
_storageService = storageService;
|
|||
|
|
|
|||
|
|
/// 获取用户的所有学习计划
|
|||
|
|
Future<List<StudyPlan>> getUserStudyPlans() async {
|
|||
|
|
try {
|
|||
|
|
final localData = await _storageService.getString(_studyPlansKey);
|
|||
|
|
if (localData != null) {
|
|||
|
|
final List<dynamic> jsonList = json.decode(localData);
|
|||
|
|
return jsonList.map((json) => StudyPlan.fromJson(json)).toList();
|
|||
|
|
}
|
|||
|
|
return [];
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('获取学习计划失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 创建新的学习计划
|
|||
|
|
Future<StudyPlan> createStudyPlan({
|
|||
|
|
required String name,
|
|||
|
|
String? description,
|
|||
|
|
required StudyPlanType type,
|
|||
|
|
required DateTime startDate,
|
|||
|
|
required DateTime endDate,
|
|||
|
|
required int dailyTarget,
|
|||
|
|
List<String> vocabularyBookIds = const [],
|
|||
|
|
}) async {
|
|||
|
|
try {
|
|||
|
|
final studyPlan = StudyPlan(
|
|||
|
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
|||
|
|
name: name,
|
|||
|
|
description: description,
|
|||
|
|
userId: 'current_user',
|
|||
|
|
type: type,
|
|||
|
|
status: StudyPlanStatus.active,
|
|||
|
|
startDate: startDate,
|
|||
|
|
endDate: endDate,
|
|||
|
|
dailyTarget: dailyTarget,
|
|||
|
|
vocabularyBookIds: vocabularyBookIds,
|
|||
|
|
createdAt: DateTime.now(),
|
|||
|
|
updatedAt: DateTime.now(),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
final existingPlans = await getUserStudyPlans();
|
|||
|
|
existingPlans.add(studyPlan);
|
|||
|
|
await _saveStudyPlansToLocal(existingPlans);
|
|||
|
|
|
|||
|
|
return studyPlan;
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('创建学习计划失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 更新学习计划
|
|||
|
|
Future<StudyPlan> updateStudyPlan(StudyPlan studyPlan) async {
|
|||
|
|
try {
|
|||
|
|
final existingPlans = await getUserStudyPlans();
|
|||
|
|
final index = existingPlans.indexWhere((plan) => plan.id == studyPlan.id);
|
|||
|
|
|
|||
|
|
if (index == -1) {
|
|||
|
|
throw Exception('学习计划不存在');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final updatedPlan = studyPlan.copyWith(updatedAt: DateTime.now());
|
|||
|
|
existingPlans[index] = updatedPlan;
|
|||
|
|
await _saveStudyPlansToLocal(existingPlans);
|
|||
|
|
|
|||
|
|
return updatedPlan;
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('更新学习计划失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 删除学习计划
|
|||
|
|
Future<void> deleteStudyPlan(String planId) async {
|
|||
|
|
try {
|
|||
|
|
final existingPlans = await getUserStudyPlans();
|
|||
|
|
existingPlans.removeWhere((plan) => plan.id == planId);
|
|||
|
|
await _saveStudyPlansToLocal(existingPlans);
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('删除学习计划失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 记录每日学习进度
|
|||
|
|
Future<void> recordDailyProgress({
|
|||
|
|
required String planId,
|
|||
|
|
required DateTime date,
|
|||
|
|
required int wordsLearned,
|
|||
|
|
required int wordsReviewed,
|
|||
|
|
required int studyTimeMinutes,
|
|||
|
|
List<String> vocabularyBookIds = const [],
|
|||
|
|
}) async {
|
|||
|
|
try {
|
|||
|
|
final record = DailyStudyRecord(
|
|||
|
|
date: DateTime(date.year, date.month, date.day),
|
|||
|
|
wordsLearned: wordsLearned,
|
|||
|
|
wordsReviewed: wordsReviewed,
|
|||
|
|
studyTimeMinutes: studyTimeMinutes,
|
|||
|
|
vocabularyBookIds: vocabularyBookIds,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 获取现有记录
|
|||
|
|
final allRecords = await _getAllDailyRecords();
|
|||
|
|
final recordKey = '${planId}_${_formatDateKey(date)}';
|
|||
|
|
|
|||
|
|
// 更新或添加记录
|
|||
|
|
allRecords[recordKey] = record;
|
|||
|
|
await _saveDailyRecordsToLocal(allRecords);
|
|||
|
|
|
|||
|
|
// 更新学习计划的进度
|
|||
|
|
await _updateStudyPlanProgress(planId);
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('记录学习进度失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 获取学习计划统计信息
|
|||
|
|
Future<StudyPlanStats> getStudyPlanStats(String planId) async {
|
|||
|
|
try {
|
|||
|
|
final studyPlans = await getUserStudyPlans();
|
|||
|
|
final studyPlan = studyPlans.where((plan) => plan.id == planId).firstOrNull;
|
|||
|
|
|
|||
|
|
if (studyPlan == null) {
|
|||
|
|
throw Exception('学习计划不存在');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final allRecords = await _getAllDailyRecords();
|
|||
|
|
final planRecords = <DailyStudyRecord>[];
|
|||
|
|
|
|||
|
|
// 获取该计划的所有记录
|
|||
|
|
for (final entry in allRecords.entries) {
|
|||
|
|
if (entry.key.startsWith('${planId}_')) {
|
|||
|
|
planRecords.add(entry.value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算统计信息
|
|||
|
|
final totalDays = studyPlan.endDate.difference(studyPlan.startDate).inDays + 1;
|
|||
|
|
final studiedDays = planRecords.length;
|
|||
|
|
final totalWordsLearned = planRecords.fold(0, (sum, record) => sum + record.wordsLearned);
|
|||
|
|
final totalWordsReviewed = planRecords.fold(0, (sum, record) => sum + record.wordsReviewed);
|
|||
|
|
|
|||
|
|
// 计算连续学习天数
|
|||
|
|
final currentStreak = _calculateCurrentStreak(planRecords);
|
|||
|
|
final maxStreak = _calculateMaxStreak(planRecords);
|
|||
|
|
|
|||
|
|
final completionRate = studyPlan.totalWords > 0
|
|||
|
|
? studyPlan.completedWords / studyPlan.totalWords
|
|||
|
|
: 0.0;
|
|||
|
|
|
|||
|
|
final averageDailyWords = studiedDays > 0
|
|||
|
|
? totalWordsLearned / studiedDays
|
|||
|
|
: 0.0;
|
|||
|
|
|
|||
|
|
final remainingDays = studyPlan.endDate.difference(DateTime.now()).inDays;
|
|||
|
|
final remainingWords = studyPlan.totalWords - studyPlan.completedWords;
|
|||
|
|
|
|||
|
|
final lastStudyDate = planRecords.isNotEmpty
|
|||
|
|
? planRecords.map((r) => r.date).reduce((a, b) => a.isAfter(b) ? a : b)
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
return StudyPlanStats(
|
|||
|
|
planId: planId,
|
|||
|
|
totalDays: totalDays,
|
|||
|
|
studiedDays: studiedDays,
|
|||
|
|
totalWords: studyPlan.totalWords,
|
|||
|
|
learnedWords: totalWordsLearned,
|
|||
|
|
reviewedWords: totalWordsReviewed,
|
|||
|
|
currentStreak: currentStreak,
|
|||
|
|
maxStreak: maxStreak,
|
|||
|
|
completionRate: completionRate,
|
|||
|
|
averageDailyWords: averageDailyWords,
|
|||
|
|
remainingDays: remainingDays > 0 ? remainingDays : 0,
|
|||
|
|
remainingWords: remainingWords > 0 ? remainingWords : 0,
|
|||
|
|
lastStudyDate: lastStudyDate,
|
|||
|
|
dailyRecords: planRecords,
|
|||
|
|
);
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('获取学习计划统计失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 获取今日学习任务
|
|||
|
|
Future<List<StudyPlan>> getTodayStudyTasks() async {
|
|||
|
|
try {
|
|||
|
|
final allPlans = await getUserStudyPlans();
|
|||
|
|
final today = DateTime.now();
|
|||
|
|
|
|||
|
|
return allPlans.where((plan) {
|
|||
|
|
return plan.status == StudyPlanStatus.active &&
|
|||
|
|
plan.startDate.isBefore(today.add(const Duration(days: 1))) &&
|
|||
|
|
plan.endDate.isAfter(today.subtract(const Duration(days: 1)));
|
|||
|
|
}).toList();
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('获取今日学习任务失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 检查今日是否完成目标
|
|||
|
|
Future<bool> isTodayTargetAchieved(String planId) async {
|
|||
|
|
try {
|
|||
|
|
final today = DateTime.now();
|
|||
|
|
final allRecords = await _getAllDailyRecords();
|
|||
|
|
final recordKey = '${planId}_${_formatDateKey(today)}';
|
|||
|
|
|
|||
|
|
final todayRecord = allRecords[recordKey];
|
|||
|
|
if (todayRecord == null) return false;
|
|||
|
|
|
|||
|
|
final studyPlans = await getUserStudyPlans();
|
|||
|
|
final studyPlan = studyPlans.where((plan) => plan.id == planId).firstOrNull;
|
|||
|
|
|
|||
|
|
if (studyPlan == null) return false;
|
|||
|
|
|
|||
|
|
return todayRecord.wordsLearned >= studyPlan.dailyTarget;
|
|||
|
|
} catch (e) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 获取学习计划模板
|
|||
|
|
Future<List<StudyPlanTemplate>> getStudyPlanTemplates() async {
|
|||
|
|
try {
|
|||
|
|
// 返回预设的学习计划模板
|
|||
|
|
return [
|
|||
|
|
StudyPlanTemplate(
|
|||
|
|
id: 'daily_basic',
|
|||
|
|
name: '每日基础学习',
|
|||
|
|
description: '每天学习20个新单词,适合初学者',
|
|||
|
|
type: StudyPlanType.daily,
|
|||
|
|
durationDays: 30,
|
|||
|
|
dailyTarget: 20,
|
|||
|
|
difficulty: 1,
|
|||
|
|
tags: ['基础', '初学者'],
|
|||
|
|
isPopular: true,
|
|||
|
|
),
|
|||
|
|
StudyPlanTemplate(
|
|||
|
|
id: 'weekly_intensive',
|
|||
|
|
name: '周集中学习',
|
|||
|
|
description: '每周集中学习,每天50个单词',
|
|||
|
|
type: StudyPlanType.weekly,
|
|||
|
|
durationDays: 7,
|
|||
|
|
dailyTarget: 50,
|
|||
|
|
difficulty: 3,
|
|||
|
|
tags: ['集中', '高强度'],
|
|||
|
|
),
|
|||
|
|
StudyPlanTemplate(
|
|||
|
|
id: 'exam_prep_cet4',
|
|||
|
|
name: 'CET-4考试准备',
|
|||
|
|
description: '为大学英语四级考试准备的学习计划',
|
|||
|
|
type: StudyPlanType.examPrep,
|
|||
|
|
durationDays: 60,
|
|||
|
|
dailyTarget: 30,
|
|||
|
|
difficulty: 2,
|
|||
|
|
tags: ['考试', 'CET-4'],
|
|||
|
|
isPopular: true,
|
|||
|
|
),
|
|||
|
|
StudyPlanTemplate(
|
|||
|
|
id: 'exam_prep_cet6',
|
|||
|
|
name: 'CET-6考试准备',
|
|||
|
|
description: '为大学英语六级考试准备的学习计划',
|
|||
|
|
type: StudyPlanType.examPrep,
|
|||
|
|
durationDays: 90,
|
|||
|
|
dailyTarget: 40,
|
|||
|
|
difficulty: 3,
|
|||
|
|
tags: ['考试', 'CET-6'],
|
|||
|
|
),
|
|||
|
|
StudyPlanTemplate(
|
|||
|
|
id: 'toefl_prep',
|
|||
|
|
name: 'TOEFL考试准备',
|
|||
|
|
description: '为托福考试准备的高强度学习计划',
|
|||
|
|
type: StudyPlanType.examPrep,
|
|||
|
|
durationDays: 120,
|
|||
|
|
dailyTarget: 60,
|
|||
|
|
difficulty: 4,
|
|||
|
|
tags: ['考试', 'TOEFL', '出国'],
|
|||
|
|
isPopular: true,
|
|||
|
|
),
|
|||
|
|
];
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('获取学习计划模板失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 从模板创建学习计划
|
|||
|
|
Future<StudyPlan> createStudyPlanFromTemplate({
|
|||
|
|
required StudyPlanTemplate template,
|
|||
|
|
required DateTime startDate,
|
|||
|
|
List<String> vocabularyBookIds = const [],
|
|||
|
|
}) async {
|
|||
|
|
try {
|
|||
|
|
final endDate = startDate.add(Duration(days: template.durationDays - 1));
|
|||
|
|
|
|||
|
|
return await createStudyPlan(
|
|||
|
|
name: template.name,
|
|||
|
|
description: template.description,
|
|||
|
|
type: template.type,
|
|||
|
|
startDate: startDate,
|
|||
|
|
endDate: endDate,
|
|||
|
|
dailyTarget: template.dailyTarget,
|
|||
|
|
vocabularyBookIds: vocabularyBookIds,
|
|||
|
|
);
|
|||
|
|
} catch (e) {
|
|||
|
|
throw Exception('从模板创建学习计划失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 保存学习计划到本地存储
|
|||
|
|
Future<void> _saveStudyPlansToLocal(List<StudyPlan> studyPlans) async {
|
|||
|
|
final jsonList = studyPlans.map((plan) => plan.toJson()).toList();
|
|||
|
|
await _storageService.setString(_studyPlansKey, json.encode(jsonList));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 保存每日记录到本地存储
|
|||
|
|
Future<void> _saveDailyRecordsToLocal(Map<String, DailyStudyRecord> records) async {
|
|||
|
|
final jsonMap = records.map((key, record) => MapEntry(key, record.toJson()));
|
|||
|
|
await _storageService.setString(_dailyRecordsKey, json.encode(jsonMap));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 获取所有每日记录
|
|||
|
|
Future<Map<String, DailyStudyRecord>> _getAllDailyRecords() async {
|
|||
|
|
final localData = await _storageService.getString(_dailyRecordsKey);
|
|||
|
|
if (localData == null) {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final Map<String, dynamic> jsonMap = json.decode(localData);
|
|||
|
|
return jsonMap.map((key, value) => MapEntry(key, DailyStudyRecord.fromJson(value)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 更新学习计划进度
|
|||
|
|
Future<void> _updateStudyPlanProgress(String planId) async {
|
|||
|
|
try {
|
|||
|
|
final studyPlans = await getUserStudyPlans();
|
|||
|
|
final index = studyPlans.indexWhere((plan) => plan.id == planId);
|
|||
|
|
|
|||
|
|
if (index == -1) return;
|
|||
|
|
|
|||
|
|
final studyPlan = studyPlans[index];
|
|||
|
|
final allRecords = await _getAllDailyRecords();
|
|||
|
|
|
|||
|
|
// 计算总完成单词数
|
|||
|
|
int totalCompleted = 0;
|
|||
|
|
for (final entry in allRecords.entries) {
|
|||
|
|
if (entry.key.startsWith('${planId}_')) {
|
|||
|
|
totalCompleted += entry.value.wordsLearned;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算连续学习天数
|
|||
|
|
final planRecords = <DailyStudyRecord>[];
|
|||
|
|
for (final entry in allRecords.entries) {
|
|||
|
|
if (entry.key.startsWith('${planId}_')) {
|
|||
|
|
planRecords.add(entry.value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final currentStreak = _calculateCurrentStreak(planRecords);
|
|||
|
|
final maxStreak = _calculateMaxStreak(planRecords);
|
|||
|
|
|
|||
|
|
final updatedPlan = studyPlan.copyWith(
|
|||
|
|
completedWords: totalCompleted,
|
|||
|
|
currentStreak: currentStreak,
|
|||
|
|
maxStreak: maxStreak > studyPlan.maxStreak ? maxStreak : studyPlan.maxStreak,
|
|||
|
|
updatedAt: DateTime.now(),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
studyPlans[index] = updatedPlan;
|
|||
|
|
await _saveStudyPlansToLocal(studyPlans);
|
|||
|
|
} catch (e) {
|
|||
|
|
// 忽略更新错误
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 计算当前连续学习天数
|
|||
|
|
int _calculateCurrentStreak(List<DailyStudyRecord> records) {
|
|||
|
|
if (records.isEmpty) return 0;
|
|||
|
|
|
|||
|
|
records.sort((a, b) => b.date.compareTo(a.date));
|
|||
|
|
|
|||
|
|
int streak = 0;
|
|||
|
|
DateTime currentDate = DateTime.now();
|
|||
|
|
|
|||
|
|
for (final record in records) {
|
|||
|
|
final daysDiff = currentDate.difference(record.date).inDays;
|
|||
|
|
|
|||
|
|
if (daysDiff == streak) {
|
|||
|
|
streak++;
|
|||
|
|
} else {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return streak;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 计算最大连续学习天数
|
|||
|
|
int _calculateMaxStreak(List<DailyStudyRecord> records) {
|
|||
|
|
if (records.isEmpty) return 0;
|
|||
|
|
|
|||
|
|
records.sort((a, b) => a.date.compareTo(b.date));
|
|||
|
|
|
|||
|
|
int maxStreak = 1;
|
|||
|
|
int currentStreak = 1;
|
|||
|
|
|
|||
|
|
for (int i = 1; i < records.length; i++) {
|
|||
|
|
final daysDiff = records[i].date.difference(records[i - 1].date).inDays;
|
|||
|
|
|
|||
|
|
if (daysDiff == 1) {
|
|||
|
|
currentStreak++;
|
|||
|
|
maxStreak = maxStreak > currentStreak ? maxStreak : currentStreak;
|
|||
|
|
} else {
|
|||
|
|
currentStreak = 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return maxStreak;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 格式化日期键
|
|||
|
|
String _formatDateKey(DateTime date) {
|
|||
|
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|||
|
|
}
|
|||
|
|
}
|