import 'package:flutter/material.dart'; import '../models/pronunciation_item.dart'; import '../services/speech_recognition_service.dart'; class PronunciationPracticeScreen extends StatefulWidget { final PronunciationItem item; const PronunciationPracticeScreen({ super.key, required this.item, }); @override State createState() => _PronunciationPracticeScreenState(); } class _PronunciationPracticeScreenState extends State with TickerProviderStateMixin { bool _isRecording = false; bool _isPlaying = false; bool _hasRecorded = false; double _currentScore = 0.0; String _feedback = ''; int _attempts = 0; late AnimationController _waveController; late AnimationController _scoreController; late Animation _waveAnimation; late Animation _scoreAnimation; final SpeechRecognitionService _speechService = SpeechRecognitionService(); PronunciationResult? _lastResult; String _recognizedText = ''; double _currentVolume = 0.0; @override void initState() { super.initState(); _initAnimations(); _initSpeechRecognition(); } void _initSpeechRecognition() { // 监听音量变化 _speechService.volumeStream.listen((volume) { if (mounted) { setState(() { _currentVolume = volume; }); } }); // 监听识别结果 _speechService.recognitionStream.listen((text) { if (mounted) { setState(() { _recognizedText = text; }); } }); } void _initAnimations() { _waveController = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, ); _scoreController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); _waveAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _waveController, curve: Curves.easeInOut, )); _scoreAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _scoreController, curve: Curves.elasticOut, )); } @override void dispose() { _waveController.dispose(); _scoreController.dispose(); _speechService.stopListening(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F7FA), appBar: AppBar( title: Text(widget.item.type.displayName), backgroundColor: Colors.white, foregroundColor: Colors.black, elevation: 0, actions: [ IconButton( icon: const Icon(Icons.info_outline), onPressed: _showTipsDialog, ), ], ), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTextCard(), const SizedBox(height: 20), if (widget.item.type != PronunciationType.sentence && widget.item.phonetic.isNotEmpty) _buildPhoneticCard(), const SizedBox(height: 20), _buildRecordingArea(), const SizedBox(height: 20), if (_hasRecorded) _buildScoreCard(), const SizedBox(height: 20), _buildTipsCard(), ], ), ), ); } Widget _buildTextCard() { return Card( elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Container( width: double.infinity, padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( widget.item.type.icon, style: const TextStyle(fontSize: 24), ), const SizedBox(width: 12), Text( widget.item.category, style: const TextStyle( fontSize: 14, color: Colors.grey, fontWeight: FontWeight.w500, ), ), const Spacer(), _buildDifficultyBadge(widget.item.difficulty), ], ), const SizedBox(height: 16), Text( widget.item.text, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, height: 1.3, ), ), ], ), ), ); } Widget _buildPhoneticCard() { return Card( elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Container( width: double.infinity, padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon( Icons.record_voice_over, color: Colors.blue, ), const SizedBox(width: 8), const Text( '音标', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: Icon( _isPlaying ? Icons.stop : Icons.play_arrow, color: Colors.blue, ), onPressed: _playStandardAudio, ), ], ), const SizedBox(height: 12), Text( widget.item.phonetic, style: const TextStyle( fontSize: 20, color: Colors.blue, fontFamily: 'monospace', fontWeight: FontWeight.w500, ), ), ], ), ), ); } Widget _buildRecordingArea() { return Card( elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Container( width: double.infinity, padding: const EdgeInsets.all(24), child: Column( children: [ const Text( '点击录音按钮开始练习', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 24), AnimatedBuilder( animation: _waveAnimation, builder: (context, child) { return Container( width: 120, height: 120, decoration: BoxDecoration( shape: BoxShape.circle, color: _isRecording ? Colors.red.withOpacity(0.1) : Colors.blue.withOpacity(0.1), border: Border.all( color: _isRecording ? Colors.red : Colors.blue, width: 2, ), ), child: Stack( alignment: Alignment.center, children: [ if (_isRecording) ...List.generate(3, (index) { return AnimatedContainer( duration: Duration(milliseconds: 500 + index * 200), width: 120 + (index * 20) * _waveAnimation.value, height: 120 + (index * 20) * _waveAnimation.value, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.red.withOpacity(0.3 - index * 0.1), width: 1, ), ), ); }), IconButton( iconSize: 48, icon: Icon( _isRecording ? Icons.stop : Icons.mic, color: _isRecording ? Colors.red : Colors.blue, ), onPressed: _toggleRecording, ), ], ), ); }, ), const SizedBox(height: 16), Text( _isRecording ? '录音中...' : '点击开始录音', style: TextStyle( fontSize: 14, color: _isRecording ? Colors.red : Colors.grey, ), ), if (_isRecording) ...[ const SizedBox(height: 12), // 音量指示器 Container( width: 200, height: 4, decoration: BoxDecoration( borderRadius: BorderRadius.circular(2), color: Colors.grey[300], ), child: FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: _currentVolume, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(2), color: Colors.red, ), ), ), ), const SizedBox(height: 8), Text( '音量: ${(_currentVolume * 100).toInt()}%', style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ], if (_recognizedText.isNotEmpty) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.blue.withOpacity(0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '识别结果:', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue, ), ), const SizedBox(height: 4), Text( _recognizedText, style: const TextStyle( fontSize: 14, color: Colors.black87, ), ), ], ), ), ], if (_attempts > 0) ...[ const SizedBox(height: 12), Text( '已练习 $_attempts 次', style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ], ], ), ), ); } Widget _buildScoreCard() { return AnimatedBuilder( animation: _scoreAnimation, builder: (context, child) { return Transform.scale( scale: 0.8 + 0.2 * _scoreAnimation.value, child: Card( elevation: 4, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Container( width: double.infinity, padding: const EdgeInsets.all(24), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: LinearGradient( colors: [ _getScoreColor().withOpacity(0.1), _getScoreColor().withOpacity(0.05), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Column( children: [ Row( children: [ Icon( Icons.score, color: _getScoreColor(), ), const SizedBox(width: 8), const Text( '发音评分', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( '${(_currentScore * _scoreAnimation.value).toInt()}', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: _getScoreColor(), ), ), Text( '/100', style: TextStyle( fontSize: 24, color: _getScoreColor(), ), ), ], ), const SizedBox(height: 8), Text( _getScoreText(), style: TextStyle( fontSize: 16, color: _getScoreColor(), fontWeight: FontWeight.w500, ), ), if (_lastResult != null) ...[ const SizedBox(height: 16), // 准确度显示 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '准确度: ${_lastResult!.accuracy.displayName}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: _getAccuracyColor(_lastResult!.accuracy), ), ), Text( _lastResult!.accuracy.emoji, style: const TextStyle(fontSize: 20), ), ], ), const SizedBox(height: 12), // 详细分析 if (_lastResult!.detailedAnalysis.isNotEmpty) ...[ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '详细分析:', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue, ), ), const SizedBox(height: 4), Column( crossAxisAlignment: CrossAxisAlignment.start, children: _lastResult!.detailedAnalysis.entries.map((entry) { return Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '${entry.key}: ${entry.value.toInt()}%', style: const TextStyle( fontSize: 12, color: Colors.black87, ), ), ); }).toList(), ), ], ), ), const SizedBox(height: 8), ], // 改进建议 if (_lastResult!.suggestions.isNotEmpty) ...[ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '改进建议:', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.orange, ), ), const SizedBox(height: 4), ...(_lastResult!.suggestions.take(2).map((suggestion) => Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '• $suggestion', style: const TextStyle( fontSize: 12, color: Colors.black87, ), ), ), )), ], ), ), const SizedBox(height: 8), ], // 反馈 if (_feedback.isNotEmpty) ...[ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.grey.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Text( _feedback, style: const TextStyle( fontSize: 14, color: Colors.grey, ), textAlign: TextAlign.center, ), ), ], ], const SizedBox(height: 16), ElevatedButton( onPressed: _toggleRecording, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: const Text('再次练习'), ), ], ), ), ), ); }, ); } Widget _buildTipsCard() { return Card( elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Container( width: double.infinity, padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon( Icons.lightbulb_outline, color: Colors.orange, ), SizedBox(width: 8), Text( '发音提示', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 12), ...widget.item.tips.map((tip) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '• ', style: TextStyle( color: Colors.orange, fontWeight: FontWeight.bold, ), ), Expanded( child: Text( tip, style: const TextStyle( fontSize: 14, height: 1.4, ), ), ), ], ), )), ], ), ), ); } Widget _buildDifficultyBadge(DifficultyLevel difficulty) { Color color; switch (difficulty) { case DifficultyLevel.beginner: color = Colors.green; break; case DifficultyLevel.intermediate: color = Colors.orange; break; case DifficultyLevel.advanced: color = Colors.red; break; } 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( '${difficulty.displayName} ${difficulty.code}', style: TextStyle( fontSize: 12, color: color, fontWeight: FontWeight.bold, ), ), ); } Color _getScoreColor() { if (_currentScore >= 80) return Colors.green; if (_currentScore >= 60) return Colors.orange; return Colors.red; } String _getScoreText() { if (_currentScore >= 90) return '优秀!'; if (_currentScore >= 80) return '良好'; if (_currentScore >= 70) return '一般'; if (_currentScore >= 60) return '需要改进'; return '继续努力'; } Color _getAccuracyColor(AccuracyLevel accuracy) { switch (accuracy) { case AccuracyLevel.excellent: return Colors.green; case AccuracyLevel.good: return Colors.blue; case AccuracyLevel.fair: return Colors.orange; case AccuracyLevel.needsImprovement: return Colors.amber; case AccuracyLevel.poor: return Colors.red; } } void _playStandardAudio() { setState(() { _isPlaying = !_isPlaying; }); // TODO: 实现音频播放功能 // 这里应该播放标准发音音频 // 模拟播放时间 if (_isPlaying) { Future.delayed(const Duration(seconds: 2), () { if (mounted) { setState(() { _isPlaying = false; }); } }); } } void _toggleRecording() async { if (_isRecording) { // 停止录音 setState(() { _isRecording = false; }); _waveController.stop(); _waveController.reset(); await _speechService.stopListening(); _analyzeRecording(); } else { // 开始录音 final hasPermission = await _speechService.checkMicrophonePermission(); if (!hasPermission) { final granted = await _speechService.requestMicrophonePermission(); if (!granted) { _showPermissionDialog(); return; } } final started = await _speechService.startListening(); if (started) { setState(() { _isRecording = true; _recognizedText = ''; }); _waveController.repeat(); } } } void _analyzeRecording() async { try { final result = await _speechService.analyzePronunciation( _recognizedText, widget.item, ); setState(() { _attempts++; _lastResult = result; _currentScore = result.score; _hasRecorded = true; _feedback = result.feedback; }); _scoreController.forward(); } catch (e) { // 如果分析失败,显示错误信息 setState(() { _feedback = '分析失败,请重试'; _hasRecorded = false; }); } } String _generateFeedback() { List feedbacks = [ '发音基本准确,注意语调的变化', '重音位置正确,继续保持', '音素发音清晰,语速可以稍微放慢', '整体表现不错,注意连读的处理', '发音标准,语调自然', ]; return feedbacks[DateTime.now().millisecond % feedbacks.length]; } void _showTipsDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('发音提示'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: widget.item.tips.map((tip) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '• ', style: TextStyle( color: Colors.orange, fontWeight: FontWeight.bold, ), ), Expanded( child: Text( tip, style: const TextStyle(fontSize: 14), ), ), ], ), )).toList(), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('知道了'), ), ], ), ); } void _showPermissionDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('需要麦克风权限'), content: const Text('为了进行发音练习,需要访问您的麦克风。请在设置中允许麦克风权限。'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('取消'), ), TextButton( onPressed: () { Navigator.pop(context); // TODO: 打开应用设置页面 }, child: const Text('去设置'), ), ], ), ); } }