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

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../models/speaking_scenario.dart';
class SpeakingFilterBar extends StatelessWidget {
final SpeakingScenario? selectedScenario;
final SpeakingDifficulty? selectedDifficulty;
final String sortBy;
final ValueChanged<SpeakingScenario?> onScenarioChanged;
final ValueChanged<SpeakingDifficulty?> onDifficultyChanged;
final ValueChanged<String> onSortChanged;
const SpeakingFilterBar({
super.key,
this.selectedScenario,
this.selectedDifficulty,
required this.sortBy,
required this.onScenarioChanged,
required this.onDifficultyChanged,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border(
bottom: BorderSide(
color: Colors.grey.withOpacity(0.2),
width: 1,
),
),
),
child: Column(
children: [
// 筛选器行
Row(
children: [
// 场景筛选
Expanded(
child: _buildScenarioFilter(context),
),
const SizedBox(width: 12),
// 难度筛选
Expanded(
child: _buildDifficultyFilter(context),
),
const SizedBox(width: 12),
// 排序筛选
Expanded(
child: _buildSortFilter(context),
),
],
),
// 清除筛选按钮
if (selectedScenario != null || selectedDifficulty != null || sortBy != 'recommended')
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
onPressed: _clearFilters,
icon: const Icon(Icons.clear, size: 16),
label: const Text('清除筛选'),
style: TextButton.styleFrom(
foregroundColor: Colors.grey[600],
textStyle: const TextStyle(fontSize: 12),
),
),
],
),
),
],
),
);
}
Widget _buildScenarioFilter(BuildContext context) {
return PopupMenuButton<SpeakingScenario?>(
initialValue: selectedScenario,
onSelected: onScenarioChanged,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.category_outlined,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedScenario?.displayName ?? '场景',
style: TextStyle(
fontSize: 12,
color: selectedScenario != null ? Colors.black87 : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: Colors.grey[600],
),
],
),
),
itemBuilder: (context) => [
const PopupMenuItem<SpeakingScenario?>(
value: null,
child: Text('全部场景'),
),
...SpeakingScenario.values.map(
(scenario) => PopupMenuItem<SpeakingScenario?>(
value: scenario,
child: Text(scenario.displayName),
),
),
],
);
}
Widget _buildDifficultyFilter(BuildContext context) {
return PopupMenuButton<SpeakingDifficulty?>(
initialValue: selectedDifficulty,
onSelected: onDifficultyChanged,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.trending_up,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedDifficulty?.displayName ?? '难度',
style: TextStyle(
fontSize: 12,
color: selectedDifficulty != null ? Colors.black87 : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: Colors.grey[600],
),
],
),
),
itemBuilder: (context) => [
const PopupMenuItem<SpeakingDifficulty?>(
value: null,
child: Text('全部难度'),
),
...SpeakingDifficulty.values.map(
(difficulty) => PopupMenuItem<SpeakingDifficulty?>(
value: difficulty,
child: Text(difficulty.displayName),
),
),
],
);
}
Widget _buildSortFilter(BuildContext context) {
final sortOptions = {
'recommended': '推荐',
'difficulty': '难度',
'duration': '时长',
'popularity': '热度',
};
return PopupMenuButton<String>(
initialValue: sortBy,
onSelected: onSortChanged,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.sort,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
sortOptions[sortBy] ?? '排序',
style: TextStyle(
fontSize: 12,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: Colors.grey[600],
),
],
),
),
itemBuilder: (context) => sortOptions.entries
.map(
(entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
);
}
void _clearFilters() {
onScenarioChanged(null);
onDifficultyChanged(null);
onSortChanged('recommended');
}
}

View File

