init
This commit is contained in:
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('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user