part of '../lesson_flow.dart'; class _SpeakingStep extends StatefulWidget { const _SpeakingStep({ required this.state, required this.text, required this.keepRecording, required this.onContinue, }); final String text; 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), ), Text( '/es - eɪtʃ - iː - en/', style: Theme.of(context).textTheme.bodyMedium, ), SectionCard( tint: AppColors.surfaceMuted, child: _AudioRow(label: '播放示范音', speech: widget.text), ), const SectionCard( tint: AppColors.warm, child: Text( '字母之间留一个短停顿。先清楚,不必快。', 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) SectionCard(child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。")), const SectionCard( child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'), ), PrimaryButton( label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续', onPressed: widget.onContinue, ), ], ), ); }