@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import '../models/speaking_stats.dart';
class SpeakingStatsCard extends StatelessWidget {
final SpeakingStats? stats;
final bool isLoading;
final VoidCallback? onTap;
const SpeakingStatsCard({
super.key,
this.stats,
this.isLoading = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
child: isLoading ? _buildLoadingState() : _buildStatsContent(),
),
),
);
}
Widget _buildLoadingState() {
return const SizedBox(
height: 120,
child: Center(
child: CircularProgressIndicator(),
),
);
}
Widget _buildStatsContent() {
if (stats == null) {
return _buildEmptyState();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
children: [
const Icon(
Icons.mic,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'口语练习统计',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (onTap != null)
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Colors.grey[600],
),
],
),
const SizedBox(height: 16),
// 统计数据网格
Row(
children: [
Expanded(
child: _buildStatItem(
'总会话',
stats!.totalSessions.toString(),
Icons.chat_bubble_outline,
Colors.blue,
),
),
Expanded(
child: _buildStatItem(
'总时长',
_formatDuration(stats!.totalMinutes),
Icons.access_time,
Colors.green,
),
),
Expanded(
child: _buildStatItem(
'平均分',
stats!.averageScore.toStringAsFixed(1),
Icons.star,
Colors.orange,
),
),
],
),
const SizedBox(height: 16),
// 进度条
_buildProgressSection(),
],
);
}
Widget _buildEmptyState() {
return SizedBox(
height: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.mic_off,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'还没有口语练习记录',
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
'开始你的第一次对话吧!',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
],
),
);
}
Widget _buildStatItem(
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
);
}
Widget _buildProgressSection() {
if (stats?.progressData == null || stats!.progressData.isEmpty) {
return const SizedBox.shrink();
}
// 获取最近的进度数据
final recentProgress = stats!.progressData.last;
final progressPercentage = (recentProgress.averageScore / 100).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'最近表现',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Text(
'${recentProgress.averageScore.toStringAsFixed(1)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progressPercentage,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
_getScoreColor(recentProgress.averageScore),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_getScoreLevel(recentProgress.averageScore),
style: TextStyle(
fontSize: 12,
color: _getScoreColor(recentProgress.averageScore),
fontWeight: FontWeight.w500,
),
),
Text(
_formatDate(recentProgress.date),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
],
);
}
String _formatDuration(int minutes) {
if (minutes < 60) {
return '${minutes}分钟';
} else {
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
if (remainingMinutes == 0) {
return '${hours}小时';
} else {
return '${hours}小时${remainingMinutes}分钟';
}
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) {
return '今天';
} else if (difference == 1) {
return '昨天';
} else if (difference < 7) {
return '${difference}天前';
} else {
return '${date.month}/${date.day}';
}
}
Color _getScoreColor(double score) {
if (score >= 90) {
return Colors.green;
} else if (score >= 80) {
return Colors.lightGreen;
} else if (score >= 70) {
return Colors.orange;
} else if (score >= 60) {
return Colors.deepOrange;
} else {
return Colors.red;
}
}
String _getScoreLevel(double score) {
if (score >= 90) {
return '优秀';
} else if (score >= 80) {
return '良好';
} else if (score >= 70) {
return '中等';
} else if (score >= 60) {
return '及格';
} else {
return '需要提高';
}
}
}

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import '../models/speaking_scenario.dart';
class SpeakingTaskCard extends StatelessWidget {
final SpeakingTask task;
final VoidCallback? onTap;
final VoidCallback? onFavorite;
const SpeakingTaskCard({
super.key,
required this.task,
this.onTap,
this.onFavorite,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题和收藏按钮
Row(
children: [
Expanded(
child: Text(
task.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
task.isFavorite ? Icons.favorite : Icons.favorite_border,
color: task.isFavorite ? Colors.red : Colors.grey,
),
onPressed: onFavorite,
tooltip: task.isFavorite ? '取消收藏' : '收藏',
),
],
),
const SizedBox(height: 8),
// 描述
Text(
task.description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
// 标签行
Row(
children: [
// 场景标签
_buildTag(
context,
task.scenario.displayName,
Colors.blue,
),
const SizedBox(width: 8),
// 难度标签
_buildTag(
context,
task.difficulty.displayName,
_getDifficultyColor(task.difficulty),
),
const Spacer(),
// 推荐标签
if (task.isRecommended)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star,
size: 12,
color: Colors.orange[700],
),
const SizedBox(width: 4),
Text(
'推荐',
style: TextStyle(
fontSize: 10,
color: Colors.orange[700],
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// 底部信息
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
'${task.estimatedDuration}分钟',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(width: 16),
Icon(
Icons.people_outline,
size: 16,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
'${task.completionCount}人完成',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
const Spacer(),
// 开始按钮
ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text(
'开始练习',
style: TextStyle(fontSize: 12),
),
),
],
),
],
),
),
),
);
}
Widget _buildTag(BuildContext context, String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
Color _getDifficultyColor(SpeakingDifficulty difficulty) {
switch (difficulty) {
case SpeakingDifficulty.beginner:
return Colors.green;
case SpeakingDifficulty.elementary:
return Colors.lightGreen;
case SpeakingDifficulty.intermediate:
return Colors.orange;
case SpeakingDifficulty.upperIntermediate:
return Colors.deepOrange;
case SpeakingDifficulty.advanced:
return Colors.red;
}
}
}