1140 lines
31 KiB
Dart
1140 lines
31 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:provider/provider.dart';
|
||
|
|
import 'package:fl_chart/fl_chart.dart';
|
||
|
|
import '../models/speaking_stats.dart';
|
||
|
|
import '../providers/speaking_provider.dart';
|
||
|
|
|
||
|
|
class SpeakingStatsScreen extends StatefulWidget {
|
||
|
|
const SpeakingStatsScreen({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<SpeakingStatsScreen> createState() => _SpeakingStatsScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SpeakingStatsScreenState extends State<SpeakingStatsScreen>
|
||
|
|
with SingleTickerProviderStateMixin {
|
||
|
|
late TabController _tabController;
|
||
|
|
String _selectedPeriod = '7days';
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_tabController = TabController(length: 3, vsync: this);
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
context.read<SpeakingProvider>().loadStats();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_tabController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(
|
||
|
|
title: const Text('口语统计'),
|
||
|
|
bottom: TabBar(
|
||
|
|
controller: _tabController,
|
||
|
|
tabs: const [
|
||
|
|
Tab(text: '总览'),
|
||
|
|
Tab(text: '进度'),
|
||
|
|
Tab(text: '分析'),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
body: Consumer<SpeakingProvider>(
|
||
|
|
builder: (context, provider, child) {
|
||
|
|
if (provider.isLoading) {
|
||
|
|
return const Center(
|
||
|
|
child: CircularProgressIndicator(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (provider.error != null) {
|
||
|
|
return _buildErrorState(provider.error!);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (provider.stats == null) {
|
||
|
|
return _buildEmptyState();
|
||
|
|
}
|
||
|
|
|
||
|
|
return TabBarView(
|
||
|
|
controller: _tabController,
|
||
|
|
children: [
|
||
|
|
_buildOverviewTab(provider.stats!),
|
||
|
|
_buildProgressTab(provider.stats!),
|
||
|
|
_buildAnalysisTab(provider.stats!),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildOverviewTab(SpeakingStats stats) {
|
||
|
|
return SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// 时间筛选
|
||
|
|
_buildPeriodSelector(),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 核心指标卡片
|
||
|
|
_buildMetricsCards(stats),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 最近活动
|
||
|
|
_buildRecentActivity(stats),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 技能分布
|
||
|
|
_buildSkillDistribution(stats),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildProgressTab(SpeakingStats stats) {
|
||
|
|
return SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// 进度图表
|
||
|
|
_buildProgressChart(stats),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 学习目标
|
||
|
|
_buildLearningGoals(stats),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 成就徽章
|
||
|
|
_buildAchievements(),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildAnalysisTab(SpeakingStats stats) {
|
||
|
|
return SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// 技能分析
|
||
|
|
_buildSkillAnalysis(stats),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 改进建议
|
||
|
|
_buildImprovementSuggestions(stats),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// 学习习惯分析
|
||
|
|
_buildLearningHabits(stats),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildPeriodSelector() {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(4),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.grey[100],
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
_buildPeriodButton('7days', '7天'),
|
||
|
|
_buildPeriodButton('30days', '30天'),
|
||
|
|
_buildPeriodButton('90days', '90天'),
|
||
|
|
_buildPeriodButton('all', '全部'),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildPeriodButton(String value, String label) {
|
||
|
|
final isSelected = _selectedPeriod == value;
|
||
|
|
|
||
|
|
return Expanded(
|
||
|
|
child: GestureDetector(
|
||
|
|
onTap: () {
|
||
|
|
setState(() {
|
||
|
|
_selectedPeriod = value;
|
||
|
|
});
|
||
|
|
},
|
||
|
|
child: Container(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: isSelected ? Colors.white : Colors.transparent,
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
boxShadow: isSelected
|
||
|
|
? [
|
||
|
|
BoxShadow(
|
||
|
|
color: Colors.black.withOpacity(0.1),
|
||
|
|
blurRadius: 4,
|
||
|
|
offset: const Offset(0, 2),
|
||
|
|
),
|
||
|
|
]
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
child: Text(
|
||
|
|
label,
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
style: TextStyle(
|
||
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||
|
|
color: isSelected ? Theme.of(context).primaryColor : Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildMetricsCards(SpeakingStats stats) {
|
||
|
|
return Column(
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: _buildMetricCard(
|
||
|
|
'总会话',
|
||
|
|
'${stats.totalSessions}',
|
||
|
|
Icons.chat_bubble_outline,
|
||
|
|
Colors.blue,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: _buildMetricCard(
|
||
|
|
'总时长',
|
||
|
|
'${stats.totalMinutes}分钟',
|
||
|
|
Icons.access_time,
|
||
|
|
Colors.green,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: _buildMetricCard(
|
||
|
|
'平均分',
|
||
|
|
'${stats.averageScore.toStringAsFixed(1)}',
|
||
|
|
Icons.star,
|
||
|
|
Colors.orange,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: _buildMetricCard(
|
||
|
|
'连续天数',
|
||
|
|
'${_calculateStreakDays(stats)}天',
|
||
|
|
Icons.local_fire_department,
|
||
|
|
Colors.red,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildMetricCard(String title, String value, IconData icon, Color color) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
boxShadow: [
|
||
|
|
BoxShadow(
|
||
|
|
color: Colors.black.withOpacity(0.05),
|
||
|
|
blurRadius: 10,
|
||
|
|
offset: const Offset(0, 2),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
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 Spacer(),
|
||
|
|
Icon(
|
||
|
|
Icons.trending_up,
|
||
|
|
color: Colors.grey[400],
|
||
|
|
size: 16,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
Text(
|
||
|
|
value,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 24,
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Text(
|
||
|
|
title,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildRecentActivity(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
if (stats.progressData.isNotEmpty)
|
||
|
|
...stats.progressData.take(5).map((data) => _buildActivityItem(data))
|
||
|
|
else
|
||
|
|
const Text(
|
||
|
|
'暂无活动记录',
|
||
|
|
style: TextStyle(color: Colors.grey),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildActivityItem(SpeakingProgressData data) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 8,
|
||
|
|
height: 8,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Theme.of(context).primaryColor,
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
'完成 ${data.sessionCount} 次对话',
|
||
|
|
style: const TextStyle(
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
'${_formatDate(data.date)} • 平均分 ${data.averageScore.toStringAsFixed(1)}',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
'${data.totalMinutes}分钟',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildSkillDistribution(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
if (stats.skillAnalysis != null)
|
||
|
|
...stats.skillAnalysis!.criteriaScores.entries.map(
|
||
|
|
(entry) => _buildSkillBar(entry.key.toString(), entry.value),
|
||
|
|
)
|
||
|
|
else
|
||
|
|
const Text(
|
||
|
|
'暂无技能数据',
|
||
|
|
style: TextStyle(color: Colors.grey),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildSkillBar(String skill, double score) {
|
||
|
|
final percentage = score / 100;
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
_getSkillDisplayName(skill),
|
||
|
|
style: const TextStyle(
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
'${score.toStringAsFixed(1)}%',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
LinearProgressIndicator(
|
||
|
|
value: percentage,
|
||
|
|
backgroundColor: Colors.grey[200],
|
||
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||
|
|
_getSkillColor(percentage),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildProgressChart(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
height: 300,
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
Expanded(
|
||
|
|
child: LineChart(
|
||
|
|
LineChartData(
|
||
|
|
gridData: FlGridData(show: false),
|
||
|
|
titlesData: FlTitlesData(
|
||
|
|
leftTitles: AxisTitles(
|
||
|
|
sideTitles: SideTitles(
|
||
|
|
showTitles: true,
|
||
|
|
reservedSize: 40,
|
||
|
|
getTitlesWidget: (value, meta) {
|
||
|
|
return Text(
|
||
|
|
value.toInt().toString(),
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
bottomTitles: AxisTitles(
|
||
|
|
sideTitles: SideTitles(
|
||
|
|
showTitles: true,
|
||
|
|
reservedSize: 30,
|
||
|
|
getTitlesWidget: (value, meta) {
|
||
|
|
if (value.toInt() < stats.progressData.length) {
|
||
|
|
final data = stats.progressData[value.toInt()];
|
||
|
|
return Text(
|
||
|
|
'${data.date.month}/${data.date.day}',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 10,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return const Text('');
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
rightTitles: const AxisTitles(
|
||
|
|
sideTitles: SideTitles(showTitles: false),
|
||
|
|
),
|
||
|
|
topTitles: const AxisTitles(
|
||
|
|
sideTitles: SideTitles(showTitles: false),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
borderData: FlBorderData(show: false),
|
||
|
|
lineBarsData: [
|
||
|
|
LineChartBarData(
|
||
|
|
spots: stats.progressData
|
||
|
|
.asMap()
|
||
|
|
.entries
|
||
|
|
.map((entry) => FlSpot(
|
||
|
|
entry.key.toDouble(),
|
||
|
|
entry.value.averageScore,
|
||
|
|
))
|
||
|
|
.toList(),
|
||
|
|
isCurved: true,
|
||
|
|
color: Theme.of(context).primaryColor,
|
||
|
|
barWidth: 3,
|
||
|
|
dotData: const FlDotData(show: false),
|
||
|
|
belowBarData: BarAreaData(
|
||
|
|
show: true,
|
||
|
|
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildLearningGoals(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
_buildGoalItem(
|
||
|
|
'每周练习',
|
||
|
|
'${stats.totalSessions}',
|
||
|
|
'7',
|
||
|
|
Icons.calendar_today,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
_buildGoalItem(
|
||
|
|
'平均分数',
|
||
|
|
'${stats.averageScore.toStringAsFixed(1)}',
|
||
|
|
'85.0',
|
||
|
|
Icons.star,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildGoalItem(String title, String current, String target, IconData icon) {
|
||
|
|
final currentValue = double.tryParse(current) ?? 0;
|
||
|
|
final targetValue = double.tryParse(target) ?? 1;
|
||
|
|
final progress = (currentValue / targetValue).clamp(0.0, 1.0);
|
||
|
|
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
icon,
|
||
|
|
size: 16,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Text(
|
||
|
|
title,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const Spacer(),
|
||
|
|
Text(
|
||
|
|
'$current / $target',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
LinearProgressIndicator(
|
||
|
|
value: progress,
|
||
|
|
backgroundColor: Colors.grey[200],
|
||
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||
|
|
progress >= 1.0 ? Colors.green : Theme.of(context).primaryColor,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildAchievements() {
|
||
|
|
final achievements = [
|
||
|
|
{'title': '初学者', 'description': '完成第一次对话', 'earned': true},
|
||
|
|
{'title': '坚持者', 'description': '连续7天练习', 'earned': true},
|
||
|
|
{'title': '进步者', 'description': '平均分达到80分', 'earned': false},
|
||
|
|
{'title': '专家', 'description': '平均分达到90分', 'earned': false},
|
||
|
|
];
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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.builder(
|
||
|
|
shrinkWrap: true,
|
||
|
|
physics: const NeverScrollableScrollPhysics(),
|
||
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
|
|
crossAxisCount: 2,
|
||
|
|
childAspectRatio: 3,
|
||
|
|
crossAxisSpacing: 12,
|
||
|
|
mainAxisSpacing: 12,
|
||
|
|
),
|
||
|
|
itemCount: achievements.length,
|
||
|
|
itemBuilder: (context, index) {
|
||
|
|
final achievement = achievements[index];
|
||
|
|
return _buildAchievementItem(achievement);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildAchievementItem(Map<String, dynamic> achievement) {
|
||
|
|
final earned = achievement['earned'] as bool;
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: earned ? Colors.orange.withOpacity(0.1) : Colors.grey[100],
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
border: Border.all(
|
||
|
|
color: earned ? Colors.orange : Colors.grey[300]!,
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
earned ? Icons.emoji_events : Icons.lock,
|
||
|
|
color: earned ? Colors.orange : Colors.grey[400],
|
||
|
|
size: 20,
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
achievement['title'] as String,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: earned ? Colors.orange : Colors.grey[600],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
achievement['description'] as String,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 10,
|
||
|
|
color: earned ? Colors.orange[700] : Colors.grey[500],
|
||
|
|
),
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildSkillAnalysis(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
if (stats.skillAnalysis != null) ...[
|
||
|
|
_buildAnalysisSection(
|
||
|
|
'优势技能',
|
||
|
|
stats.skillAnalysis!.strengths,
|
||
|
|
Colors.green,
|
||
|
|
Icons.trending_up,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
_buildAnalysisSection(
|
||
|
|
'待改进技能',
|
||
|
|
stats.skillAnalysis!.weaknesses,
|
||
|
|
Colors.orange,
|
||
|
|
Icons.trending_down,
|
||
|
|
),
|
||
|
|
] else
|
||
|
|
const Text(
|
||
|
|
'暂无分析数据',
|
||
|
|
style: TextStyle(color: Colors.grey),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildAnalysisSection(
|
||
|
|
String title,
|
||
|
|
List<String> items,
|
||
|
|
Color color,
|
||
|
|
IconData icon,
|
||
|
|
) {
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
icon,
|
||
|
|
color: color,
|
||
|
|
size: 16,
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Text(
|
||
|
|
title,
|
||
|
|
style: TextStyle(
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: color,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
if (items.isNotEmpty)
|
||
|
|
...items.map((item) => Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 4,
|
||
|
|
height: 4,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: color,
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
item,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
color: Colors.grey[700],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
))
|
||
|
|
else
|
||
|
|
Text(
|
||
|
|
'暂无数据',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
color: Colors.grey[500],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildImprovementSuggestions(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
if (stats.skillAnalysis?.recommendations.isNotEmpty ?? false)
|
||
|
|
...stats.skillAnalysis!.recommendations.map(
|
||
|
|
(suggestion) => _buildSuggestionItem(suggestion),
|
||
|
|
)
|
||
|
|
else
|
||
|
|
const Text(
|
||
|
|
'暂无建议',
|
||
|
|
style: TextStyle(color: Colors.grey),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildSuggestionItem(String suggestion) {
|
||
|
|
return Container(
|
||
|
|
margin: const EdgeInsets.only(bottom: 12),
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.blue[50],
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
border: Border.all(
|
||
|
|
color: Colors.blue[200]!,
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
Icons.lightbulb_outline,
|
||
|
|
color: Colors.blue[600],
|
||
|
|
size: 20,
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
suggestion,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
color: Colors.blue[800],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildLearningHabits(SpeakingStats stats) {
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
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),
|
||
|
|
_buildHabitItem(
|
||
|
|
'最活跃时间',
|
||
|
|
_getMostActiveTime(stats),
|
||
|
|
Icons.access_time,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
_buildHabitItem(
|
||
|
|
'平均会话时长',
|
||
|
|
'${_getAverageSessionDuration(stats)}分钟',
|
||
|
|
Icons.timer,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
_buildHabitItem(
|
||
|
|
'学习频率',
|
||
|
|
'${_getLearningFrequency(stats)}次/周',
|
||
|
|
Icons.repeat,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildHabitItem(String title, String value, IconData icon) {
|
||
|
|
return Row(
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
icon,
|
||
|
|
color: Colors.grey[600],
|
||
|
|
size: 20,
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
title,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
value,
|
||
|
|
style: TextStyle(
|
||
|
|
color: Colors.grey[600],
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildErrorState(String error) {
|
||
|
|
return Center(
|
||
|
|
child: Column(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
Icons.error_outline,
|
||
|
|
size: 64,
|
||
|
|
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(
|
||
|
|
error,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
color: Colors.grey[500],
|
||
|
|
),
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
ElevatedButton(
|
||
|
|
onPressed: () {
|
||
|
|
context.read<SpeakingProvider>().loadStats();
|
||
|
|
},
|
||
|
|
child: const Text('重试'),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildEmptyState() {
|
||
|
|
return Center(
|
||
|
|
child: Column(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
Icons.bar_chart,
|
||
|
|
size: 64,
|
||
|
|
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],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 辅助方法
|
||
|
|
int _calculateStreakDays(SpeakingStats stats) {
|
||
|
|
// 简化实现,实际应该根据连续学习天数计算
|
||
|
|
return stats.progressData.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
String _getSkillDisplayName(String skill) {
|
||
|
|
const skillNames = {
|
||
|
|
'pronunciation': '发音',
|
||
|
|
'fluency': '流利度',
|
||
|
|
'grammar': '语法',
|
||
|
|
'vocabulary': '词汇',
|
||
|
|
'comprehension': '理解力',
|
||
|
|
};
|
||
|
|
return skillNames[skill] ?? skill;
|
||
|
|
}
|
||
|
|
|
||
|
|
Color _getSkillColor(double percentage) {
|
||
|
|
if (percentage >= 0.8) return Colors.green;
|
||
|
|
if (percentage >= 0.6) return Colors.orange;
|
||
|
|
return Colors.red;
|
||
|
|
}
|
||
|
|
|
||
|
|
String _formatDate(DateTime date) {
|
||
|
|
return '${date.month}月${date.day}日';
|
||
|
|
}
|
||
|
|
|
||
|
|
String _getMostActiveTime(SpeakingStats stats) {
|
||
|
|
// 简化实现,实际应该分析学习时间分布
|
||
|
|
return '上午 10:00';
|
||
|
|
}
|
||
|
|
|
||
|
|
int _getAverageSessionDuration(SpeakingStats stats) {
|
||
|
|
if (stats.progressData.isEmpty) return 0;
|
||
|
|
final totalMinutes = stats.progressData
|
||
|
|
.map((data) => data.totalMinutes)
|
||
|
|
.reduce((a, b) => a + b);
|
||
|
|
return totalMinutes ~/ stats.progressData.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
double _getLearningFrequency(SpeakingStats stats) {
|
||
|
|
// 简化实现,实际应该根据时间范围计算
|
||
|
|
return stats.progressData.length / 4.0; // 假设数据跨度4周
|
||
|
|
}
|
||
|
|
}
|