This commit is contained in:
sjk
2025-11-17 13:39:05 +08:00
commit d4cfe2b9de
479 changed files with 109324 additions and 0 deletions

View 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);
}
}
}

View File

@@ -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);
}
},
),
),
);
}
}

View File

@@ -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;
}
}

View 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);
}
}
}

View 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,
),
),
),
],
);
}
}

View File

@@ -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')}';
}
}

View 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,
),
),
],
);
}
}

View 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('确定'),
),
],
),
);
}
}

View 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);
}
}
}

View 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;
}
}

View 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('知道了'),
),
],
),
);
}
}