Files
English/kouyu_english/lib/features/lesson/steps/speaking_step.dart
T
shenleiandClaude Opus 5 be70ad2a29 refactor: 将课程各步骤拆分到 features/lesson/steps/
lesson_flow.dart 保留流程状态、公共脚手架与小组件,预习/听/说/读/写/独立表达
六个步骤各自成为 part 文件。代码仅搬移并经 dart format 格式化,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:23:26 +09:00

152 lines
5.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> _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<void> _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,
),
],
),
);
}