refactor: 将课程各步骤拆分到 features/lesson/steps/

lesson_flow.dart 保留流程状态、公共脚手架与小组件,预习/听/说/读/写/独立表达
六个步骤各自成为 part 文件。代码仅搬移并经 dart format 格式化,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 17:23:26 +09:00
co-authored by Claude Opus 5
parent 8302977351
commit be70ad2a29
7 changed files with 1097 additions and 1025 deletions
@@ -0,0 +1,151 @@
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,
),
],
),
);
}