part of '../lesson_flow.dart'; class _SpeakingStep extends StatefulWidget { const _SpeakingStep({ required this.state, required this.text, this.tip = '', required this.keepRecording, required this.onContinue, }); final String text; final String tip; final AppState state; final bool keepRecording; final VoidCallback onContinue; @override State<_SpeakingStep> createState() => _SpeakingStepState(); } class _SpeakingStepState extends State<_SpeakingStep> with VoiceAnswerMixin<_SpeakingStep> { String transcript = ''; @override AppState get voiceState => widget.state; @override void dispose() { disposeVoiceAnswer(keepRecording: widget.keepRecording); super.dispose(); } Future _toggleMic() async { if (aiVoiceRecording) { await finishVoiceInput( noSpeech: '未识别到清晰发音,请重试或点击“播放示范音”。', onTranscript: (text) => transcript = text, ); return; } await VoiceService.instance.stopRecordingPlayback(); if (mounted) setState(() => playingRecording = false); if (!widget.keepRecording) { await VoiceService.instance.deleteRecording(recordingPath); } final recordStarted = await startVoiceInput(); if (!recordStarted || !mounted) return; setState(() => recordingPath = null); showVoiceMessage('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。'); } Future _togglePlayRecording() async { if (playingRecording) { await VoiceService.instance.stopRecordingPlayback(); if (mounted) setState(() => playingRecording = false); return; } if (recordingPath == null) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('请先使用麦克风跟读,录音完成后即可播放。'))); return; } await playRecording(); } @override Widget build(BuildContext context) => _LessonScaffold( step: 3, child: SpacedColumn( children: [ const Eyebrow('跟读'), Text('先听,再说。', style: Theme.of(context).textTheme.headlineMedium), LexiconText( widget.text, state: widget.state, textAlign: TextAlign.center, style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w600), ), SectionCard( tint: AppColors.surfaceMuted, child: _AudioRow(label: '播放示范音', speech: widget.text), ), if (widget.tip.isNotEmpty) SectionCard( tint: AppColors.warm, child: Text( widget.tip, style: TextStyle(color: AppColors.warmInk), ), ), SecondaryButton( label: transcribing ? '正在 AI 识别发音…' : (listening ? '停止录音并识别' : '使用麦克风跟读'), onPressed: transcribing ? null : _toggleMic, ), SecondaryButton( label: playingRecording ? '停止播放' : '播放跟读', onPressed: (listening || transcribing) ? null : _togglePlayRecording, ), if (recordingPath != null) SectionCard( tint: AppColors.softGreen, child: SpacedColumn( spacing: 8, children: [ Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: (listening || transcribing) ? null : _togglePlayRecording, icon: Icon( playingRecording ? Icons.stop : Icons.play_arrow, ), label: Text(playingRecording ? '停止播放' : '播放跟读'), ), ), const SizedBox(width: 8), IconButton( tooltip: '删除录音', onPressed: (listening || transcribing) ? null : deleteRecording, icon: const Icon(Icons.delete_outline), ), ], ), ], ), ), if (transcript.isNotEmpty) _SpokenComparisonCard(expected: widget.text, transcript: transcript), const SectionCard( child: Text( '转写只反映机器听成了什么,不等于发音评分;没听出的词可以再听示范、重说一次。这一步只算跟读练习,不作为独立口语证据。', ), ), PrimaryButton( label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续', onPressed: widget.onContinue, ), ], ), ); } class _SpokenComparisonCard extends StatelessWidget { const _SpokenComparisonCard({ required this.expected, required this.transcript, }); final String expected; final String transcript; @override Widget build(BuildContext context) { final result = compareSpoken(expected, transcript); final summary = result.matches ? '每个词都被听出来了。' : result.close ? '大部分词都被听出来了,橙色的词再练一次。' : '有几个词没被听出来:先听示范,再慢一点说。'; return SectionCard( tint: result.close ? AppColors.softGreen : AppColors.warm, child: SpacedColumn( spacing: 8, children: [ Text('听出 ${result.heardCount}/${result.total} 个词。$summary'), Wrap( spacing: 6, runSpacing: 6, children: [ for (final word in result.words) Text( word.text, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: word.heard ? AppColors.green : AppColors.warmInk, decoration: word.heard ? TextDecoration.none : TextDecoration.underline, ), ), ], ), Text( '设备转写:$transcript', style: Theme.of(context).textTheme.bodySmall, ), ], ), ); } }