Files
ai_english/client/lib/features/speaking/screens/pronunciation_practice_screen.dart
2025-11-17 14:09:17 +08:00

869 lines
27 KiB
Dart

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<PronunciationPracticeScreen> createState() => _PronunciationPracticeScreenState();
}
class _PronunciationPracticeScreenState extends State<PronunciationPracticeScreen>
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<double> _waveAnimation;
late Animation<double> _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<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _waveController,
curve: Curves.easeInOut,
));
_scoreAnimation = Tween<double>(
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<String> 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('去设置'),
),
],
),
);
}
}