Files
English/kouyu_english/lib/features/lesson/steps/speaking_step.dart
T

262 lines
8.7 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.segmentId,
required this.text,
this.tip = '',
this.chunks = const [],
this.examples = const [],
required this.buildIndex,
required this.onBuildProgress,
required this.keepRecording,
required this.onContinue,
});
final String segmentId;
final String text;
final String tip;
final List<String> chunks;
final List<String> examples;
final int buildIndex;
final ValueChanged<int> onBuildProgress;
final AppState state;
final bool keepRecording;
final ValueChanged<bool> 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),
),
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)),
),
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
if (widget.chunks.length >= 2)
SectionCard(
tint: AppColors.surfaceMuted,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('拆开练(可选)'),
const SizedBox(height: 8),
Text(
widget.buildIndex == 0
? '先听词块,再逐步合成整句。'
: widget.chunks.take(widget.buildIndex).join(' '),
),
TextButton.icon(
onPressed: () async {
final next = widget.buildIndex < widget.chunks.length
? widget.buildIndex + 1
: 1;
await VoiceService.instance.speak(
widget.chunks.take(next).join(' '),
);
widget.onBuildProgress(next);
},
icon: const Icon(Icons.play_arrow),
label: Text(
widget.buildIndex == widget.chunks.length
? '从头再练'
: '听下一段并跟读',
),
),
],
),
),
if (widget.examples.isNotEmpty)
ExpansionTile(
title: Text('更多示范句(${widget.examples.length})'),
children: [
for (final example in widget.examples)
ListTile(
title: LexiconText(example, state: widget.state),
trailing: IconButton(
tooltip: '播放示范句',
icon: const Icon(Icons.volume_up_outlined),
onPressed: () => VoiceService.instance.speak(example),
),
),
],
),
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: recordingPath == null ? '跳过跟读,继续' : '录音完成,继续',
onPressed: (listening || transcribing)
? null
: () => widget.onContinue(recordingPath != null),
),
],
),
);
}
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,
),
],
),
);
}
}