init
This commit is contained in:
388
client/lib/features/writing/data/writing_static_data.dart
Normal file
388
client/lib/features/writing/data/writing_static_data.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
import '../models/writing_task.dart';
|
||||
|
||||
class WritingStaticData {
|
||||
static List<WritingTask> getAllTasks() {
|
||||
final regularTasks = [
|
||||
// 议论文类型
|
||||
WritingTask(
|
||||
id: 'essay_1',
|
||||
title: '环境保护的重要性',
|
||||
description: '写一篇关于环境保护重要性的议论文,阐述个人观点并提供支持论据。',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.intermediate,
|
||||
timeLimit: 45,
|
||||
wordLimit: 300,
|
||||
keywords: ['环境保护', '可持续发展', '气候变化', '绿色生活'],
|
||||
requirements: [
|
||||
'明确表达个人观点',
|
||||
'提供至少3个支持论据',
|
||||
'使用恰当的连接词',
|
||||
'结构清晰,逻辑性强'
|
||||
],
|
||||
prompt: '随着工业化的发展,环境问题日益严重。请就"环境保护的重要性"这一话题写一篇议论文,表达你的观点并提供有力的论据支持。',
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 7)),
|
||||
),
|
||||
|
||||
WritingTask(
|
||||
id: 'essay_2',
|
||||
title: '科技对教育的影响',
|
||||
description: '分析科技发展对现代教育的积极和消极影响。',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.upperIntermediate,
|
||||
timeLimit: 50,
|
||||
wordLimit: 400,
|
||||
keywords: ['科技', '教育', '在线学习', '数字化'],
|
||||
requirements: [
|
||||
'分析积极和消极两方面影响',
|
||||
'举出具体例子说明',
|
||||
'使用高级词汇和句型',
|
||||
'观点平衡,论证充分'
|
||||
],
|
||||
prompt: '科技的快速发展正在改变教育的方式。请分析科技对现代教育的影响,包括积极和消极两个方面。',
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 5)),
|
||||
),
|
||||
|
||||
// 书信类型
|
||||
WritingTask(
|
||||
id: 'letter_1',
|
||||
title: '给朋友的邀请信',
|
||||
description: '写一封邀请朋友参加生日聚会的信件。',
|
||||
type: WritingType.letter,
|
||||
difficulty: WritingDifficulty.elementary,
|
||||
timeLimit: 30,
|
||||
wordLimit: 150,
|
||||
keywords: ['邀请', '生日聚会', '朋友', '时间地点'],
|
||||
requirements: [
|
||||
'使用正确的书信格式',
|
||||
'语气友好亲切',
|
||||
'包含时间、地点等具体信息',
|
||||
'表达期待之情'
|
||||
],
|
||||
prompt: '你即将举办生日聚会,想邀请你的好朋友Tom参加。请写一封邀请信,包含聚会的时间、地点和活动安排。',
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 3)),
|
||||
),
|
||||
|
||||
WritingTask(
|
||||
id: 'letter_2',
|
||||
title: '求职申请信',
|
||||
description: '写一封应聘市场营销职位的求职信。',
|
||||
type: WritingType.letter,
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
timeLimit: 40,
|
||||
wordLimit: 250,
|
||||
keywords: ['求职', '市场营销', '技能', '经验'],
|
||||
requirements: [
|
||||
'使用正式的商务信件格式',
|
||||
'突出个人优势和相关经验',
|
||||
'表达对职位的兴趣',
|
||||
'语言专业得体'
|
||||
],
|
||||
prompt: '你看到一家公司招聘市场营销专员的广告,请写一封求职申请信,介绍你的背景和为什么适合这个职位。',
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 2)),
|
||||
),
|
||||
|
||||
// 邮件类型
|
||||
WritingTask(
|
||||
id: 'email_1',
|
||||
title: '商务邮件:会议安排',
|
||||
description: '写一封关于安排部门会议的商务邮件。',
|
||||
type: WritingType.email,
|
||||
difficulty: WritingDifficulty.intermediate,
|
||||
timeLimit: 25,
|
||||
wordLimit: 120,
|
||||
keywords: ['会议', '安排', '议程', '时间'],
|
||||
requirements: [
|
||||
'使用恰当的邮件格式',
|
||||
'主题明确',
|
||||
'内容简洁明了',
|
||||
'包含必要的会议信息'
|
||||
],
|
||||
prompt: '作为项目经理,你需要安排下周的部门会议。请写一封邮件通知团队成员,包含会议时间、地点和主要议程。',
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
|
||||
// 报告类型
|
||||
WritingTask(
|
||||
id: 'report_1',
|
||||
title: '市场调研报告',
|
||||
description: '撰写一份关于新产品市场前景的调研报告。',
|
||||
type: WritingType.report,
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
timeLimit: 60,
|
||||
wordLimit: 500,
|
||||
keywords: ['市场调研', '数据分析', '趋势', '建议'],
|
||||
requirements: [
|
||||
'使用正式的报告格式',
|
||||
'包含数据和图表说明',
|
||||
'分析客观准确',
|
||||
'提出可行性建议'
|
||||
],
|
||||
prompt: '你的公司计划推出一款新的智能手表产品。请根据市场调研数据,撰写一份市场前景分析报告。',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
|
||||
// 故事类型
|
||||
WritingTask(
|
||||
id: 'story_1',
|
||||
title: '童年回忆',
|
||||
description: '写一个关于童年难忘经历的故事。',
|
||||
type: WritingType.story,
|
||||
difficulty: WritingDifficulty.intermediate,
|
||||
timeLimit: 35,
|
||||
wordLimit: 200,
|
||||
keywords: ['童年', '回忆', '成长', '经历'],
|
||||
requirements: [
|
||||
'情节生动有趣',
|
||||
'使用描述性语言',
|
||||
'表达真实情感',
|
||||
'结构完整'
|
||||
],
|
||||
prompt: '回想你的童年时光,选择一个特别难忘的经历,写成一个小故事。可以是快乐的、有趣的,或者让你学到重要道理的经历。',
|
||||
createdAt: DateTime.now().subtract(const Duration(hours: 12)),
|
||||
),
|
||||
|
||||
// 评论类型
|
||||
WritingTask(
|
||||
id: 'review_1',
|
||||
title: '电影评论',
|
||||
description: '写一篇关于最近观看电影的评论。',
|
||||
type: WritingType.review,
|
||||
difficulty: WritingDifficulty.upperIntermediate,
|
||||
timeLimit: 40,
|
||||
wordLimit: 300,
|
||||
keywords: ['电影', '评论', '剧情', '演技'],
|
||||
requirements: [
|
||||
'客观评价电影各个方面',
|
||||
'支持观点的具体例子',
|
||||
'使用评论性词汇',
|
||||
'给出推荐建议'
|
||||
],
|
||||
prompt: '选择一部你最近观看的电影,写一篇评论。包括对剧情、演技、视觉效果等方面的评价,并说明是否推荐他人观看。',
|
||||
createdAt: DateTime.now().subtract(const Duration(hours: 6)),
|
||||
),
|
||||
|
||||
// 描述文类型
|
||||
WritingTask(
|
||||
id: 'description_1',
|
||||
title: '我的理想房屋',
|
||||
description: '描述你心目中理想房屋的样子。',
|
||||
type: WritingType.description,
|
||||
difficulty: WritingDifficulty.elementary,
|
||||
timeLimit: 30,
|
||||
wordLimit: 180,
|
||||
keywords: ['房屋', '设计', '装修', '功能'],
|
||||
requirements: [
|
||||
'使用丰富的形容词',
|
||||
'空间描述清晰',
|
||||
'细节生动具体',
|
||||
'逻辑顺序合理'
|
||||
],
|
||||
prompt: '想象你有机会设计自己的理想房屋。请详细描述这个房屋的外观、内部布局、装修风格和特殊功能。',
|
||||
createdAt: DateTime.now().subtract(const Duration(hours: 3)),
|
||||
),
|
||||
];
|
||||
|
||||
final examTasks = getExamTasks();
|
||||
|
||||
return [...regularTasks, ...examTasks];
|
||||
}
|
||||
|
||||
static List<WritingTask> getTasksByType(WritingType type) {
|
||||
return getAllTasks().where((task) => task.type == type).toList();
|
||||
}
|
||||
|
||||
static List<WritingTask> getTasksByDifficulty(WritingDifficulty difficulty) {
|
||||
return getAllTasks().where((task) => task.difficulty == difficulty).toList();
|
||||
}
|
||||
|
||||
static WritingTask? getTaskById(String id) {
|
||||
try {
|
||||
return getAllTasks().firstWhere((task) => task.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static List<WritingTask> getRecentTasks({int limit = 5}) {
|
||||
final tasks = getAllTasks();
|
||||
tasks.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return tasks.take(limit).toList();
|
||||
}
|
||||
|
||||
static List<WritingTask> getPopularTasks({int limit = 5}) {
|
||||
// 模拟热门任务(实际应用中可能基于完成次数、评分等)
|
||||
final popularIds = ['essay_1', 'letter_1', 'email_1', 'story_1', 'description_1'];
|
||||
return getAllTasks()
|
||||
.where((task) => popularIds.contains(task.id))
|
||||
.take(limit)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<WritingTask> getExamTasks() {
|
||||
return [
|
||||
// 四六级写作
|
||||
WritingTask(
|
||||
id: 'cet_1',
|
||||
title: '校园生活的变化',
|
||||
description: '描述大学校园生活的变化及其影响',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.intermediate,
|
||||
timeLimit: 30,
|
||||
wordLimit: 120,
|
||||
keywords: ['校园生活', '变化', '影响', '大学'],
|
||||
requirements: [
|
||||
'字数不少于120词',
|
||||
'结构清晰,逻辑合理',
|
||||
'语言准确,表达流畅',
|
||||
'内容充实,观点明确'
|
||||
],
|
||||
prompt: 'For this part, you are allowed 30 minutes to write an essay on the changes in campus life. You should write at least 120 words but no more than 180 words.',
|
||||
examType: ExamType.cet,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
WritingTask(
|
||||
id: 'cet_2',
|
||||
title: '网络学习的利弊',
|
||||
description: '分析网络学习的优势和劣势',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.intermediate,
|
||||
timeLimit: 30,
|
||||
wordLimit: 120,
|
||||
keywords: ['网络学习', '在线教育', '优势', '劣势'],
|
||||
requirements: [
|
||||
'字数不少于120词',
|
||||
'分析利弊两个方面',
|
||||
'举例说明观点',
|
||||
'语言规范,表达清楚'
|
||||
],
|
||||
prompt: 'For this part, you are allowed 30 minutes to write an essay on the advantages and disadvantages of online learning. You should write at least 120 words but no more than 180 words.',
|
||||
examType: ExamType.cet,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 2)),
|
||||
),
|
||||
|
||||
// 考研写作
|
||||
WritingTask(
|
||||
id: 'kaoyan_1',
|
||||
title: '文化交流的重要性',
|
||||
description: '论述文化交流在全球化时代的重要性',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.upperIntermediate,
|
||||
timeLimit: 30,
|
||||
wordLimit: 160,
|
||||
keywords: ['文化交流', '全球化', '理解', '合作'],
|
||||
requirements: [
|
||||
'字数160-200词',
|
||||
'论点明确,论证充分',
|
||||
'使用高级词汇和句型',
|
||||
'结构完整,逻辑清晰'
|
||||
],
|
||||
prompt: 'Write an essay of 160-200 words based on the following drawing. In your essay, you should 1) describe the drawing briefly, 2) explain its intended meaning, and 3) give your comments.',
|
||||
examType: ExamType.kaoyan,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 3)),
|
||||
),
|
||||
WritingTask(
|
||||
id: 'kaoyan_2',
|
||||
title: '环境保护与经济发展',
|
||||
description: '讨论环境保护与经济发展的关系',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.upperIntermediate,
|
||||
timeLimit: 30,
|
||||
wordLimit: 160,
|
||||
keywords: ['环境保护', '经济发展', '平衡', '可持续'],
|
||||
requirements: [
|
||||
'字数160-200词',
|
||||
'分析两者关系',
|
||||
'提出解决方案',
|
||||
'语言准确,表达地道'
|
||||
],
|
||||
prompt: 'Write an essay of 160-200 words based on the following drawing. In your essay, you should 1) describe the drawing briefly, 2) explain its intended meaning, and 3) give your comments.',
|
||||
examType: ExamType.kaoyan,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 4)),
|
||||
),
|
||||
|
||||
// 托福写作
|
||||
WritingTask(
|
||||
id: 'toefl_1',
|
||||
title: '独立写作:在线教育vs传统教育',
|
||||
description: '比较在线教育和传统教育的优劣',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
timeLimit: 30,
|
||||
wordLimit: 300,
|
||||
keywords: ['在线教育', '传统教育', '比较', '效果'],
|
||||
requirements: [
|
||||
'字数不少于300词',
|
||||
'明确表达个人观点',
|
||||
'提供具体例子支持',
|
||||
'使用多样化的句型和词汇'
|
||||
],
|
||||
prompt: 'Do you agree or disagree with the following statement? Online education is more effective than traditional classroom education. Use specific reasons and examples to support your answer.',
|
||||
examType: ExamType.toefl,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 5)),
|
||||
),
|
||||
WritingTask(
|
||||
id: 'toefl_2',
|
||||
title: '独立写作:科技对人际关系的影响',
|
||||
description: '分析现代科技对人际关系的影响',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
timeLimit: 30,
|
||||
wordLimit: 300,
|
||||
keywords: ['科技', '人际关系', '社交媒体', '沟通'],
|
||||
requirements: [
|
||||
'字数不少于300词',
|
||||
'分析正面和负面影响',
|
||||
'使用具体例子',
|
||||
'结论明确'
|
||||
],
|
||||
prompt: 'Do you agree or disagree with the following statement? Technology has made people less social and more isolated. Use specific reasons and examples to support your answer.',
|
||||
examType: ExamType.toefl,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 6)),
|
||||
),
|
||||
|
||||
// 雅思写作
|
||||
WritingTask(
|
||||
id: 'ielts_1',
|
||||
title: 'Task 2: 城市化的影响',
|
||||
description: '讨论城市化对社会和环境的影响',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
timeLimit: 40,
|
||||
wordLimit: 250,
|
||||
keywords: ['城市化', '社会影响', '环境影响', '发展'],
|
||||
requirements: [
|
||||
'字数不少于250词',
|
||||
'讨论问题的两个方面',
|
||||
'给出自己的观点',
|
||||
'使用正式的学术语言'
|
||||
],
|
||||
prompt: 'In many countries, people are moving from rural areas to cities. What are the advantages and disadvantages of this trend? Give reasons for your answer and include any relevant examples from your own knowledge or experience.',
|
||||
examType: ExamType.ielts,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 7)),
|
||||
),
|
||||
WritingTask(
|
||||
id: 'ielts_2',
|
||||
title: 'Task 2: 教育的目的',
|
||||
description: '讨论教育的主要目的是什么',
|
||||
type: WritingType.essay,
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
timeLimit: 40,
|
||||
wordLimit: 250,
|
||||
keywords: ['教育', '目的', '技能', '知识'],
|
||||
requirements: [
|
||||
'字数不少于250词',
|
||||
'清楚表达观点',
|
||||
'提供相关例子',
|
||||
'逻辑清晰,结构完整'
|
||||
],
|
||||
prompt: 'Some people think that the main purpose of education is to prepare students for the working world. Others believe that education should focus on developing knowledge and critical thinking skills. Discuss both views and give your own opinion.',
|
||||
examType: ExamType.ielts,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 8)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<WritingTask> getTasksByExamType(ExamType examType) {
|
||||
return getExamTasks().where((task) => task.examType == examType).toList();
|
||||
}
|
||||
}
|
||||
96
client/lib/features/writing/models/writing_record.dart
Normal file
96
client/lib/features/writing/models/writing_record.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
/// 写作完成记录模型
|
||||
class WritingRecord {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String taskTitle;
|
||||
final String taskDescription;
|
||||
final String content;
|
||||
final int wordCount;
|
||||
final int timeUsed; // 使用的时间(秒)
|
||||
final int score;
|
||||
final DateTime completedAt;
|
||||
final Map<String, dynamic>? feedback;
|
||||
|
||||
const WritingRecord({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.taskTitle,
|
||||
required this.taskDescription,
|
||||
required this.content,
|
||||
required this.wordCount,
|
||||
required this.timeUsed,
|
||||
required this.score,
|
||||
required this.completedAt,
|
||||
this.feedback,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'taskId': taskId,
|
||||
'taskTitle': taskTitle,
|
||||
'taskDescription': taskDescription,
|
||||
'content': content,
|
||||
'wordCount': wordCount,
|
||||
'timeUsed': timeUsed,
|
||||
'score': score,
|
||||
'completedAt': completedAt.toIso8601String(),
|
||||
'feedback': feedback,
|
||||
};
|
||||
}
|
||||
|
||||
factory WritingRecord.fromJson(Map<String, dynamic> json) {
|
||||
return WritingRecord(
|
||||
id: json['id'],
|
||||
taskId: json['taskId'],
|
||||
taskTitle: json['taskTitle'],
|
||||
taskDescription: json['taskDescription'],
|
||||
content: json['content'],
|
||||
wordCount: json['wordCount'],
|
||||
timeUsed: json['timeUsed'],
|
||||
score: json['score'],
|
||||
completedAt: DateTime.parse(json['completedAt']),
|
||||
feedback: json['feedback'],
|
||||
);
|
||||
}
|
||||
|
||||
WritingRecord copyWith({
|
||||
String? id,
|
||||
String? taskId,
|
||||
String? taskTitle,
|
||||
String? taskDescription,
|
||||
String? content,
|
||||
int? wordCount,
|
||||
int? timeUsed,
|
||||
int? score,
|
||||
DateTime? completedAt,
|
||||
Map<String, dynamic>? feedback,
|
||||
}) {
|
||||
return WritingRecord(
|
||||
id: id ?? this.id,
|
||||
taskId: taskId ?? this.taskId,
|
||||
taskTitle: taskTitle ?? this.taskTitle,
|
||||
taskDescription: taskDescription ?? this.taskDescription,
|
||||
content: content ?? this.content,
|
||||
wordCount: wordCount ?? this.wordCount,
|
||||
timeUsed: timeUsed ?? this.timeUsed,
|
||||
score: score ?? this.score,
|
||||
completedAt: completedAt ?? this.completedAt,
|
||||
feedback: feedback ?? this.feedback,
|
||||
);
|
||||
}
|
||||
|
||||
String get formattedDate {
|
||||
return '${completedAt.year}-${completedAt.month.toString().padLeft(2, '0')}-${completedAt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String get formattedTime {
|
||||
final minutes = timeUsed ~/ 60;
|
||||
final seconds = timeUsed % 60;
|
||||
return '${minutes}分${seconds}秒';
|
||||
}
|
||||
|
||||
String get scoreText {
|
||||
return '${score}分';
|
||||
}
|
||||
}
|
||||
238
client/lib/features/writing/models/writing_stats.dart
Normal file
238
client/lib/features/writing/models/writing_stats.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
class WritingStats {
|
||||
final String userId;
|
||||
final int totalTasks;
|
||||
final int completedTasks;
|
||||
final int totalWords;
|
||||
final int totalTimeSpent; // 秒
|
||||
final double averageScore;
|
||||
final Map<String, int> taskTypeStats;
|
||||
final Map<String, int> difficultyStats;
|
||||
final List<WritingProgressData> progressData;
|
||||
final WritingSkillAnalysis skillAnalysis;
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const WritingStats({
|
||||
required this.userId,
|
||||
required this.totalTasks,
|
||||
required this.completedTasks,
|
||||
required this.totalWords,
|
||||
required this.totalTimeSpent,
|
||||
required this.averageScore,
|
||||
required this.taskTypeStats,
|
||||
required this.difficultyStats,
|
||||
required this.progressData,
|
||||
required this.skillAnalysis,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
double get completionRate => totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
|
||||
|
||||
double get averageWordsPerTask => completedTasks > 0 ? totalWords / completedTasks : 0;
|
||||
|
||||
double get averageTimePerTask => completedTasks > 0 ? totalTimeSpent / completedTasks : 0;
|
||||
|
||||
factory WritingStats.fromJson(Map<String, dynamic> json) {
|
||||
final userIdVal = json['userId'];
|
||||
final totalTasksVal = json['totalTasks'] ?? json['total_submissions'];
|
||||
final completedTasksVal = json['completedTasks'] ?? json['completed_submissions'];
|
||||
final totalWordsVal = json['totalWords'] ?? json['total_word_count'];
|
||||
final totalTimeSpentVal = json['totalTimeSpent'] ?? json['total_time_spent'];
|
||||
final averageScoreVal = json['averageScore'] ?? json['average_score'];
|
||||
final difficultyStatsVal = json['difficultyStats'] ?? json['difficulty_stats'] ?? {};
|
||||
final progressDataVal = json['progressData'] ?? [];
|
||||
final skillAnalysisVal = json['skillAnalysis'];
|
||||
final lastUpdatedVal = json['lastUpdated'];
|
||||
|
||||
final stats = WritingStats(
|
||||
userId: userIdVal?.toString() ?? '',
|
||||
totalTasks: _asInt(totalTasksVal),
|
||||
completedTasks: _asInt(completedTasksVal),
|
||||
totalWords: _asInt(totalWordsVal),
|
||||
totalTimeSpent: _asInt(totalTimeSpentVal),
|
||||
averageScore: _asDouble(averageScoreVal),
|
||||
taskTypeStats: Map<String, int>.from(json['taskTypeStats'] ?? {}),
|
||||
difficultyStats: Map<String, int>.from(difficultyStatsVal),
|
||||
progressData: (progressDataVal is List)
|
||||
? progressDataVal
|
||||
.map((e) => WritingProgressData.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList()
|
||||
: <WritingProgressData>[],
|
||||
skillAnalysis: skillAnalysisVal is Map<String, dynamic>
|
||||
? WritingSkillAnalysis.fromJson(skillAnalysisVal)
|
||||
: WritingSkillAnalysis(
|
||||
criteriaScores: {
|
||||
'grammar': _normalize01(_asDouble(json['average_grammar_score'])),
|
||||
'coherence': _normalize01(_asDouble(json['average_coherence_score'])),
|
||||
'vocab': _normalize01(_asDouble(json['average_vocab_score'])),
|
||||
},
|
||||
errorCounts: {},
|
||||
strengths: const [],
|
||||
weaknesses: const [],
|
||||
recommendations: const [],
|
||||
improvementRate: 0.0,
|
||||
lastAnalyzed: DateTime.now(),
|
||||
),
|
||||
lastUpdated: lastUpdatedVal is String
|
||||
? DateTime.parse(lastUpdatedVal)
|
||||
: DateTime.now(),
|
||||
);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'totalTasks': totalTasks,
|
||||
'completedTasks': completedTasks,
|
||||
'totalWords': totalWords,
|
||||
'totalTimeSpent': totalTimeSpent,
|
||||
'averageScore': averageScore,
|
||||
'taskTypeStats': taskTypeStats,
|
||||
'difficultyStats': difficultyStats,
|
||||
'progressData': progressData.map((e) => e.toJson()).toList(),
|
||||
'skillAnalysis': skillAnalysis.toJson(),
|
||||
'lastUpdated': lastUpdated.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
WritingStats copyWith({
|
||||
String? userId,
|
||||
int? totalTasks,
|
||||
int? completedTasks,
|
||||
int? totalWords,
|
||||
int? totalTimeSpent,
|
||||
double? averageScore,
|
||||
Map<String, int>? taskTypeStats,
|
||||
Map<String, int>? difficultyStats,
|
||||
List<WritingProgressData>? progressData,
|
||||
WritingSkillAnalysis? skillAnalysis,
|
||||
DateTime? lastUpdated,
|
||||
}) {
|
||||
return WritingStats(
|
||||
userId: userId ?? this.userId,
|
||||
totalTasks: totalTasks ?? this.totalTasks,
|
||||
completedTasks: completedTasks ?? this.completedTasks,
|
||||
totalWords: totalWords ?? this.totalWords,
|
||||
totalTimeSpent: totalTimeSpent ?? this.totalTimeSpent,
|
||||
averageScore: averageScore ?? this.averageScore,
|
||||
taskTypeStats: taskTypeStats ?? this.taskTypeStats,
|
||||
difficultyStats: difficultyStats ?? this.difficultyStats,
|
||||
progressData: progressData ?? this.progressData,
|
||||
skillAnalysis: skillAnalysis ?? this.skillAnalysis,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WritingProgressData {
|
||||
final DateTime date;
|
||||
final double score;
|
||||
final int wordCount;
|
||||
final int timeSpent;
|
||||
final String taskType;
|
||||
final String difficulty;
|
||||
|
||||
const WritingProgressData({
|
||||
required this.date,
|
||||
required this.score,
|
||||
required this.wordCount,
|
||||
required this.timeSpent,
|
||||
required this.taskType,
|
||||
required this.difficulty,
|
||||
});
|
||||
|
||||
factory WritingProgressData.fromJson(Map<String, dynamic> json) {
|
||||
return WritingProgressData(
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
score: (json['score'] as num).toDouble(),
|
||||
wordCount: json['wordCount'] as int,
|
||||
timeSpent: json['timeSpent'] as int,
|
||||
taskType: json['taskType'] as String,
|
||||
difficulty: json['difficulty'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'date': date.toIso8601String(),
|
||||
'score': score,
|
||||
'wordCount': wordCount,
|
||||
'timeSpent': timeSpent,
|
||||
'taskType': taskType,
|
||||
'difficulty': difficulty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WritingSkillAnalysis {
|
||||
final Map<String, double> criteriaScores;
|
||||
final Map<String, int> errorCounts;
|
||||
final List<String> strengths;
|
||||
final List<String> weaknesses;
|
||||
final List<String> recommendations;
|
||||
final double improvementRate;
|
||||
final DateTime lastAnalyzed;
|
||||
|
||||
const WritingSkillAnalysis({
|
||||
required this.criteriaScores,
|
||||
required this.errorCounts,
|
||||
required this.strengths,
|
||||
required this.weaknesses,
|
||||
required this.recommendations,
|
||||
required this.improvementRate,
|
||||
required this.lastAnalyzed,
|
||||
});
|
||||
|
||||
factory WritingSkillAnalysis.fromJson(Map<String, dynamic> json) {
|
||||
return WritingSkillAnalysis(
|
||||
criteriaScores: Map<String, double>.from(
|
||||
(json['criteriaScores'] as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(k, (v as num).toDouble()),
|
||||
),
|
||||
),
|
||||
errorCounts: Map<String, int>.from(json['errorCounts'] ?? {}),
|
||||
strengths: List<String>.from(json['strengths'] ?? []),
|
||||
weaknesses: List<String>.from(json['weaknesses'] ?? []),
|
||||
recommendations: List<String>.from(json['recommendations'] ?? []),
|
||||
improvementRate: (json['improvementRate'] as num).toDouble(),
|
||||
lastAnalyzed: DateTime.parse(json['lastAnalyzed'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'criteriaScores': criteriaScores,
|
||||
'errorCounts': errorCounts,
|
||||
'strengths': strengths,
|
||||
'weaknesses': weaknesses,
|
||||
'recommendations': recommendations,
|
||||
'improvementRate': improvementRate,
|
||||
'lastAnalyzed': lastAnalyzed.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
int _asInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) {
|
||||
final parsed = double.tryParse(v);
|
||||
return parsed?.toInt() ?? int.tryParse(v) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
double _asDouble(dynamic v) {
|
||||
if (v == null) return 0.0;
|
||||
if (v is double) return v;
|
||||
if (v is num) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double _normalize01(double v) {
|
||||
if (v <= 1.0) return v;
|
||||
return v / 100.0;
|
||||
}
|
||||
408
client/lib/features/writing/models/writing_submission.dart
Normal file
408
client/lib/features/writing/models/writing_submission.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'writing_task.dart';
|
||||
|
||||
class WritingSubmission {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String userId;
|
||||
final String content;
|
||||
final int wordCount;
|
||||
final int timeSpent; // 秒
|
||||
final WritingStatus status;
|
||||
final DateTime submittedAt;
|
||||
final WritingFeedback? feedback;
|
||||
final WritingScore? score;
|
||||
|
||||
const WritingSubmission({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.userId,
|
||||
required this.content,
|
||||
required this.wordCount,
|
||||
required this.timeSpent,
|
||||
required this.status,
|
||||
required this.submittedAt,
|
||||
this.feedback,
|
||||
this.score,
|
||||
});
|
||||
|
||||
factory WritingSubmission.fromJson(Map<String, dynamic> json) {
|
||||
return WritingSubmission(
|
||||
id: json['id'] as String,
|
||||
taskId: json['taskId'] as String,
|
||||
userId: json['userId'] as String,
|
||||
content: json['content'] as String,
|
||||
wordCount: json['wordCount'] as int,
|
||||
timeSpent: json['timeSpent'] as int,
|
||||
status: WritingStatus.values.firstWhere(
|
||||
(e) => e.name == json['status'],
|
||||
orElse: () => WritingStatus.draft,
|
||||
),
|
||||
submittedAt: DateTime.parse(json['submittedAt'] as String),
|
||||
feedback: json['feedback'] != null
|
||||
? WritingFeedback.fromJson(json['feedback'] as Map<String, dynamic>)
|
||||
: null,
|
||||
score: json['score'] != null
|
||||
? WritingScore.fromJson(json['score'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'taskId': taskId,
|
||||
'userId': userId,
|
||||
'content': content,
|
||||
'wordCount': wordCount,
|
||||
'timeSpent': timeSpent,
|
||||
'status': status.name,
|
||||
'submittedAt': submittedAt.toIso8601String(),
|
||||
'feedback': feedback?.toJson(),
|
||||
'score': score?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
WritingSubmission copyWith({
|
||||
String? id,
|
||||
String? taskId,
|
||||
String? userId,
|
||||
String? content,
|
||||
int? wordCount,
|
||||
int? timeSpent,
|
||||
WritingStatus? status,
|
||||
DateTime? submittedAt,
|
||||
WritingFeedback? feedback,
|
||||
WritingScore? score,
|
||||
}) {
|
||||
return WritingSubmission(
|
||||
id: id ?? this.id,
|
||||
taskId: taskId ?? this.taskId,
|
||||
userId: userId ?? this.userId,
|
||||
content: content ?? this.content,
|
||||
wordCount: wordCount ?? this.wordCount,
|
||||
timeSpent: timeSpent ?? this.timeSpent,
|
||||
status: status ?? this.status,
|
||||
submittedAt: submittedAt ?? this.submittedAt,
|
||||
feedback: feedback ?? this.feedback,
|
||||
score: score ?? this.score,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WritingFeedback {
|
||||
final String id;
|
||||
final String submissionId;
|
||||
final String overallComment;
|
||||
final List<WritingCriteriaFeedback> criteriaFeedbacks;
|
||||
final List<WritingError> errors;
|
||||
final List<WritingSuggestion> suggestions;
|
||||
final DateTime createdAt;
|
||||
|
||||
const WritingFeedback({
|
||||
required this.id,
|
||||
required this.submissionId,
|
||||
required this.overallComment,
|
||||
required this.criteriaFeedbacks,
|
||||
required this.errors,
|
||||
required this.suggestions,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory WritingFeedback.fromJson(Map<String, dynamic> json) {
|
||||
return WritingFeedback(
|
||||
id: json['id'] as String,
|
||||
submissionId: json['submissionId'] as String,
|
||||
overallComment: json['overallComment'] as String,
|
||||
criteriaFeedbacks: (json['criteriaFeedbacks'] as List<dynamic>)
|
||||
.map((e) => WritingCriteriaFeedback.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
errors: (json['errors'] as List<dynamic>)
|
||||
.map((e) => WritingError.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
suggestions: (json['suggestions'] as List<dynamic>)
|
||||
.map((e) => WritingSuggestion.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'submissionId': submissionId,
|
||||
'overallComment': overallComment,
|
||||
'criteriaFeedbacks': criteriaFeedbacks.map((e) => e.toJson()).toList(),
|
||||
'errors': errors.map((e) => e.toJson()).toList(),
|
||||
'suggestions': suggestions.map((e) => e.toJson()).toList(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WritingScore {
|
||||
final String id;
|
||||
final String submissionId;
|
||||
final double totalScore;
|
||||
final double maxScore;
|
||||
final Map<WritingCriteria, double> criteriaScores;
|
||||
final String grade;
|
||||
final DateTime createdAt;
|
||||
|
||||
const WritingScore({
|
||||
required this.id,
|
||||
required this.submissionId,
|
||||
required this.totalScore,
|
||||
required this.maxScore,
|
||||
required this.criteriaScores,
|
||||
required this.grade,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
double get percentage => (totalScore / maxScore) * 100;
|
||||
|
||||
factory WritingScore.fromJson(Map<String, dynamic> json) {
|
||||
return WritingScore(
|
||||
id: json['id'] as String,
|
||||
submissionId: json['submissionId'] as String,
|
||||
totalScore: (json['totalScore'] as num).toDouble(),
|
||||
maxScore: (json['maxScore'] as num).toDouble(),
|
||||
criteriaScores: Map<WritingCriteria, double>.fromEntries(
|
||||
(json['criteriaScores'] as Map<String, dynamic>).entries.map(
|
||||
(e) => MapEntry(
|
||||
WritingCriteria.values.firstWhere((c) => c.name == e.key),
|
||||
(e.value as num).toDouble(),
|
||||
),
|
||||
),
|
||||
),
|
||||
grade: json['grade'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'submissionId': submissionId,
|
||||
'totalScore': totalScore,
|
||||
'maxScore': maxScore,
|
||||
'criteriaScores': Map<String, dynamic>.fromEntries(
|
||||
criteriaScores.entries.map(
|
||||
(e) => MapEntry(e.key.name, e.value),
|
||||
),
|
||||
),
|
||||
'grade': grade,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WritingCriteriaFeedback {
|
||||
final WritingCriteria criteria;
|
||||
final double score;
|
||||
final double maxScore;
|
||||
final String comment;
|
||||
final List<String> strengths;
|
||||
final List<String> improvements;
|
||||
|
||||
const WritingCriteriaFeedback({
|
||||
required this.criteria,
|
||||
required this.score,
|
||||
required this.maxScore,
|
||||
required this.comment,
|
||||
required this.strengths,
|
||||
required this.improvements,
|
||||
});
|
||||
|
||||
factory WritingCriteriaFeedback.fromJson(Map<String, dynamic> json) {
|
||||
return WritingCriteriaFeedback(
|
||||
criteria: WritingCriteria.values.firstWhere(
|
||||
(e) => e.name == json['criteria'],
|
||||
),
|
||||
score: (json['score'] as num).toDouble(),
|
||||
maxScore: (json['maxScore'] as num).toDouble(),
|
||||
comment: json['comment'] as String,
|
||||
strengths: List<String>.from(json['strengths'] ?? []),
|
||||
improvements: List<String>.from(json['improvements'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'criteria': criteria.name,
|
||||
'score': score,
|
||||
'maxScore': maxScore,
|
||||
'comment': comment,
|
||||
'strengths': strengths,
|
||||
'improvements': improvements,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WritingError {
|
||||
final WritingErrorType type;
|
||||
final String description;
|
||||
final String originalText;
|
||||
final String? suggestedText;
|
||||
final int startPosition;
|
||||
final int endPosition;
|
||||
final String explanation;
|
||||
|
||||
const WritingError({
|
||||
required this.type,
|
||||
required this.description,
|
||||
required this.originalText,
|
||||
this.suggestedText,
|
||||
required this.startPosition,
|
||||
required this.endPosition,
|
||||
required this.explanation,
|
||||
});
|
||||
|
||||
factory WritingError.fromJson(Map<String, dynamic> json) {
|
||||
return WritingError(
|
||||
type: WritingErrorType.values.firstWhere(
|
||||
(e) => e.name == json['type'],
|
||||
),
|
||||
description: json['description'] as String,
|
||||
originalText: json['originalText'] as String,
|
||||
suggestedText: json['suggestedText'] as String?,
|
||||
startPosition: json['startPosition'] as int,
|
||||
endPosition: json['endPosition'] as int,
|
||||
explanation: json['explanation'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'description': description,
|
||||
'originalText': originalText,
|
||||
'suggestedText': suggestedText,
|
||||
'startPosition': startPosition,
|
||||
'endPosition': endPosition,
|
||||
'explanation': explanation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WritingSuggestion {
|
||||
final WritingSuggestionType type;
|
||||
final String title;
|
||||
final String description;
|
||||
final String? example;
|
||||
final int? position;
|
||||
|
||||
const WritingSuggestion({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.example,
|
||||
this.position,
|
||||
});
|
||||
|
||||
factory WritingSuggestion.fromJson(Map<String, dynamic> json) {
|
||||
return WritingSuggestion(
|
||||
type: WritingSuggestionType.values.firstWhere(
|
||||
(e) => e.name == json['type'],
|
||||
),
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
example: json['example'] as String?,
|
||||
position: json['position'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'example': example,
|
||||
'position': position,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum WritingStatus {
|
||||
draft,
|
||||
submitted,
|
||||
grading,
|
||||
graded,
|
||||
revised,
|
||||
}
|
||||
|
||||
enum WritingCriteria {
|
||||
content,
|
||||
organization,
|
||||
vocabulary,
|
||||
grammar,
|
||||
mechanics,
|
||||
}
|
||||
|
||||
enum WritingErrorType {
|
||||
grammar,
|
||||
spelling,
|
||||
punctuation,
|
||||
vocabulary,
|
||||
structure,
|
||||
style,
|
||||
}
|
||||
|
||||
enum WritingSuggestionType {
|
||||
improvement,
|
||||
enhancement,
|
||||
alternative,
|
||||
clarification,
|
||||
}
|
||||
|
||||
extension WritingStatusExtension on WritingStatus {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case WritingStatus.draft:
|
||||
return '草稿';
|
||||
case WritingStatus.submitted:
|
||||
return '已提交';
|
||||
case WritingStatus.grading:
|
||||
return '评分中';
|
||||
case WritingStatus.graded:
|
||||
return '已评分';
|
||||
case WritingStatus.revised:
|
||||
return '已修改';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WritingCriteriaExtension on WritingCriteria {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case WritingCriteria.content:
|
||||
return '内容';
|
||||
case WritingCriteria.organization:
|
||||
return '结构';
|
||||
case WritingCriteria.vocabulary:
|
||||
return '词汇';
|
||||
case WritingCriteria.grammar:
|
||||
return '语法';
|
||||
case WritingCriteria.mechanics:
|
||||
return '拼写标点';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WritingErrorTypeExtension on WritingErrorType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case WritingErrorType.grammar:
|
||||
return '语法错误';
|
||||
case WritingErrorType.spelling:
|
||||
return '拼写错误';
|
||||
case WritingErrorType.punctuation:
|
||||
return '标点错误';
|
||||
case WritingErrorType.vocabulary:
|
||||
return '词汇错误';
|
||||
case WritingErrorType.structure:
|
||||
return '结构错误';
|
||||
case WritingErrorType.style:
|
||||
return '风格问题';
|
||||
}
|
||||
}
|
||||
}
|
||||
267
client/lib/features/writing/models/writing_task.dart
Normal file
267
client/lib/features/writing/models/writing_task.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
class WritingTask {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final WritingType type;
|
||||
final WritingDifficulty difficulty;
|
||||
final int timeLimit; // 分钟
|
||||
final int wordLimit;
|
||||
final List<String> keywords;
|
||||
final List<String> requirements;
|
||||
final String? prompt;
|
||||
final String? imageUrl;
|
||||
final ExamType? examType; // 考试类型
|
||||
final DateTime createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const WritingTask({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.type,
|
||||
required this.difficulty,
|
||||
required this.timeLimit,
|
||||
required this.wordLimit,
|
||||
required this.keywords,
|
||||
required this.requirements,
|
||||
this.prompt,
|
||||
this.imageUrl,
|
||||
this.examType,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory WritingTask.fromJson(Map<String, dynamic> json) {
|
||||
final idVal = json['id'] ?? json['prompt_id'];
|
||||
final typeVal = json['type'];
|
||||
final difficultyVal = json['difficulty'];
|
||||
final timeLimitVal = json['timeLimit'] ?? json['time_limit'];
|
||||
final wordLimitVal = json['wordLimit'] ?? json['word_limit'];
|
||||
final examTypeVal = json['examType'] ?? json['exam_type'];
|
||||
final createdAtVal = json['createdAt'] ?? json['created_at'];
|
||||
final updatedAtVal = json['updatedAt'] ?? json['updated_at'];
|
||||
|
||||
return WritingTask(
|
||||
id: (idVal ?? '').toString(),
|
||||
title: (json['title'] ?? '').toString(),
|
||||
description: (json['description'] ?? '').toString(),
|
||||
type: typeVal is String
|
||||
? WritingType.values.firstWhere(
|
||||
(e) => e.name == typeVal,
|
||||
orElse: () => WritingType.essay,
|
||||
)
|
||||
: WritingType.essay,
|
||||
difficulty: difficultyVal is String
|
||||
? WritingDifficulty.values.firstWhere(
|
||||
(e) => e.name == difficultyVal,
|
||||
orElse: () => WritingDifficulty.intermediate,
|
||||
)
|
||||
: WritingDifficulty.intermediate,
|
||||
timeLimit: _asInt(timeLimitVal),
|
||||
wordLimit: _asInt(wordLimitVal),
|
||||
keywords: List<String>.from(json['keywords'] ?? []),
|
||||
requirements: List<String>.from(json['requirements'] ?? []),
|
||||
prompt: json['prompt'] as String?,
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
examType: examTypeVal is String
|
||||
? ExamType.values.firstWhere(
|
||||
(e) => e.name == examTypeVal,
|
||||
orElse: () => ExamType.cet,
|
||||
)
|
||||
: null,
|
||||
createdAt: _parseDate(createdAtVal),
|
||||
updatedAt: updatedAtVal != null ? _parseDate(updatedAtVal) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'type': type.name,
|
||||
'difficulty': difficulty.name,
|
||||
'timeLimit': timeLimit,
|
||||
'wordLimit': wordLimit,
|
||||
'keywords': keywords,
|
||||
'requirements': requirements,
|
||||
'prompt': prompt,
|
||||
'imageUrl': imageUrl,
|
||||
'examType': examType?.name,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
WritingTask copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
WritingType? type,
|
||||
WritingDifficulty? difficulty,
|
||||
int? timeLimit,
|
||||
int? wordLimit,
|
||||
List<String>? keywords,
|
||||
List<String>? requirements,
|
||||
String? prompt,
|
||||
String? imageUrl,
|
||||
ExamType? examType,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return WritingTask(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
type: type ?? this.type,
|
||||
difficulty: difficulty ?? this.difficulty,
|
||||
timeLimit: timeLimit ?? this.timeLimit,
|
||||
wordLimit: wordLimit ?? this.wordLimit,
|
||||
keywords: keywords ?? this.keywords,
|
||||
requirements: requirements ?? this.requirements,
|
||||
prompt: prompt ?? this.prompt,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
examType: examType ?? this.examType,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum WritingType {
|
||||
essay,
|
||||
letter,
|
||||
email,
|
||||
report,
|
||||
story,
|
||||
review,
|
||||
article,
|
||||
diary,
|
||||
description,
|
||||
argument,
|
||||
}
|
||||
|
||||
enum WritingDifficulty {
|
||||
beginner,
|
||||
elementary,
|
||||
intermediate,
|
||||
upperIntermediate,
|
||||
advanced,
|
||||
}
|
||||
|
||||
extension WritingTypeExtension on WritingType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case WritingType.essay:
|
||||
return '议论文';
|
||||
case WritingType.letter:
|
||||
return '书信';
|
||||
case WritingType.email:
|
||||
return '邮件';
|
||||
case WritingType.report:
|
||||
return '报告';
|
||||
case WritingType.story:
|
||||
return '故事';
|
||||
case WritingType.review:
|
||||
return '评论';
|
||||
case WritingType.article:
|
||||
return '文章';
|
||||
case WritingType.diary:
|
||||
return '日记';
|
||||
case WritingType.description:
|
||||
return '描述文';
|
||||
case WritingType.argument:
|
||||
return '辩论文';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WritingDifficultyExtension on WritingDifficulty {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case WritingDifficulty.beginner:
|
||||
return '初级';
|
||||
case WritingDifficulty.elementary:
|
||||
return '基础';
|
||||
case WritingDifficulty.intermediate:
|
||||
return '中级';
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return '中高级';
|
||||
case WritingDifficulty.advanced:
|
||||
return '高级';
|
||||
}
|
||||
}
|
||||
|
||||
int get level {
|
||||
switch (this) {
|
||||
case WritingDifficulty.beginner:
|
||||
return 1;
|
||||
case WritingDifficulty.elementary:
|
||||
return 2;
|
||||
case WritingDifficulty.intermediate:
|
||||
return 3;
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return 4;
|
||||
case WritingDifficulty.advanced:
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExamType {
|
||||
cet, // 四六级
|
||||
kaoyan, // 考研
|
||||
toefl, // 托福
|
||||
ielts, // 雅思
|
||||
}
|
||||
|
||||
extension ExamTypeExtension on ExamType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ExamType.cet:
|
||||
return '四六级';
|
||||
case ExamType.kaoyan:
|
||||
return '考研';
|
||||
case ExamType.toefl:
|
||||
return '托福';
|
||||
case ExamType.ielts:
|
||||
return '雅思';
|
||||
}
|
||||
}
|
||||
|
||||
String get description {
|
||||
switch (this) {
|
||||
case ExamType.cet:
|
||||
return '大学英语四六级考试写作';
|
||||
case ExamType.kaoyan:
|
||||
return '研究生入学考试英语写作';
|
||||
case ExamType.toefl:
|
||||
return 'TOEFL托福考试写作';
|
||||
case ExamType.ielts:
|
||||
return 'IELTS雅思考试写作';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _asInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) {
|
||||
final parsed = double.tryParse(v);
|
||||
return parsed?.toInt() ?? int.tryParse(v) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime _parseDate(dynamic v) {
|
||||
if (v is String) {
|
||||
try {
|
||||
return DateTime.parse(v);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (v is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(v);
|
||||
}
|
||||
return DateTime.now();
|
||||
}
|
||||
117
client/lib/features/writing/providers/writing_provider.dart
Normal file
117
client/lib/features/writing/providers/writing_provider.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../models/writing_submission.dart';
|
||||
import '../services/writing_service.dart';
|
||||
|
||||
/// 写作任务状态
|
||||
class WritingTasksState {
|
||||
final List<WritingTask> tasks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
WritingTasksState({
|
||||
this.tasks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
WritingTasksState copyWith({
|
||||
List<WritingTask>? tasks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return WritingTasksState(
|
||||
tasks: tasks ?? this.tasks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 写作任务 Notifier
|
||||
class WritingTasksNotifier extends StateNotifier<WritingTasksState> {
|
||||
final WritingService _writingService;
|
||||
|
||||
WritingTasksNotifier(this._writingService) : super(WritingTasksState());
|
||||
|
||||
/// 加载写作任务列表
|
||||
Future<void> loadTasks({
|
||||
WritingType? type,
|
||||
WritingDifficulty? difficulty,
|
||||
int page = 1,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final tasks = await _writingService.getWritingTasks(
|
||||
type: type,
|
||||
difficulty: difficulty,
|
||||
page: page,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
|
||||
state = state.copyWith(tasks: tasks, isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取推荐任务
|
||||
Future<void> loadRecommendedTasks(String userId, {bool forceRefresh = false}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final tasks = await _writingService.getRecommendedWritingTasks(
|
||||
userId: userId,
|
||||
limit: 5,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
|
||||
state = state.copyWith(tasks: tasks, isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写作服务 Provider
|
||||
final writingServiceProvider = Provider<WritingService>((ref) {
|
||||
return WritingService();
|
||||
});
|
||||
|
||||
/// 写作任务列表 Provider
|
||||
final writingTasksProvider = StateNotifierProvider<WritingTasksNotifier, WritingTasksState>((ref) {
|
||||
final service = ref.watch(writingServiceProvider);
|
||||
return WritingTasksNotifier(service);
|
||||
});
|
||||
|
||||
/// 按考试类型获取写作任务
|
||||
final examWritingTasksProvider = FutureProvider.family<List<WritingTask>, ExamType>((ref, examType) async {
|
||||
final service = ref.watch(writingServiceProvider);
|
||||
|
||||
try {
|
||||
// 从后端获取所有任务,然后在前端过滤
|
||||
final tasks = await service.getWritingTasks(limit: 100);
|
||||
return tasks.where((task) => task.examType == examType).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
/// 用户写作历史 Provider
|
||||
final userWritingHistoryProvider = FutureProvider.family<List<WritingSubmission>, String>((ref, userId) async {
|
||||
final service = ref.watch(writingServiceProvider);
|
||||
|
||||
try {
|
||||
return await service.getUserWritingHistory(userId: userId, limit: 3);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
388
client/lib/features/writing/screens/exam_writing_screen.dart
Normal file
388
client/lib/features/writing/screens/exam_writing_screen.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
import 'writing_detail_screen.dart';
|
||||
|
||||
/// 考试写作页面
|
||||
class ExamWritingScreen extends ConsumerStatefulWidget {
|
||||
final ExamType? examType;
|
||||
final String title;
|
||||
|
||||
const ExamWritingScreen({
|
||||
super.key,
|
||||
this.examType,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExamWritingScreen> createState() => _ExamWritingScreenState();
|
||||
}
|
||||
|
||||
class _ExamWritingScreenState extends ConsumerState<ExamWritingScreen> {
|
||||
ExamType? selectedExamType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectedExamType = widget.examType;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (widget.examType == null) _buildExamTypeFilter(),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (selectedExamType != null) {
|
||||
final tasksAsync = ref.watch(examWritingTasksProvider(selectedExamType!));
|
||||
return tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) return _buildEmptyState();
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
return _buildTaskCard(task);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.grey[400]),
|
||||
const SizedBox(height: 12),
|
||||
Text('加载失败', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(examWritingTasksProvider(selectedExamType!));
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final service = ref.watch(writingServiceProvider);
|
||||
return FutureBuilder<List<WritingTask>>(
|
||||
future: service.getWritingTasks(limit: 100),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.grey[400]),
|
||||
const SizedBox(height: 12),
|
||||
Text('加载失败', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final allTasks = snapshot.data ?? [];
|
||||
final examTasks = allTasks.where((t) => t.examType != null).toList();
|
||||
if (examTasks.isEmpty) return _buildEmptyState();
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: examTasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = examTasks[index];
|
||||
return _buildTaskCard(task);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExamTypeFilter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'选择考试类型',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', null),
|
||||
const SizedBox(width: 8),
|
||||
...ExamType.values.map((type) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildFilterChip(type.displayName, type),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(String label, ExamType? type) {
|
||||
final isSelected = selectedExamType == type;
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
selectedExamType = type;
|
||||
});
|
||||
},
|
||||
backgroundColor: Colors.grey[100],
|
||||
selectedColor: const Color(0xFF2196F3).withOpacity(0.2),
|
||||
checkmarkColor: const Color(0xFF2196F3),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[700],
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.school_outlined,
|
||||
size: 80,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无考试写作题目',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请选择其他考试类型或稍后再试',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(WritingTask task) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WritingDetailScreen(task: task),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getExamTypeColor(task.examType!),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
task.examType!.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
task.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
Icons.access_time,
|
||||
'${task.timeLimit}分钟',
|
||||
const Color(0xFF4CAF50),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildInfoChip(
|
||||
Icons.text_fields,
|
||||
'${task.wordLimit}词',
|
||||
const Color(0xFFFF9800),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildInfoChip(
|
||||
Icons.star,
|
||||
'${task.difficulty.level}级',
|
||||
_getDifficultyColor(task.difficulty),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (task.keywords.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: task.keywords.take(3).map((keyword) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
keyword,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(IconData icon, String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(WritingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case WritingDifficulty.beginner:
|
||||
return const Color(0xFF4CAF50);
|
||||
case WritingDifficulty.elementary:
|
||||
return const Color(0xFF8BC34A);
|
||||
case WritingDifficulty.intermediate:
|
||||
return const Color(0xFFFF9800);
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return const Color(0xFFFF5722);
|
||||
case WritingDifficulty.advanced:
|
||||
return const Color(0xFFF44336);
|
||||
}
|
||||
}
|
||||
|
||||
Color _getExamTypeColor(ExamType examType) {
|
||||
switch (examType) {
|
||||
case ExamType.cet:
|
||||
return const Color(0xFF2196F3);
|
||||
case ExamType.kaoyan:
|
||||
return const Color(0xFFF44336);
|
||||
case ExamType.toefl:
|
||||
return const Color(0xFF4CAF50);
|
||||
case ExamType.ielts:
|
||||
return const Color(0xFF9C27B0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/writing_task.dart';
|
||||
|
||||
class WritingFilterBar extends StatelessWidget {
|
||||
final WritingType? selectedType;
|
||||
final WritingDifficulty? selectedDifficulty;
|
||||
final String? sortBy;
|
||||
final bool isAscending;
|
||||
final Function(WritingType?) onTypeChanged;
|
||||
final Function(WritingDifficulty?) onDifficultyChanged;
|
||||
final Function(String) onSortChanged;
|
||||
final VoidCallback onClearFilters;
|
||||
|
||||
const WritingFilterBar({
|
||||
super.key,
|
||||
this.selectedType,
|
||||
this.selectedDifficulty,
|
||||
this.sortBy,
|
||||
this.isAscending = true,
|
||||
required this.onTypeChanged,
|
||||
required this.onDifficultyChanged,
|
||||
required this.onSortChanged,
|
||||
required this.onClearFilters,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'筛选条件',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: onClearFilters,
|
||||
child: const Text('清除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTypeFilter(),
|
||||
const SizedBox(width: 12),
|
||||
_buildDifficultyFilter(),
|
||||
const SizedBox(width: 12),
|
||||
_buildSortFilter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeFilter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: selectedType != null ? Colors.blue[50] : Colors.white,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<WritingType?>(
|
||||
value: selectedType,
|
||||
hint: const Text(
|
||||
'类型',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
isDense: true,
|
||||
items: [
|
||||
const DropdownMenuItem<WritingType?>(
|
||||
value: null,
|
||||
child: Text('全部类型'),
|
||||
),
|
||||
...WritingType.values.map((type) {
|
||||
return DropdownMenuItem<WritingType?>(
|
||||
value: type,
|
||||
child: Text(type.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: onTypeChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDifficultyFilter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: selectedDifficulty != null ? Colors.orange[50] : Colors.white,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<WritingDifficulty?>(
|
||||
value: selectedDifficulty,
|
||||
hint: const Text(
|
||||
'难度',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
isDense: true,
|
||||
items: [
|
||||
const DropdownMenuItem<WritingDifficulty?>(
|
||||
value: null,
|
||||
child: Text('全部难度'),
|
||||
),
|
||||
...WritingDifficulty.values.map((difficulty) {
|
||||
return DropdownMenuItem<WritingDifficulty?>(
|
||||
value: difficulty,
|
||||
child: Text(difficulty.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: onDifficultyChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortFilter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: sortBy != null ? Colors.green[50] : Colors.white,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: sortBy,
|
||||
hint: const Text(
|
||||
'排序',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
isDense: true,
|
||||
items: const [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'createdAt',
|
||||
child: Text('创建时间'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'difficulty',
|
||||
child: Text('难度'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'timeLimit',
|
||||
child: Text('时间限制'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'wordLimit',
|
||||
child: Text('字数限制'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
onSortChanged(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/writing_stats.dart';
|
||||
|
||||
class WritingStatsCard extends StatelessWidget {
|
||||
final WritingStats stats;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const WritingStatsCard({
|
||||
super.key,
|
||||
required this.stats,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作统计',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'完成任务',
|
||||
'${stats.completedTasks}',
|
||||
Icons.task_alt,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'总字数',
|
||||
'${stats.totalWords}',
|
||||
Icons.text_fields,
|
||||
Colors.blue,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'平均分',
|
||||
'${stats.averageScore.toStringAsFixed(1)}',
|
||||
Icons.star,
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (stats.taskTypeStats.isNotEmpty) ...[
|
||||
const Text(
|
||||
'任务类型分布',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: stats.taskTypeStats.entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
entry.key,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
entry.value.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (stats.difficultyStats.isNotEmpty) ...[
|
||||
const Text(
|
||||
'难度分布',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: stats.difficultyStats.entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
entry.key,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
entry.value.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (stats.skillAnalysis.criteriaScores.isNotEmpty) ...[
|
||||
const Text(
|
||||
'技能分析',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: [
|
||||
...stats.skillAnalysis.criteriaScores.entries.map((entry) =>
|
||||
_buildSkillItem(entry.key, entry.value)
|
||||
).toList(),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(
|
||||
String label,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkillItem(String skill, double score) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
skill,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: LinearProgressIndicator(
|
||||
value: score / 100,
|
||||
backgroundColor: Colors.grey[300],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getScoreColor(score),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${score.toStringAsFixed(1)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getScoreColor(double score) {
|
||||
if (score >= 80) return Colors.green;
|
||||
if (score >= 60) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
439
client/lib/features/writing/screens/writing_detail_screen.dart
Normal file
439
client/lib/features/writing/screens/writing_detail_screen.dart
Normal file
@@ -0,0 +1,439 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import 'writing_exercise_screen.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
|
||||
/// 写作练习详情页面
|
||||
class WritingDetailScreen extends ConsumerWidget {
|
||||
final WritingTask task;
|
||||
|
||||
const WritingDetailScreen({
|
||||
super.key,
|
||||
required this.task,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
title: const Text('写作详情'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTaskHeader(),
|
||||
const SizedBox(height: 20),
|
||||
_buildTaskInfo(),
|
||||
const SizedBox(height: 20),
|
||||
_buildPrompt(),
|
||||
const SizedBox(height: 20),
|
||||
_buildRequirements(),
|
||||
if (task.keywords.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildKeywords(),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
_buildStartButton(context, ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(task.difficulty),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
task.difficulty.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
task.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'任务信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
Icons.category,
|
||||
'类型',
|
||||
task.type.displayName,
|
||||
const Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
Icons.timer,
|
||||
'时间限制',
|
||||
'${task.timeLimit}分钟',
|
||||
const Color(0xFF4CAF50),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
Icons.text_fields,
|
||||
'字数要求',
|
||||
'${task.wordLimit}词',
|
||||
const Color(0xFFFF9800),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
Icons.star,
|
||||
'难度等级',
|
||||
'${task.difficulty.level}级',
|
||||
_getDifficultyColor(task.difficulty),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(IconData icon, String label, String value, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrompt() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作提示',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
task.prompt ?? '暂无写作提示',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequirements() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作要求',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...task.requirements.map((requirement) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF4CAF50),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
requirement,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeywords() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'关键词提示',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: task.keywords.map((keyword) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF9800).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFF9800).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
keyword,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFFFF9800),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStartButton(BuildContext context, WidgetRef ref) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
try {
|
||||
final service = ref.read(writingServiceProvider);
|
||||
final freshTask = await service.getWritingTask(task.id);
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WritingExerciseScreen(task: freshTask),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('加载失败'),
|
||||
content: const Text('无法获取最新任务,稍后重试'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'开始写作',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(WritingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case WritingDifficulty.beginner:
|
||||
return const Color(0xFF4CAF50);
|
||||
case WritingDifficulty.elementary:
|
||||
return const Color(0xFF8BC34A);
|
||||
case WritingDifficulty.intermediate:
|
||||
return const Color(0xFFFF9800);
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return const Color(0xFFFF5722);
|
||||
case WritingDifficulty.advanced:
|
||||
return const Color(0xFFF44336);
|
||||
}
|
||||
}
|
||||
}
|
||||
393
client/lib/features/writing/screens/writing_exercise_screen.dart
Normal file
393
client/lib/features/writing/screens/writing_exercise_screen.dart
Normal file
@@ -0,0 +1,393 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import 'writing_result_screen.dart';
|
||||
|
||||
/// 写作练习页面
|
||||
class WritingExerciseScreen extends StatefulWidget {
|
||||
final WritingTask task;
|
||||
|
||||
const WritingExerciseScreen({
|
||||
super.key,
|
||||
required this.task,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WritingExerciseScreen> createState() => _WritingExerciseScreenState();
|
||||
}
|
||||
|
||||
class _WritingExerciseScreenState extends State<WritingExerciseScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
Timer? _timer;
|
||||
int _remainingSeconds = 0;
|
||||
int _wordCount = 0;
|
||||
bool _isSubmitted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final minutes = widget.task.timeLimit > 0 ? widget.task.timeLimit : 30;
|
||||
_remainingSeconds = minutes * 60;
|
||||
_startTimer();
|
||||
_textController.addListener(_updateWordCount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_remainingSeconds > 0) {
|
||||
setState(() {
|
||||
_remainingSeconds--;
|
||||
});
|
||||
} else {
|
||||
_submitWriting();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateWordCount() {
|
||||
final text = _textController.text.trim();
|
||||
final words = text.isEmpty ? 0 : text.split(RegExp(r'\s+')).length;
|
||||
setState(() {
|
||||
_wordCount = words;
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _submitWriting() {
|
||||
if (_isSubmitted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitted = true;
|
||||
});
|
||||
|
||||
_timer?.cancel();
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WritingResultScreen(
|
||||
task: widget.task,
|
||||
content: _textController.text,
|
||||
wordCount: _wordCount,
|
||||
timeUsed: widget.task.timeLimit * 60 - _remainingSeconds,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSubmitDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('提交写作'),
|
||||
content: Text('确定要提交吗?当前已写${_wordCount}词,剩余时间${_formatTime(_remainingSeconds)}。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('继续写作'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_submitWriting();
|
||||
},
|
||||
child: const Text('确定提交'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.task.title),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
onPressed: _showTaskInfo,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildStatusBar(),
|
||||
Expanded(
|
||||
child: _buildWritingArea(),
|
||||
),
|
||||
_buildBottomBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar() {
|
||||
final timeColor = _remainingSeconds < 300 ? Colors.red : const Color(0xFF2196F3); // 5分钟内显示红色
|
||||
final limit = widget.task.wordLimit > 0 ? widget.task.wordLimit : 200;
|
||||
final wordColor = _wordCount > limit ? Colors.red : const Color(0xFF4CAF50);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatusItem(
|
||||
Icons.timer,
|
||||
'剩余时间',
|
||||
_formatTime(_remainingSeconds),
|
||||
timeColor,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatusItem(
|
||||
Icons.text_fields,
|
||||
'字数统计',
|
||||
'$_wordCount/$limit',
|
||||
wordColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusItem(IconData icon, String label, String value, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWritingArea() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'写作提示:${widget.task.prompt}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '请在此处开始写作...',
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _showTaskInfo,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2196F3),
|
||||
side: const BorderSide(color: Color(0xFF2196F3)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('查看要求'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _wordCount > 0 ? _showSubmitDialog : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'提交写作',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTaskInfo() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'写作要求',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoSection('写作提示', widget.task.prompt ?? '暂无写作提示'),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoSection('写作要求', widget.task.requirements.join('\n• ')),
|
||||
if (widget.task.keywords.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoSection('关键词', widget.task.keywords.join(', ')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoSection(String title, String content) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
content,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/writing_submission.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
|
||||
class WritingHistoryScreen extends StatefulWidget {
|
||||
const WritingHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WritingHistoryScreen> createState() => _WritingHistoryScreenState();
|
||||
}
|
||||
|
||||
class _WritingHistoryScreenState extends State<WritingHistoryScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Provider.of<WritingProvider>(context, listen: false).loadSubmissions();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('写作历史'),
|
||||
),
|
||||
body: Consumer<WritingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(provider.error!),
|
||||
ElevatedButton(
|
||||
onPressed: () => provider.loadSubmissions(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.submissions.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('暂无写作记录'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: provider.submissions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final submission = provider.submissions[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text('写作任务 ${index + 1}'),
|
||||
subtitle: Text(
|
||||
'状态: ${submission.status.displayName}\n'
|
||||
'字数: ${submission.wordCount}\n'
|
||||
'时间: ${_formatDateTime(submission.submittedAt)}',
|
||||
),
|
||||
trailing: submission.score != null
|
||||
? Text(
|
||||
'${submission.score!.totalScore.toStringAsFixed(1)}分',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.month}-${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
690
client/lib/features/writing/screens/writing_home_screen.dart
Normal file
690
client/lib/features/writing/screens/writing_home_screen.dart
Normal file
@@ -0,0 +1,690 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
import '../widgets/writing_mode_card.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../models/writing_record.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
import 'exam_writing_screen.dart';
|
||||
|
||||
/// 写作练习主页面
|
||||
class WritingHomeScreen extends ConsumerStatefulWidget {
|
||||
const WritingHomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WritingHomeScreen> createState() => _WritingHomeScreenState();
|
||||
}
|
||||
|
||||
class _WritingHomeScreenState extends ConsumerState<WritingHomeScreen> {
|
||||
bool _statsForceRefresh = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'写作练习',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildWritingModes(),
|
||||
const SizedBox(height: 20),
|
||||
_buildExamWriting(context),
|
||||
const SizedBox(height: 20),
|
||||
_buildRecentWritings(),
|
||||
const SizedBox(height: 20),
|
||||
_buildWritingProgress(),
|
||||
const SizedBox(height: 100), // 底部导航栏空间
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWritingModes() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作模式',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: WritingModeCard(
|
||||
title: '议论文写作',
|
||||
subtitle: '观点论述练习',
|
||||
icon: Icons.article,
|
||||
color: const Color(0xFF2196F3),
|
||||
type: WritingType.essay,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: WritingModeCard(
|
||||
title: '应用文写作',
|
||||
subtitle: '实用文体练习',
|
||||
icon: Icons.email,
|
||||
color: const Color(0xFF4CAF50),
|
||||
type: WritingType.email,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: WritingModeCard(
|
||||
title: '初级练习',
|
||||
subtitle: '基础写作训练',
|
||||
icon: Icons.school,
|
||||
color: const Color(0xFFFF9800),
|
||||
difficulty: WritingDifficulty.beginner,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: WritingModeCard(
|
||||
title: '高级练习',
|
||||
subtitle: '进阶写作挑战',
|
||||
icon: Icons.star,
|
||||
color: const Color(0xFFF44336),
|
||||
difficulty: WritingDifficulty.advanced,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Widget _buildExamWriting(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'考试写作',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 2.5,
|
||||
children: [
|
||||
_buildExamCard(context, '四六级', Icons.school, Colors.blue, ExamType.cet),
|
||||
_buildExamCard(context, '考研', Icons.menu_book, Colors.red, ExamType.kaoyan),
|
||||
_buildExamCard(context, '托福', Icons.flight_takeoff, Colors.green, ExamType.toefl),
|
||||
_buildExamCard(context, '雅思', Icons.language, Colors.purple, ExamType.ielts),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExamCard(BuildContext context, String title, IconData icon, Color color, ExamType examType) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExamWritingScreen(
|
||||
examType: examType,
|
||||
title: title,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecentWritings() {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final userId = user?.id?.toString() ?? '';
|
||||
final historyAsync = ref.watch(userWritingHistoryProvider(userId));
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'最近写作',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
historyAsync.when(
|
||||
data: (records) {
|
||||
if (records.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'还没有完成的写作',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'完成一篇写作练习后,记录会显示在这里',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: records.take(3).map((record) {
|
||||
// 将WritingSubmission转换为WritingRecord显示
|
||||
final writingRecord = WritingRecord(
|
||||
id: record.id,
|
||||
taskId: record.taskId,
|
||||
taskTitle: '写作任务',
|
||||
taskDescription: '',
|
||||
content: record.content,
|
||||
wordCount: record.wordCount,
|
||||
timeUsed: record.timeSpent,
|
||||
score: record.score?.totalScore.toInt() ?? 0,
|
||||
feedback: record.feedback?.toJson(),
|
||||
completedAt: record.submittedAt,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildWritingRecordItem(writingRecord),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(userWritingHistoryProvider(userId));
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWritingRecordItem(WritingRecord record) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
record.taskTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
record.taskDescription,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
record.formattedDate,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${record.wordCount}词',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
record.formattedTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getScoreColor(record.score),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
record.scoreText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getScoreColor(int score) {
|
||||
if (score >= 90) return const Color(0xFF4CAF50);
|
||||
if (score >= 80) return const Color(0xFF2196F3);
|
||||
if (score >= 70) return const Color(0xFFFF9800);
|
||||
return const Color(0xFFFF5722);
|
||||
}
|
||||
|
||||
Widget _buildWritingItem(
|
||||
String title,
|
||||
String subtitle,
|
||||
String score,
|
||||
String date,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
score,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWritingProgress() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作统计',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final service = ref.watch(writingServiceProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final userId = user?.id?.toString() ?? '';
|
||||
return FutureBuilder(
|
||||
future: service.getUserWritingStats(userId, forceRefresh: _statsForceRefresh),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'统计加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_statsForceRefresh = !_statsForceRefresh;
|
||||
});
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final stats = snapshot.data!;
|
||||
if (stats.completedTasks == 0) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insights,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无写作统计',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_statsForceRefresh = !_statsForceRefresh;
|
||||
});
|
||||
},
|
||||
child: const Text('刷新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildProgressItem('${stats.completedTasks}', '完成篇数', Icons.article),
|
||||
_buildProgressItem('${stats.averageScore.toStringAsFixed(0)}', '平均分', Icons.grade),
|
||||
_buildProgressItem('${((stats.skillAnalysis.criteriaScores['grammar'] ?? 0.0) * 100).toStringAsFixed(0)}%', '语法正确率', Icons.spellcheck),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressItem(String value, String label, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: const Color(0xFF2196F3),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
392
client/lib/features/writing/screens/writing_list_screen.dart
Normal file
392
client/lib/features/writing/screens/writing_list_screen.dart
Normal file
@@ -0,0 +1,392 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
import 'writing_detail_screen.dart';
|
||||
|
||||
/// 写作练习列表页面
|
||||
class WritingListScreen extends ConsumerStatefulWidget {
|
||||
final WritingType? type;
|
||||
final WritingDifficulty? difficulty;
|
||||
final String title;
|
||||
|
||||
const WritingListScreen({
|
||||
super.key,
|
||||
this.type,
|
||||
this.difficulty,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<WritingListScreen> createState() => _WritingListScreenState();
|
||||
}
|
||||
|
||||
class _WritingListScreenState extends ConsumerState<WritingListScreen> {
|
||||
WritingDifficulty? selectedDifficulty;
|
||||
WritingType? selectedType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectedDifficulty = widget.difficulty;
|
||||
selectedType = widget.type;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadTasks();
|
||||
});
|
||||
}
|
||||
|
||||
void _loadTasks() {
|
||||
ref.read(writingTasksProvider.notifier).loadTasks(
|
||||
type: selectedType,
|
||||
difficulty: selectedDifficulty,
|
||||
page: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(writingTasksProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: _showFilterDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (selectedDifficulty != null || selectedType != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
if (selectedDifficulty != null)
|
||||
Chip(
|
||||
label: Text(selectedDifficulty!.displayName),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
selectedDifficulty = null;
|
||||
});
|
||||
_loadTasks();
|
||||
},
|
||||
),
|
||||
if (selectedType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Chip(
|
||||
label: Text(selectedType!.displayName),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
selectedType = null;
|
||||
});
|
||||
_loadTasks();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _loadTasks,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无写作任务',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = state.tasks[index];
|
||||
return _buildTaskCard(task);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(WritingTask task) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WritingDetailScreen(task: task),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(task.difficulty),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
task.difficulty.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
task.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
Icons.category,
|
||||
task.type.displayName,
|
||||
const Color(0xFF2196F3),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(
|
||||
Icons.timer,
|
||||
'${task.timeLimit}分钟',
|
||||
const Color(0xFF4CAF50),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(
|
||||
Icons.text_fields,
|
||||
'${task.wordLimit}词',
|
||||
const Color(0xFFFF9800),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (task.keywords.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: task.keywords.take(3).map((keyword) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
keyword,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(IconData icon, String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(WritingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case WritingDifficulty.beginner:
|
||||
return const Color(0xFF4CAF50);
|
||||
case WritingDifficulty.elementary:
|
||||
return const Color(0xFF8BC34A);
|
||||
case WritingDifficulty.intermediate:
|
||||
return const Color(0xFFFF9800);
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return const Color(0xFFFF5722);
|
||||
case WritingDifficulty.advanced:
|
||||
return const Color(0xFFF44336);
|
||||
}
|
||||
}
|
||||
|
||||
void _showFilterDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('筛选条件'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('难度等级'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: WritingDifficulty.values.map((difficulty) => FilterChip(
|
||||
label: Text(difficulty.displayName),
|
||||
selected: selectedDifficulty == difficulty,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
selectedDifficulty = selected ? difficulty : null;
|
||||
});
|
||||
},
|
||||
)).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('写作类型'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: WritingType.values.map((type) => FilterChip(
|
||||
label: Text(type.displayName),
|
||||
selected: selectedType == type,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
selectedType = selected ? type : null;
|
||||
});
|
||||
},
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selectedDifficulty = null;
|
||||
selectedType = null;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
_loadTasks();
|
||||
},
|
||||
child: const Text('清除'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_loadTasks();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
551
client/lib/features/writing/screens/writing_result_screen.dart
Normal file
551
client/lib/features/writing/screens/writing_result_screen.dart
Normal file
@@ -0,0 +1,551 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../models/writing_record.dart';
|
||||
import '../services/writing_record_service.dart';
|
||||
|
||||
/// 写作结果页面
|
||||
class WritingResultScreen extends StatefulWidget {
|
||||
final WritingTask task;
|
||||
final String content;
|
||||
final int wordCount;
|
||||
final int timeUsed; // 使用的时间(秒)
|
||||
|
||||
const WritingResultScreen({
|
||||
super.key,
|
||||
required this.task,
|
||||
required this.content,
|
||||
required this.wordCount,
|
||||
required this.timeUsed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WritingResultScreen> createState() => _WritingResultScreenState();
|
||||
}
|
||||
|
||||
class _WritingResultScreenState extends State<WritingResultScreen> {
|
||||
late int score;
|
||||
late Map<String, dynamic> feedback;
|
||||
bool _recordSaved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
score = _calculateScore();
|
||||
feedback = _generateFeedback(score);
|
||||
_saveWritingRecord();
|
||||
}
|
||||
|
||||
Future<void> _saveWritingRecord() async {
|
||||
if (_recordSaved) return;
|
||||
|
||||
final record = WritingRecord(
|
||||
id: 'record_${DateTime.now().millisecondsSinceEpoch}',
|
||||
taskId: widget.task.id,
|
||||
taskTitle: widget.task.title,
|
||||
taskDescription: widget.task.description,
|
||||
content: widget.content,
|
||||
wordCount: widget.wordCount,
|
||||
timeUsed: widget.timeUsed,
|
||||
score: score,
|
||||
completedAt: DateTime.now(),
|
||||
feedback: feedback,
|
||||
);
|
||||
|
||||
await WritingRecordService.saveRecord(record);
|
||||
_recordSaved = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
title: const Text('写作结果'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildScoreCard(score),
|
||||
const SizedBox(height: 20),
|
||||
_buildStatistics(),
|
||||
const SizedBox(height: 20),
|
||||
_buildFeedback(feedback),
|
||||
const SizedBox(height: 20),
|
||||
_buildContentReview(),
|
||||
const SizedBox(height: 30),
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreCard(int score) {
|
||||
Color scoreColor;
|
||||
String scoreLevel;
|
||||
|
||||
if (score >= 90) {
|
||||
scoreColor = const Color(0xFF4CAF50);
|
||||
scoreLevel = '优秀';
|
||||
} else if (score >= 80) {
|
||||
scoreColor = const Color(0xFF2196F3);
|
||||
scoreLevel = '良好';
|
||||
} else if (score >= 70) {
|
||||
scoreColor = const Color(0xFFFF9800);
|
||||
scoreLevel = '一般';
|
||||
} else {
|
||||
scoreColor = const Color(0xFFF44336);
|
||||
scoreLevel = '需要改进';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'写作得分',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scoreColor.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: scoreColor,
|
||||
width: 4,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'$score',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scoreColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
scoreLevel,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: scoreColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.task.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatistics() {
|
||||
final timeUsedMinutes = (widget.timeUsed / 60).ceil();
|
||||
final timeLimit = widget.task.timeLimit;
|
||||
final wordLimit = widget.task.wordLimit;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作统计',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
Icons.text_fields,
|
||||
'字数',
|
||||
'${widget.wordCount} / $wordLimit',
|
||||
widget.wordCount <= wordLimit ? const Color(0xFF4CAF50) : const Color(0xFFF44336),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
Icons.timer,
|
||||
'用时',
|
||||
'$timeUsedMinutes / $timeLimit 分钟',
|
||||
const Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
Icons.category,
|
||||
'类型',
|
||||
widget.task.type.displayName,
|
||||
const Color(0xFFFF9800),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
Icons.star,
|
||||
'难度',
|
||||
widget.task.difficulty.displayName,
|
||||
_getDifficultyColor(widget.task.difficulty),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(IconData icon, String label, String value, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedback(Map<String, dynamic> feedback) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'评价反馈',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeedbackItem('内容质量', feedback['content'], feedback['contentScore']),
|
||||
const SizedBox(height: 12),
|
||||
_buildFeedbackItem('语法准确性', feedback['grammar'], feedback['grammarScore']),
|
||||
const SizedBox(height: 12),
|
||||
_buildFeedbackItem('词汇运用', feedback['vocabulary'], feedback['vocabularyScore']),
|
||||
const SizedBox(height: 12),
|
||||
_buildFeedbackItem('结构组织', feedback['structure'], feedback['structureScore']),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedbackItem(String title, String description, int score) {
|
||||
Color scoreColor;
|
||||
if (score >= 90) {
|
||||
scoreColor = const Color(0xFF4CAF50);
|
||||
} else if (score >= 80) {
|
||||
scoreColor = const Color(0xFF2196F3);
|
||||
} else if (score >= 70) {
|
||||
scoreColor = const Color(0xFFFF9800);
|
||||
} else {
|
||||
scoreColor = const Color(0xFFF44336);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: scoreColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$score分',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentReview() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'写作内容',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.content.isEmpty ? '未提交任何内容' : widget.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2196F3),
|
||||
side: const BorderSide(color: Color(0xFF2196F3)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('返回首页'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: 实现重新练习功能
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'重新练习',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
int _calculateScore() {
|
||||
int score = 70; // 基础分数
|
||||
|
||||
// 字数评分
|
||||
if (widget.wordCount >= widget.task.wordLimit * 0.8 && widget.wordCount <= widget.task.wordLimit * 1.2) {
|
||||
score += 10;
|
||||
} else if (widget.wordCount >= widget.task.wordLimit * 0.6) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// 时间评分
|
||||
final timeUsedMinutes = widget.timeUsed / 60;
|
||||
if (timeUsedMinutes <= widget.task.timeLimit) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// 内容长度评分
|
||||
if (widget.content.length > 100) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score.clamp(0, 100);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _generateFeedback(int score) {
|
||||
return {
|
||||
'content': score >= 80
|
||||
? '内容丰富,主题明确,论述清晰。'
|
||||
: '内容需要更加充实,建议增加具体的例子和细节。',
|
||||
'contentScore': score >= 80 ? 85 : 75,
|
||||
'grammar': score >= 80
|
||||
? '语法使用准确,句式多样。'
|
||||
: '语法基本正确,建议注意时态和语态的使用。',
|
||||
'grammarScore': score >= 80 ? 88 : 78,
|
||||
'vocabulary': score >= 80
|
||||
? '词汇运用恰当,表达准确。'
|
||||
: '词汇使用基本准确,可以尝试使用更多高级词汇。',
|
||||
'vocabularyScore': score >= 80 ? 82 : 72,
|
||||
'structure': score >= 80
|
||||
? '文章结构清晰,逻辑性强。'
|
||||
: '文章结构基本合理,建议加强段落之间的连接。',
|
||||
'structureScore': score >= 80 ? 86 : 76,
|
||||
};
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(WritingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case WritingDifficulty.beginner:
|
||||
return const Color(0xFF4CAF50);
|
||||
case WritingDifficulty.elementary:
|
||||
return const Color(0xFF8BC34A);
|
||||
case WritingDifficulty.intermediate:
|
||||
return const Color(0xFFFF9800);
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return const Color(0xFFFF5722);
|
||||
case WritingDifficulty.advanced:
|
||||
return const Color(0xFFF44336);
|
||||
}
|
||||
}
|
||||
}
|
||||
273
client/lib/features/writing/screens/writing_stats_screen.dart
Normal file
273
client/lib/features/writing/screens/writing_stats_screen.dart
Normal file
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/writing_stats.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
|
||||
class WritingStatsScreen extends StatefulWidget {
|
||||
const WritingStatsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WritingStatsScreen> createState() => _WritingStatsScreenState();
|
||||
}
|
||||
|
||||
class _WritingStatsScreenState extends State<WritingStatsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Provider.of<WritingProvider>(context, listen: false).loadStats();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('写作统计'),
|
||||
),
|
||||
body: Consumer<WritingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(provider.error!),
|
||||
ElevatedButton(
|
||||
onPressed: () => provider.loadStats(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.stats == null) {
|
||||
return const Center(
|
||||
child: Text('暂无统计数据'),
|
||||
);
|
||||
}
|
||||
|
||||
final stats = provider.stats!;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildOverviewCard(stats),
|
||||
const SizedBox(height: 16),
|
||||
_buildTaskTypeStats(stats),
|
||||
const SizedBox(height: 16),
|
||||
_buildDifficultyStats(stats),
|
||||
const SizedBox(height: 16),
|
||||
_buildSkillAnalysis(stats),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewCard(WritingStats stats) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'总体统计',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'完成任务',
|
||||
'${stats.completedTasks}',
|
||||
Icons.assignment_turned_in,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'总字数',
|
||||
'${stats.totalWords}',
|
||||
Icons.text_fields,
|
||||
Colors.blue,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'平均分',
|
||||
'${stats.averageScore.toStringAsFixed(1)}',
|
||||
Icons.star,
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String label, String value, IconData icon, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskTypeStats(WritingStats stats) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'任务类型分布',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...stats.taskTypeStats.entries.map((entry) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(entry.key),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: LinearProgressIndicator(
|
||||
value: entry.value / stats.completedTasks,
|
||||
backgroundColor: Colors.grey[300],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${entry.value}'),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDifficultyStats(WritingStats stats) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'难度分布',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...stats.difficultyStats.entries.map((entry) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(entry.key),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: LinearProgressIndicator(
|
||||
value: entry.value / stats.completedTasks,
|
||||
backgroundColor: Colors.grey[300],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${entry.value}'),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkillAnalysis(WritingStats stats) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'技能分析',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...stats.skillAnalysis.criteriaScores.entries.map((entry) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(entry.key),
|
||||
Text(
|
||||
'${(entry.value * 10).toStringAsFixed(1)}/10',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: entry.value,
|
||||
backgroundColor: Colors.grey[300],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getSkillColor(entry.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getSkillColor(double value) {
|
||||
if (value >= 0.9) return Colors.green;
|
||||
if (value >= 0.8) return Colors.lightGreen;
|
||||
if (value >= 0.7) return Colors.orange;
|
||||
if (value >= 0.6) return Colors.deepOrange;
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
437
client/lib/features/writing/screens/writing_task_screen.dart
Normal file
437
client/lib/features/writing/screens/writing_task_screen.dart
Normal file
@@ -0,0 +1,437 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:async';
|
||||
import '../models/writing_task.dart';
|
||||
import '../models/writing_submission.dart';
|
||||
import '../providers/writing_provider.dart';
|
||||
|
||||
class WritingTaskScreen extends StatefulWidget {
|
||||
final WritingTask task;
|
||||
|
||||
const WritingTaskScreen({
|
||||
super.key,
|
||||
required this.task,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WritingTaskScreen> createState() => _WritingTaskScreenState();
|
||||
}
|
||||
|
||||
class _WritingTaskScreenState extends State<WritingTaskScreen> {
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _timer;
|
||||
int _elapsedSeconds = 0;
|
||||
bool _isSubmitting = false;
|
||||
bool _showInstructions = true;
|
||||
int _wordCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimer();
|
||||
_contentController.addListener(_updateWordCount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_contentController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
setState(() {
|
||||
_elapsedSeconds++;
|
||||
});
|
||||
|
||||
// 检查时间限制
|
||||
if (widget.task.timeLimit != null &&
|
||||
_elapsedSeconds >= widget.task.timeLimit! * 60) {
|
||||
_showTimeUpDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateWordCount() {
|
||||
final text = _contentController.text;
|
||||
final words = text.trim().split(RegExp(r'\s+'));
|
||||
setState(() {
|
||||
_wordCount = text.trim().isEmpty ? 0 : words.length;
|
||||
});
|
||||
}
|
||||
|
||||
void _showTimeUpDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('时间到!'),
|
||||
content: const Text('写作时间已结束,请提交您的作品。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_submitWriting();
|
||||
},
|
||||
child: const Text('提交'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitWriting() async {
|
||||
if (_isSubmitting) return;
|
||||
|
||||
final content = _contentController.text.trim();
|
||||
if (content.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入写作内容')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = Provider.of<WritingProvider>(context, listen: false);
|
||||
// 首先开始任务(如果还没有开始)
|
||||
if (provider.currentSubmission == null) {
|
||||
await provider.startTask(widget.task.id);
|
||||
}
|
||||
// 更新内容
|
||||
provider.updateContent(content);
|
||||
provider.updateTimeSpent(_elapsedSeconds);
|
||||
// 提交写作
|
||||
final success = await provider.submitWriting();
|
||||
|
||||
if (mounted && success) {
|
||||
Navigator.of(context).pop(true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('写作已提交,正在批改中...')),
|
||||
);
|
||||
} else if (mounted && !success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('提交失败,请重试')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('提交失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.task.title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_showInstructions ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showInstructions = !_showInstructions;
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
onPressed: _showHelpDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 状态栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[300]!),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer,
|
||||
size: 20,
|
||||
color: _getTimeColor(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(_elapsedSeconds),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getTimeColor(),
|
||||
),
|
||||
),
|
||||
if (widget.task.timeLimit != null)
|
||||
Text(
|
||||
' / ${widget.task.timeLimit}分钟',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
size: 20,
|
||||
color: _getWordCountColor(_wordCount),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$_wordCount',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getWordCountColor(_wordCount),
|
||||
),
|
||||
),
|
||||
if (widget.task.wordLimit != null)
|
||||
Text(
|
||||
' / ${widget.task.wordLimit}字',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 任务说明
|
||||
if (_showInstructions)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[300]!),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.assignment,
|
||||
color: Colors.blue[700],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'任务要求',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.task.description,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (widget.task.requirements.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'具体要求:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...widget.task.requirements.map((req) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 2),
|
||||
child: Text(
|
||||
'• $req',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
if (widget.task.keywords.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'关键词:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: widget.task.keywords.map((keyword) => Chip(
|
||||
label: Text(
|
||||
keyword,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
backgroundColor: Colors.blue[100],
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
if (widget.task.prompt != null && widget.task.prompt!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'提示:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 2),
|
||||
child: Text(
|
||||
'💡 ${widget.task.prompt}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 写作区域
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '请在此处开始写作...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.all(16),
|
||||
),
|
||||
style: const TextStyle(fontSize: 16, height: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, -1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('保存草稿'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _submitWriting,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('提交'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTimeColor() {
|
||||
if (widget.task.timeLimit == null) return Colors.blue;
|
||||
final remainingMinutes = (widget.task.timeLimit! * 60 - _elapsedSeconds) / 60;
|
||||
if (remainingMinutes <= 5) return Colors.red;
|
||||
if (remainingMinutes <= 10) return Colors.orange;
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
Color _getWordCountColor(int wordCount) {
|
||||
if (widget.task.wordLimit == null) return Colors.green;
|
||||
final ratio = wordCount / widget.task.wordLimit!;
|
||||
if (ratio > 1.1) return Colors.red;
|
||||
if (ratio > 0.9) return Colors.green;
|
||||
if (ratio > 0.5) return Colors.orange;
|
||||
return Colors.grey;
|
||||
}
|
||||
|
||||
void _showHelpDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('写作帮助'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'写作技巧:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('• 仔细阅读任务要求,确保理解题意'),
|
||||
Text('• 合理安排时间,留出检查和修改的时间'),
|
||||
Text('• 注意文章结构,包括开头、主体和结尾'),
|
||||
Text('• 使用多样化的词汇和句式'),
|
||||
Text('• 检查语法、拼写和标点符号'),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'评分标准:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('• 内容相关性和完整性'),
|
||||
Text('• 语言准确性和流畅性'),
|
||||
Text('• 词汇丰富度和语法复杂性'),
|
||||
Text('• 文章结构和逻辑性'),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
client/lib/features/writing/services/writing_record_service.dart
Normal file
168
client/lib/features/writing/services/writing_record_service.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/writing_record.dart';
|
||||
|
||||
/// 写作记录管理服务
|
||||
class WritingRecordService {
|
||||
static const String _recordsKey = 'writing_records';
|
||||
|
||||
/// 保存写作记录
|
||||
static Future<void> saveRecord(WritingRecord record) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final records = await getRecords();
|
||||
records.add(record);
|
||||
|
||||
final recordsJson = records.map((r) => r.toJson()).toList();
|
||||
await prefs.setString(_recordsKey, jsonEncode(recordsJson));
|
||||
}
|
||||
|
||||
/// 获取所有写作记录
|
||||
static Future<List<WritingRecord>> getRecords() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final recordsString = prefs.getString(_recordsKey);
|
||||
|
||||
if (recordsString == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final recordsJson = jsonDecode(recordsString) as List;
|
||||
return recordsJson.map((json) => WritingRecord.fromJson(json)).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取最近的写作记录
|
||||
static Future<List<WritingRecord>> getRecentRecords({int limit = 5}) async {
|
||||
final records = await getRecords();
|
||||
records.sort((a, b) => b.completedAt.compareTo(a.completedAt));
|
||||
return records.take(limit).toList();
|
||||
}
|
||||
|
||||
/// 根据ID获取写作记录
|
||||
static Future<WritingRecord?> getRecordById(String id) async {
|
||||
final records = await getRecords();
|
||||
try {
|
||||
return records.firstWhere((record) => record.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除写作记录
|
||||
static Future<void> deleteRecord(String id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final records = await getRecords();
|
||||
records.removeWhere((record) => record.id == id);
|
||||
|
||||
final recordsJson = records.map((r) => r.toJson()).toList();
|
||||
await prefs.setString(_recordsKey, jsonEncode(recordsJson));
|
||||
}
|
||||
|
||||
/// 获取写作统计数据
|
||||
static Future<Map<String, dynamic>> getStatistics() async {
|
||||
final records = await getRecords();
|
||||
|
||||
if (records.isEmpty) {
|
||||
return {
|
||||
'totalCount': 0,
|
||||
'averageScore': 0,
|
||||
'totalTimeUsed': 0,
|
||||
'averageWordCount': 0,
|
||||
};
|
||||
}
|
||||
|
||||
final totalScore = records.fold<int>(0, (sum, record) => sum + record.score);
|
||||
final totalTimeUsed = records.fold<int>(0, (sum, record) => sum + record.timeUsed);
|
||||
final totalWordCount = records.fold<int>(0, (sum, record) => sum + record.wordCount);
|
||||
|
||||
return {
|
||||
'totalCount': records.length,
|
||||
'averageScore': (totalScore / records.length).round(),
|
||||
'totalTimeUsed': totalTimeUsed,
|
||||
'averageWordCount': (totalWordCount / records.length).round(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 清空所有记录(用于测试)
|
||||
static Future<void> clearAllRecords() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_recordsKey);
|
||||
}
|
||||
|
||||
/// 添加一些示例数据(用于演示)
|
||||
static Future<void> addSampleData() async {
|
||||
final now = DateTime.now();
|
||||
|
||||
final sampleRecords = [
|
||||
WritingRecord(
|
||||
id: 'record_1',
|
||||
taskId: 'essay_1',
|
||||
taskTitle: '市场调研报告',
|
||||
taskDescription: '撰写一份关于新产品市场前景的调研报告。',
|
||||
content: '随着科技的不断发展,智能手表市场呈现出强劲的增长势头。根据最新的市场调研数据显示,全球智能手表市场规模预计在未来五年内将保持年均15%的增长率。消费者对健康监测、运动追踪等功能的需求日益增长,为智能手表产品提供了广阔的市场空间。建议公司抓住这一机遇,加大研发投入,推出具有竞争力的产品。',
|
||||
wordCount: 156,
|
||||
timeUsed: 1800, // 30分钟
|
||||
score: 90,
|
||||
completedAt: now.subtract(const Duration(days: 1)),
|
||||
feedback: {
|
||||
'content': '内容丰富,主题明确,论述清晰。',
|
||||
'contentScore': 88,
|
||||
'grammar': '语法使用准确,句式多样。',
|
||||
'grammarScore': 92,
|
||||
'vocabulary': '词汇运用恰当,表达准确。',
|
||||
'vocabularyScore': 89,
|
||||
'structure': '文章结构清晰,逻辑性强。',
|
||||
'structureScore': 91,
|
||||
},
|
||||
),
|
||||
WritingRecord(
|
||||
id: 'record_2',
|
||||
taskId: 'letter_1',
|
||||
taskTitle: '我的理想房屋',
|
||||
taskDescription: '描述你心目中理想房屋的样子。',
|
||||
content: '我理想中的房屋坐落在一个安静的社区里,周围绿树成荫,环境优美。房屋采用现代简约风格设计,外观简洁大方。内部布局合理,包括宽敞的客厅、舒适的卧室、功能齐全的厨房和书房。特别是书房,我希望有一面大大的书墙,可以收藏我喜爱的书籍。房屋还应该有一个小花园,种植各种花草,让生活更加贴近自然。',
|
||||
wordCount: 142,
|
||||
timeUsed: 1200, // 20分钟
|
||||
score: 89,
|
||||
completedAt: now.subtract(const Duration(days: 2)),
|
||||
feedback: {
|
||||
'content': '内容充实,描述生动。',
|
||||
'contentScore': 87,
|
||||
'grammar': '语法基本正确,表达流畅。',
|
||||
'grammarScore': 90,
|
||||
'vocabulary': '词汇使用恰当,描述性强。',
|
||||
'vocabularyScore': 88,
|
||||
'structure': '结构清晰,层次分明。',
|
||||
'structureScore': 92,
|
||||
},
|
||||
),
|
||||
WritingRecord(
|
||||
id: 'record_3',
|
||||
taskId: 'review_1',
|
||||
taskTitle: '电影评论',
|
||||
taskDescription: '写一篇关于最近观看电影的评论。',
|
||||
content: '最近观看了《流浪地球2》这部科幻电影,给我留下了深刻的印象。影片在视觉效果方面表现出色,宏大的场面和精细的特效让人震撼。故事情节紧凑,人物刻画也比较丰满。特别是对人类面临危机时的团结精神的展现,很有感染力。不过,部分情节的发展略显仓促,希望能有更多的细节描述。总的来说,这是一部值得推荐的优秀科幻电影。',
|
||||
wordCount: 138,
|
||||
timeUsed: 1500, // 25分钟
|
||||
score: 92,
|
||||
completedAt: now.subtract(const Duration(hours: 12)),
|
||||
feedback: {
|
||||
'content': '评论客观全面,观点明确。',
|
||||
'contentScore': 90,
|
||||
'grammar': '语法准确,表达自然。',
|
||||
'grammarScore': 93,
|
||||
'vocabulary': '词汇丰富,用词精准。',
|
||||
'vocabularyScore': 91,
|
||||
'structure': '结构合理,逻辑清晰。',
|
||||
'structureScore': 94,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
for (final record in sampleRecords) {
|
||||
await saveRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
242
client/lib/features/writing/services/writing_service.dart
Normal file
242
client/lib/features/writing/services/writing_service.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import '../models/writing_task.dart';
|
||||
import '../models/writing_submission.dart';
|
||||
import '../models/writing_stats.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
import '../../../core/services/enhanced_api_service.dart';
|
||||
import '../../../core/models/api_response.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../models/writing_submission.dart';
|
||||
import '../models/writing_stats.dart';
|
||||
|
||||
/// 写作训练服务
|
||||
class WritingService {
|
||||
final EnhancedApiService _enhancedApiService = EnhancedApiService();
|
||||
|
||||
// 缓存时长配置
|
||||
static const Duration _shortCacheDuration = Duration(minutes: 5);
|
||||
static const Duration _longCacheDuration = Duration(hours: 1);
|
||||
|
||||
/// 获取写作任务列表
|
||||
Future<List<WritingTask>> getWritingTasks({
|
||||
WritingType? type,
|
||||
WritingDifficulty? difficulty,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<List<WritingTask>>(
|
||||
ApiEndpoints.writingPrompts,
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (type != null) 'type': type.name,
|
||||
if (difficulty != null) 'difficulty': difficulty.name,
|
||||
},
|
||||
useCache: !forceRefresh,
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) {
|
||||
final List<dynamic> list = data['data'] ?? [];
|
||||
return list.map((json) => WritingTask.fromJson(json)).toList();
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取写作任务失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个写作任务详情
|
||||
Future<WritingTask> getWritingTask(String taskId) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<WritingTask>(
|
||||
'${ApiEndpoints.writingPrompts}/$taskId',
|
||||
cacheDuration: _longCacheDuration,
|
||||
fromJson: (data) => WritingTask.fromJson(data['data']),
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取写作任务详情失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交写作作业
|
||||
Future<WritingSubmission> submitWriting({
|
||||
required String taskId,
|
||||
required String userId,
|
||||
required String content,
|
||||
required int timeSpent,
|
||||
required int wordCount,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.post<WritingSubmission>(
|
||||
ApiEndpoints.writingSubmissions,
|
||||
data: {
|
||||
'prompt_id': taskId,
|
||||
'user_id': userId,
|
||||
'content': content,
|
||||
'time_spent': timeSpent,
|
||||
'word_count': wordCount,
|
||||
},
|
||||
fromJson: (data) => WritingSubmission.fromJson(data['data']),
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('提交写作作业失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户写作历史
|
||||
Future<List<WritingSubmission>> getUserWritingHistory({
|
||||
required String userId,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<List<WritingSubmission>>(
|
||||
ApiEndpoints.writingSubmissions,
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
useCache: !forceRefresh,
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) {
|
||||
final List<dynamic> list = data['data'] ?? [];
|
||||
return list.map((json) => WritingSubmission.fromJson(json)).toList();
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取写作历史失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户写作统计
|
||||
Future<WritingStats> getUserWritingStats(String userId, {bool forceRefresh = false}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<WritingStats>(
|
||||
ApiEndpoints.writingStats,
|
||||
useCache: !forceRefresh,
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) => WritingStats.fromJson(data['data']),
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取写作统计失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取写作反馈
|
||||
Future<WritingSubmission> getWritingFeedback(String submissionId) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<WritingSubmission>(
|
||||
'${ApiEndpoints.writingSubmissions}/$submissionId',
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) => WritingSubmission.fromJson(data['data']),
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取写作反馈失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索写作任务
|
||||
Future<List<WritingTask>> searchWritingTasks({
|
||||
required String query,
|
||||
WritingType? type,
|
||||
WritingDifficulty? difficulty,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<List<WritingTask>>(
|
||||
'${ApiEndpoints.writingPrompts}/search',
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (type != null) 'type': type.name,
|
||||
if (difficulty != null) 'difficulty': difficulty.name,
|
||||
},
|
||||
useCache: !forceRefresh,
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) {
|
||||
final List<dynamic> list = data['data'] ?? [];
|
||||
return list.map((json) => WritingTask.fromJson(json)).toList();
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('搜索写作任务失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取推荐的写作任务
|
||||
Future<List<WritingTask>> getRecommendedWritingTasks({
|
||||
required String userId,
|
||||
int limit = 10,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<List<WritingTask>>(
|
||||
'${ApiEndpoints.writingPrompts}/recommendations',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
},
|
||||
useCache: !forceRefresh,
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) {
|
||||
final List<dynamic> list = data['data'] ?? [];
|
||||
return list.map((json) => WritingTask.fromJson(json)).toList();
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取推荐写作任务失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
111
client/lib/features/writing/widgets/writing_mode_card.dart
Normal file
111
client/lib/features/writing/widgets/writing_mode_card.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/writing_task.dart';
|
||||
import '../screens/writing_list_screen.dart';
|
||||
|
||||
/// 写作模式卡片组件
|
||||
class WritingModeCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final WritingType? type;
|
||||
final WritingDifficulty? difficulty;
|
||||
|
||||
const WritingModeCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.type,
|
||||
this.difficulty,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WritingListScreen(
|
||||
title: title,
|
||||
type: type,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'开始练习',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
360
client/lib/features/writing/widgets/writing_stats_card.dart
Normal file
360
client/lib/features/writing/widgets/writing_stats_card.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/writing_stats.dart';
|
||||
|
||||
class WritingStatsCard extends StatelessWidget {
|
||||
final WritingStats stats;
|
||||
final bool showDetails;
|
||||
|
||||
const WritingStatsCard({
|
||||
super.key,
|
||||
required this.stats,
|
||||
this.showDetails = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.purple.shade50,
|
||||
Colors.purple.shade100,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: Colors.purple.shade700,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'写作统计',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.purple.shade700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showDetails)
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Colors.purple.shade700,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 主要统计数据
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.assignment,
|
||||
label: '完成任务',
|
||||
value: '${stats.completedTasks}',
|
||||
total: '${stats.totalTasks}',
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.text_fields,
|
||||
label: '总字数',
|
||||
value: _formatNumber(stats.totalWords),
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.star,
|
||||
label: '平均分',
|
||||
value: stats.averageScore.toStringAsFixed(1),
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (showDetails) ...[
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 详细统计
|
||||
_buildDetailedStats(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
String? total,
|
||||
required Color color,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
total != null ? '$value/$total' : value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedStats() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 任务类型统计
|
||||
Text(
|
||||
'任务类型分布',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildTypeStats(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 难度分布
|
||||
Text(
|
||||
'难度分布',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDifficultyStats(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 技能分析
|
||||
// 技能分析
|
||||
Text(
|
||||
'技能分析',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSkillAnalysis(),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeStats() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: stats.taskTypeStats.entries.map((entry) {
|
||||
final percentage = stats.totalTasks > 0
|
||||
? (entry.value / stats.totalTasks * 100).toInt()
|
||||
: 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${entry.key}: ${entry.value} ($percentage%)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDifficultyStats() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: stats.difficultyStats.entries.map((entry) {
|
||||
final percentage = stats.totalTasks > 0
|
||||
? (entry.value / stats.totalTasks * 100).toInt()
|
||||
: 0;
|
||||
|
||||
Color color;
|
||||
switch (entry.key) {
|
||||
case 'beginner':
|
||||
case 'elementary':
|
||||
color = Colors.green;
|
||||
break;
|
||||
case 'intermediate':
|
||||
case 'upperIntermediate':
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case 'advanced':
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
color = Colors.grey;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${entry.key}: ${entry.value} ($percentage%)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkillAnalysis() {
|
||||
final analysis = stats.skillAnalysis;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
...analysis.criteriaScores.entries.map((entry) {
|
||||
Color color;
|
||||
switch (entry.key) {
|
||||
case 'grammar':
|
||||
color = Colors.blue;
|
||||
break;
|
||||
case 'vocabulary':
|
||||
color = Colors.green;
|
||||
break;
|
||||
case 'structure':
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case 'content':
|
||||
color = Colors.purple;
|
||||
break;
|
||||
default:
|
||||
color = Colors.grey;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildSkillBar(entry.key, entry.value * 100, color),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkillBar(String skill, double score, Color color) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
skill,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: score / 100,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${score.toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatNumber(int number) {
|
||||
if (number >= 1000000) {
|
||||
return '${(number / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
271
client/lib/features/writing/widgets/writing_task_card.dart
Normal file
271
client/lib/features/writing/widgets/writing_task_card.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/writing_task.dart';
|
||||
|
||||
class WritingTaskCard extends StatelessWidget {
|
||||
final WritingTask task;
|
||||
final VoidCallback? onTap;
|
||||
final bool showProgress;
|
||||
|
||||
const WritingTaskCard({
|
||||
Key? key,
|
||||
required this.task,
|
||||
this.onTap,
|
||||
this.showProgress = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和类型
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTypeChip(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 描述
|
||||
Text(
|
||||
task.description,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 任务信息
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
icon: Icons.signal_cellular_alt,
|
||||
label: task.difficulty.displayName,
|
||||
color: _getDifficultyColor(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (task.timeLimit > 0)
|
||||
_buildInfoChip(
|
||||
icon: Icons.timer,
|
||||
label: '${task.timeLimit}分钟',
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (task.wordLimit > 0)
|
||||
_buildInfoChip(
|
||||
icon: Icons.text_fields,
|
||||
label: '${task.wordLimit}词',
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 关键词
|
||||
if (task.keywords.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: task.keywords.take(3).map((keyword) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.purple.shade200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
keyword,
|
||||
style: TextStyle(
|
||||
color: Colors.purple.shade700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
// 进度条(如果需要显示)
|
||||
if (showProgress) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildProgressBar(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeChip() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor().withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _getTypeColor(),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
task.type.displayName,
|
||||
style: TextStyle(
|
||||
color: _getTypeColor(),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressBar() {
|
||||
// TODO: 实现进度条逻辑
|
||||
const progress = 0.6; // 示例进度
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'完成进度',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(progress * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.purple.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor() {
|
||||
switch (task.type) {
|
||||
case WritingType.essay:
|
||||
return Colors.blue;
|
||||
case WritingType.letter:
|
||||
return Colors.green;
|
||||
case WritingType.report:
|
||||
return Colors.orange;
|
||||
case WritingType.story:
|
||||
return Colors.purple;
|
||||
case WritingType.review:
|
||||
return Colors.red;
|
||||
case WritingType.argument:
|
||||
return Colors.teal;
|
||||
case WritingType.article:
|
||||
return Colors.indigo;
|
||||
case WritingType.email:
|
||||
return Colors.cyan;
|
||||
case WritingType.diary:
|
||||
return Colors.pink;
|
||||
case WritingType.description:
|
||||
return Colors.amber;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getDifficultyColor() {
|
||||
switch (task.difficulty) {
|
||||
case WritingDifficulty.beginner:
|
||||
return Colors.green;
|
||||
case WritingDifficulty.elementary:
|
||||
return Colors.lightGreen;
|
||||
case WritingDifficulty.intermediate:
|
||||
return Colors.orange;
|
||||
case WritingDifficulty.upperIntermediate:
|
||||
return Colors.deepOrange;
|
||||
case WritingDifficulty.advanced:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user