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 createState() => _SpeakingStatsScreenState(); } class _SpeakingStatsScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; String _selectedPeriod = '7days'; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().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( 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( _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( 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 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 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().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周 } }