262 lines
8.7 KiB
Dart
262 lines
8.7 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|