对话页、AI 补练页、课程跟读步骤、独立表达步骤和评估页各自复制了一套 "录音 → AI 转写 → 填入答案"的流程,其中 3 页还复制了"录音回听/播放/删除" 的按钮和方法。现在统一到 lib/widgets/voice_answer.dart: - VoiceAnswerMixin:startVoiceInput / finishVoiceInput / toggleRecording / playRecording / deleteRecording / disposeVoiceAnswer,状态字段名沿用各页原名, 页面的 build 代码基本不动; - RecordingControls:录音回听按钮行。 用户可见的变化: - "未识别到语音"提示统一为「未识别到清晰语音,请再试一次或直接输入文字。」 (对话页、补练页、独立表达步骤、评估页原本各有不同说法;跟读步骤保留 原来提示"播放示范音"的文案)。麦克风不可用、开始录音的提示保持各页原样。 - 对话页的录音回听按钮与播放按钮之间增加 8px 间距,与其他页面一致。 - 删除录音时先停止正在播放的录音(原来只有跟读步骤这样做)。 - 离开页面时,如果还在录音或设备识别中,会先停止;不保留录音时一并删除 这段录音文件。原来课程两个步骤离开时不停止录音,各页离开时也不停止 "录音回听"的录音。 flutter analyze 无问题,flutter test 142 个测试通过;现有测试未覆盖麦克风流程。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1396 lines
45 KiB
Dart
1396 lines
45 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../core/app_state.dart';
|
||
import '../../core/ai_service.dart';
|
||
import '../../core/app_theme.dart';
|
||
import '../../core/models.dart';
|
||
import '../../core/seed_courses.dart';
|
||
import '../../core/voice_service.dart';
|
||
import '../../core/writing_feedback.dart';
|
||
import '../../widgets/app_widgets.dart';
|
||
import '../../widgets/lexicon_lookup.dart';
|
||
import '../../widgets/voice_answer.dart';
|
||
|
||
class LessonFlow extends StatefulWidget {
|
||
const LessonFlow({
|
||
super.key,
|
||
required this.state,
|
||
required this.onOpenDialogue,
|
||
required this.onFinish,
|
||
});
|
||
|
||
final AppState state;
|
||
final VoidCallback onOpenDialogue;
|
||
final VoidCallback onFinish;
|
||
|
||
@override
|
||
State<LessonFlow> createState() => _LessonFlowState();
|
||
}
|
||
|
||
class _LessonFlowState extends State<LessonFlow> {
|
||
final writingController = TextEditingController();
|
||
final independentController = TextEditingController();
|
||
int selectedAnswer = -1;
|
||
int previewIndex = 0;
|
||
bool listeningAudioPlayed = false;
|
||
bool showWritingHelp = true;
|
||
bool showIndependentHelp = false;
|
||
LessonSegment get segment {
|
||
final lesson = lessonById(widget.state.activeLessonId);
|
||
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
|
||
}
|
||
|
||
LessonActivity get activity =>
|
||
activityBySegmentId(segment.id, widget.state.activeLessonId);
|
||
|
||
List<VocabularyItem> get previewItems =>
|
||
vocabularyBySegmentId(segment.id, widget.state.activeLessonId);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
writingController.text = widget.state.lessonWritingDraft;
|
||
independentController.text = widget.state.independentAttemptDraft;
|
||
previewIndex = widget.state.previewIndex
|
||
.clamp(0, previewItems.length - 1)
|
||
.toInt();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
writingController.dispose();
|
||
independentController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final lesson = lessonById(widget.state.activeLessonId);
|
||
final Widget content;
|
||
switch (widget.state.lessonStep) {
|
||
case LessonStep.preview:
|
||
content = _PreviewStep(
|
||
state: widget.state,
|
||
item: previewItems[previewIndex],
|
||
position: previewIndex + 1,
|
||
total: previewItems.length,
|
||
onLookup: () => showLexiconLookup(
|
||
context,
|
||
state: widget.state,
|
||
initialText: previewItems[previewIndex].word,
|
||
),
|
||
onNext: () {
|
||
if (previewIndex < previewItems.length - 1) {
|
||
setState(() => previewIndex += 1);
|
||
widget.state.setPreviewIndex(previewIndex);
|
||
} else {
|
||
widget.state.completePreview();
|
||
}
|
||
},
|
||
onSkip: widget.state.completePreview,
|
||
);
|
||
case LessonStep.listening:
|
||
final listeningOptions = shuffledOptions(
|
||
activity.answers,
|
||
'${activity.listening}-listening',
|
||
);
|
||
content = _ListeningStep(
|
||
state: widget.state,
|
||
activity: activity,
|
||
options: listeningOptions,
|
||
correctAnswer: activity.answers.first,
|
||
selectedAnswer: selectedAnswer,
|
||
audioPlayed: listeningAudioPlayed,
|
||
onSelected: (value) => setState(() => selectedAnswer = value),
|
||
onPlayed: () => setState(() => listeningAudioPlayed = true),
|
||
onLookup: () => showLexiconLookup(
|
||
context,
|
||
state: widget.state,
|
||
initialText: activity.listening,
|
||
),
|
||
onContinue:
|
||
selectedAnswer >= 0 &&
|
||
listeningOptions[selectedAnswer] == activity.answers.first
|
||
? widget.state.completeListening
|
||
: null,
|
||
);
|
||
case LessonStep.speaking:
|
||
content = _SpeakingStep(
|
||
state: widget.state,
|
||
text: activity.speaking,
|
||
keepRecording: widget.state.keepRecordings,
|
||
onContinue: () => widget.state.completeSpeaking(assisted: true),
|
||
);
|
||
case LessonStep.reading:
|
||
content = _ReadingStep(
|
||
state: widget.state,
|
||
activity: activity,
|
||
onLookup: () => showLexiconLookup(
|
||
context,
|
||
state: widget.state,
|
||
initialText: activity.reading,
|
||
),
|
||
onContinue: widget.state.completeReading,
|
||
);
|
||
case LessonStep.writing:
|
||
content = _WritingStep(
|
||
state: widget.state,
|
||
lessonId: widget.state.activeLessonId,
|
||
segmentId: segment.id,
|
||
activity: activity,
|
||
controller: writingController,
|
||
showHelp: showWritingHelp,
|
||
canContinue: writingController.text.trim().isNotEmpty,
|
||
onChanged: () {
|
||
widget.state.setLessonWritingDraft(writingController.text);
|
||
setState(() {});
|
||
},
|
||
onToggleHelp: () =>
|
||
setState(() => showWritingHelp = !showWritingHelp),
|
||
onContinue: (usedAiFeedback) => widget.state.completeWriting(
|
||
assisted: showWritingHelp || usedAiFeedback,
|
||
rawAnswer: writingController.text.trim(),
|
||
),
|
||
);
|
||
case LessonStep.dialogue:
|
||
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
|
||
case LessonStep.independent:
|
||
content = _IndependentStep(
|
||
state: widget.state,
|
||
segmentId: segment.id,
|
||
keepRecording: widget.state.keepRecordings,
|
||
activity: activity,
|
||
controller: independentController,
|
||
showHelp: showIndependentHelp,
|
||
canContinue: independentController.text.trim().isNotEmpty,
|
||
onChanged: () {
|
||
widget.state.setIndependentAttemptDraft(independentController.text);
|
||
setState(() {});
|
||
},
|
||
onNeedHelp: () => setState(() => showIndependentHelp = true),
|
||
onLookup: () {
|
||
setState(() => showIndependentHelp = true);
|
||
showLexiconLookup(
|
||
context,
|
||
state: widget.state,
|
||
initialText: activity.independentPrompt,
|
||
);
|
||
},
|
||
onContinue: (spoken, recordingPath) =>
|
||
widget.state.completeIndependentAttempt(
|
||
assisted: showIndependentHelp,
|
||
spoken: spoken,
|
||
rawAnswer: independentController.text.trim(),
|
||
recordingPath: recordingPath,
|
||
),
|
||
onLater: () =>
|
||
widget.state.completeIndependentAttempt(assisted: true),
|
||
);
|
||
case LessonStep.complete:
|
||
content = _CompletionStep(
|
||
assisted: widget.state.independentAttemptAssisted,
|
||
isFinalSegment:
|
||
widget.state.activeSegmentIndexFor(lesson.id) ==
|
||
lesson.segments.length - 1,
|
||
nextSegmentNumber: widget.state.activeSegmentIndexFor(lesson.id) + 2,
|
||
onFinish: () {
|
||
widget.state.finishCurrentLessonSegment();
|
||
widget.onFinish();
|
||
},
|
||
);
|
||
}
|
||
return _LessonScope(
|
||
title:
|
||
'第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段',
|
||
onExit: widget.onFinish,
|
||
child: content,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LessonScaffold extends StatelessWidget {
|
||
const _LessonScaffold({required this.step, required this.child});
|
||
|
||
final int step;
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => AppPage(
|
||
appBar: AppBar(
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
tooltip: "退出课程",
|
||
onPressed: () {
|
||
final onExit = _LessonScope.exitOf(context);
|
||
if (onExit != null) {
|
||
onExit();
|
||
} else if (Navigator.canPop(context)) {
|
||
Navigator.pop(context);
|
||
}
|
||
},
|
||
),
|
||
title: Text(_LessonScope.of(context)),
|
||
),
|
||
child: SpacedColumn(
|
||
spacing: 16,
|
||
children: [
|
||
Row(
|
||
children: List.generate(
|
||
6,
|
||
(index) => Expanded(
|
||
child: Container(
|
||
height: 6,
|
||
margin: EdgeInsets.only(right: index == 5 ? 0 : 5),
|
||
decoration: BoxDecoration(
|
||
color: index < step ? AppColors.green : AppColors.line,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _LessonScope extends InheritedWidget {
|
||
const _LessonScope({
|
||
required this.title,
|
||
this.onExit,
|
||
required super.child,
|
||
});
|
||
|
||
final String title;
|
||
final VoidCallback? onExit;
|
||
|
||
static String of(BuildContext context) =>
|
||
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.title ??
|
||
'A0 课程练习';
|
||
|
||
static VoidCallback? exitOf(BuildContext context) =>
|
||
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.onExit;
|
||
|
||
@override
|
||
bool updateShouldNotify(_LessonScope oldWidget) =>
|
||
title != oldWidget.title || onExit != oldWidget.onExit;
|
||
}
|
||
|
||
class _PreviewStep extends StatelessWidget {
|
||
const _PreviewStep({
|
||
required this.state,
|
||
required this.item,
|
||
required this.position,
|
||
required this.total,
|
||
required this.onLookup,
|
||
required this.onNext,
|
||
required this.onSkip,
|
||
});
|
||
|
||
final VocabularyItem item;
|
||
final AppState state;
|
||
final int position;
|
||
final int total;
|
||
final VoidCallback onLookup;
|
||
final VoidCallback onNext;
|
||
final VoidCallback onSkip;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _LessonScaffold(
|
||
step: 1,
|
||
child: SpacedColumn(
|
||
children: [
|
||
Eyebrow('先认识今天的词 · $position / $total'),
|
||
Text('后面会遇到这些词。', style: Theme.of(context).textTheme.headlineMedium),
|
||
const Text('先听一遍、知道意思就够了,不用马上背会。'),
|
||
SectionCard(
|
||
tint: AppColors.softGreen,
|
||
child: SpacedColumn(
|
||
children: [
|
||
Text(
|
||
item.word,
|
||
style: const TextStyle(
|
||
fontSize: 28,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
if (item.ipa != null)
|
||
Text(item.ipa!, style: Theme.of(context).textTheme.bodyMedium),
|
||
Text(item.meaning, style: const TextStyle(fontSize: 17)),
|
||
_AudioRow(label: '播放示范音', speech: item.word),
|
||
LexiconText(item.example, state: state),
|
||
Text(item.exampleMeaning),
|
||
],
|
||
),
|
||
),
|
||
Wrap(
|
||
spacing: 8,
|
||
children: [ActionChip(label: const Text('查词'), onPressed: onLookup)],
|
||
),
|
||
PrimaryButton(
|
||
label: position == total ? '进入课程' : '认识了,下一个',
|
||
onPressed: onNext,
|
||
),
|
||
TextButton(onPressed: onSkip, child: const Text('跳过,直接进入课程')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _ListeningStep extends StatelessWidget {
|
||
const _ListeningStep({
|
||
required this.state,
|
||
required this.activity,
|
||
required this.options,
|
||
required this.correctAnswer,
|
||
required this.selectedAnswer,
|
||
required this.audioPlayed,
|
||
required this.onSelected,
|
||
required this.onPlayed,
|
||
required this.onLookup,
|
||
required this.onContinue,
|
||
});
|
||
|
||
final LessonActivity activity;
|
||
final AppState state;
|
||
final List<String> options;
|
||
final String correctAnswer;
|
||
|
||
final int selectedAnswer;
|
||
final bool audioPlayed;
|
||
final ValueChanged<int> onSelected;
|
||
final VoidCallback onPlayed;
|
||
final VoidCallback onLookup;
|
||
final VoidCallback? onContinue;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final answers = options;
|
||
return _LessonScaffold(
|
||
step: 2,
|
||
child: SpacedColumn(
|
||
spacing: 14,
|
||
children: [
|
||
const Eyebrow('听一听'),
|
||
Text(
|
||
activity.listeningQuestion,
|
||
style: Theme.of(context).textTheme.headlineMedium,
|
||
),
|
||
SectionCard(
|
||
tint: AppColors.surfaceMuted,
|
||
child: _AudioRow(
|
||
label: audioPlayed ? '再播放一次' : '播放问题',
|
||
speech: activity.listening,
|
||
onPlayed: onPlayed,
|
||
),
|
||
),
|
||
TextButton.icon(
|
||
onPressed: onLookup,
|
||
icon: const Icon(Icons.menu_book_outlined),
|
||
label: const Text('查看词或短语'),
|
||
),
|
||
if (audioPlayed)
|
||
SectionCard(child: LexiconText(activity.listening, state: state)),
|
||
for (var index = 0; index < answers.length; index++)
|
||
SectionCard(
|
||
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||
onTap: () {
|
||
onSelected(index);
|
||
if (!audioPlayed) {
|
||
onPlayed();
|
||
}
|
||
},
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
selectedAnswer == index
|
||
? Icons.radio_button_checked
|
||
: Icons.radio_button_off,
|
||
color: selectedAnswer == index
|
||
? AppColors.green
|
||
: AppColors.muted,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(answers[index]),
|
||
],
|
||
),
|
||
),
|
||
PrimaryButton(
|
||
label: selectedAnswer >= 0
|
||
? '检查并继续'
|
||
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
|
||
onPressed: onContinue,
|
||
),
|
||
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||
const Text(
|
||
'再听一次,选择正确答案。',
|
||
style: TextStyle(color: AppColors.warmInk),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _ReadingStep extends StatefulWidget {
|
||
const _ReadingStep({
|
||
required this.state,
|
||
required this.activity,
|
||
required this.onLookup,
|
||
required this.onContinue,
|
||
});
|
||
final LessonActivity activity;
|
||
final AppState state;
|
||
final VoidCallback onLookup;
|
||
final VoidCallback onContinue;
|
||
|
||
@override
|
||
State<_ReadingStep> createState() => _ReadingStepState();
|
||
}
|
||
|
||
class _ReadingStepState extends State<_ReadingStep> {
|
||
final controller = TextEditingController();
|
||
int? selectedOptionIndex;
|
||
bool showAnswer = false;
|
||
|
||
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
|
||
late final List<String> options = shuffledOptions(
|
||
widget.activity.readingOptions,
|
||
'${widget.activity.readingQuestion}-reading',
|
||
);
|
||
|
||
@override
|
||
void dispose() {
|
||
controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
bool _isOptionCorrect(int index) {
|
||
if (options.isEmpty || index < 0 || index >= options.length) {
|
||
return false;
|
||
}
|
||
final option = options[index].trim();
|
||
final answer = widget.activity.readingAnswer.trim();
|
||
if (option.toLowerCase() == answer.toLowerCase()) return true;
|
||
|
||
final normOption = option
|
||
.toLowerCase()
|
||
.replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fa5]'), '');
|
||
final normAnswer = answer
|
||
.toLowerCase()
|
||
.replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fa5]'), '');
|
||
return normOption.isNotEmpty &&
|
||
normAnswer.isNotEmpty &&
|
||
(normOption.contains(normAnswer) || normAnswer.contains(normOption));
|
||
}
|
||
|
||
bool get isOptionMode => options.isNotEmpty;
|
||
|
||
bool get isCorrect {
|
||
if (isOptionMode) {
|
||
return selectedOptionIndex != null && _isOptionCorrect(selectedOptionIndex!);
|
||
}
|
||
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
|
||
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||
'',
|
||
);
|
||
final response = controller.text.toLowerCase().replaceAll(
|
||
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||
'',
|
||
);
|
||
// 只接受写全了答案的输入:过去反向的 answer.contains(response) 让单个字母
|
||
// 也能判对('a' 通过 'A book')。
|
||
return response.isNotEmpty && answer.isNotEmpty && response.contains(answer);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final hasSelected = selectedOptionIndex != null;
|
||
final answeredCorrectly = isCorrect;
|
||
|
||
return _LessonScaffold(
|
||
step: 4,
|
||
child: SpacedColumn(
|
||
children: [
|
||
const Eyebrow('读一读'),
|
||
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
|
||
SectionCard(
|
||
tint: AppColors.softGreen,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Row(
|
||
children: [
|
||
Icon(Icons.chat_bubble_outline, size: 16, color: AppColors.green),
|
||
SizedBox(width: 6),
|
||
Text(
|
||
'对话内容',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.green,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
InkWell(
|
||
onTap: () => VoiceService.instance.speak(widget.activity.reading),
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.volume_up_outlined, size: 16, color: AppColors.green),
|
||
SizedBox(width: 4),
|
||
Text('朗读对话', style: TextStyle(fontSize: 13, color: AppColors.green)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
LexiconText(
|
||
widget.activity.reading,
|
||
state: widget.state,
|
||
style: const TextStyle(fontSize: 16, height: 1.6),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Row(
|
||
children: [
|
||
TextButton.icon(
|
||
onPressed: widget.onLookup,
|
||
icon: const Icon(Icons.menu_book_outlined, size: 18),
|
||
label: const Text('查词或短语'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
TextButton.icon(
|
||
onPressed: () {
|
||
final lines = widget.activity.reading.split('\n');
|
||
final target = lines.firstWhere(
|
||
(l) => l.trim().isNotEmpty,
|
||
orElse: () => widget.activity.reading,
|
||
).replaceFirst(RegExp(r'^[A-Za-z]+:\s*'), '');
|
||
showLexiconLookup(
|
||
context,
|
||
state: widget.state,
|
||
initialText: target,
|
||
);
|
||
},
|
||
icon: const Icon(Icons.auto_stories_outlined, size: 18),
|
||
label: const Text('句型深度解析'),
|
||
),
|
||
],
|
||
),
|
||
SectionCard(
|
||
tint: AppColors.surfaceMuted,
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.green.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Text(
|
||
'问题',
|
||
style: TextStyle(
|
||
color: AppColors.green,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
widget.activity.readingQuestion,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.ink,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (isOptionMode) ...[
|
||
for (var index = 0; index < options.length; index++) ...[
|
||
SectionCard(
|
||
tint: selectedOptionIndex == index
|
||
? (_isOptionCorrect(index) ? AppColors.softGreen : AppColors.warm)
|
||
: null,
|
||
onTap: () {
|
||
setState(() {
|
||
selectedOptionIndex = index;
|
||
showAnswer = false;
|
||
});
|
||
},
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
selectedOptionIndex == index
|
||
? (_isOptionCorrect(index) ? Icons.check_circle : Icons.cancel_outlined)
|
||
: Icons.radio_button_off,
|
||
color: selectedOptionIndex == index
|
||
? (_isOptionCorrect(index) ? AppColors.green : AppColors.warmInk)
|
||
: AppColors.muted,
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
options[index],
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: selectedOptionIndex == index ? FontWeight.w600 : FontWeight.normal,
|
||
color: selectedOptionIndex == index
|
||
? (_isOptionCorrect(index) ? AppColors.green : AppColors.warmInk)
|
||
: AppColors.ink,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
if (hasSelected && answeredCorrectly)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.softGreen,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: AppColors.green.withValues(alpha: 0.3)),
|
||
),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.check_circle, color: AppColors.green, size: 20),
|
||
SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'回答正确!点击下方按钮继续',
|
||
style: TextStyle(
|
||
color: AppColors.green,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
else if (hasSelected && !answeredCorrectly)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.warm,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: AppColors.warmInk.withValues(alpha: 0.2)),
|
||
),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.help_outline, color: AppColors.warmInk, size: 20),
|
||
SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'不对哦,再仔细观察对话中的关键句子~',
|
||
style: TextStyle(color: AppColors.warmInk, fontSize: 13),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
] else ...[
|
||
TextField(
|
||
controller: controller,
|
||
onChanged: (_) => setState(() {}),
|
||
decoration: const InputDecoration(
|
||
hintText: '用英文输入答案',
|
||
filled: true,
|
||
fillColor: AppColors.surface,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
],
|
||
if (showAnswer)
|
||
SectionCard(
|
||
tint: AppColors.warm,
|
||
child: Text(
|
||
'答案:${widget.activity.readingAnswer}',
|
||
style: const TextStyle(color: AppColors.warmInk),
|
||
),
|
||
),
|
||
if (!showAnswer &&
|
||
((isOptionMode && hasSelected && !answeredCorrectly) ||
|
||
(!isOptionMode && controller.text.isNotEmpty && !answeredCorrectly)))
|
||
TextButton(
|
||
onPressed: () => setState(() => showAnswer = true),
|
||
child: const Text('查看答案后继续学习'),
|
||
),
|
||
PrimaryButton(
|
||
label: showAnswer || answeredCorrectly
|
||
? '继续写一写'
|
||
: (isOptionMode ? '请选择答案' : '检查并继续'),
|
||
onPressed: showAnswer || answeredCorrectly ? widget.onContinue : null,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WritingStep extends StatefulWidget {
|
||
const _WritingStep({
|
||
required this.state,
|
||
required this.lessonId,
|
||
required this.segmentId,
|
||
required this.activity,
|
||
required this.controller,
|
||
required this.showHelp,
|
||
required this.canContinue,
|
||
required this.onChanged,
|
||
required this.onToggleHelp,
|
||
required this.onContinue,
|
||
});
|
||
final String lessonId;
|
||
final AppState state;
|
||
final String segmentId;
|
||
final LessonActivity activity;
|
||
final TextEditingController controller;
|
||
final bool showHelp;
|
||
final bool canContinue;
|
||
final VoidCallback onChanged;
|
||
final VoidCallback onToggleHelp;
|
||
final ValueChanged<bool> onContinue;
|
||
|
||
@override
|
||
State<_WritingStep> createState() => _WritingStepState();
|
||
}
|
||
|
||
class _WritingStepState extends State<_WritingStep> {
|
||
WritingCheckResult? result;
|
||
WritingAiFeedback? aiFeedback;
|
||
String? aiFeedbackError;
|
||
bool requestingAiFeedback = false;
|
||
|
||
void _checkOrContinue() {
|
||
if (result?.complete == true) {
|
||
widget.onContinue(aiFeedback != null);
|
||
return;
|
||
}
|
||
setState(
|
||
() => result = WritingFeedback.check(
|
||
widget.lessonId,
|
||
widget.controller.text,
|
||
segmentId: widget.segmentId,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _requestAiFeedback() async {
|
||
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
|
||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||
return;
|
||
}
|
||
setState(() {
|
||
requestingAiFeedback = true;
|
||
aiFeedbackError = null;
|
||
});
|
||
final feedback = await AiService.instance.writingFeedback(
|
||
provider: widget.state.aiProvider,
|
||
endpoint: widget.state.aiEndpoint,
|
||
model: widget.state.aiModel,
|
||
lessonId: widget.lessonId,
|
||
taskPrompt: widget.activity.writingPrompt,
|
||
answer: widget.controller.text.trim(),
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
requestingAiFeedback = false;
|
||
aiFeedback = feedback;
|
||
aiFeedbackError = feedback == null
|
||
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
|
||
: null;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _LessonScaffold(
|
||
step: 5,
|
||
child: SpacedColumn(
|
||
children: [
|
||
const Eyebrow('写一写'),
|
||
Text(
|
||
widget.activity.writingPrompt,
|
||
style: Theme.of(context).textTheme.headlineMedium,
|
||
),
|
||
SectionCard(
|
||
tint: AppColors.surfaceMuted,
|
||
child: Text(
|
||
'小提示:${grammarNoteForSegment(widget.segmentId, widget.lessonId)}',
|
||
),
|
||
),
|
||
if (widget.showHelp)
|
||
SectionCard(
|
||
tint: AppColors.softGreen,
|
||
child: Text(
|
||
widget.activity.writingExample,
|
||
style: TextStyle(fontSize: 18, height: 1.5),
|
||
),
|
||
),
|
||
TextField(
|
||
controller: widget.controller,
|
||
onChanged: (_) {
|
||
setState(() {
|
||
result = null;
|
||
aiFeedback = null;
|
||
aiFeedbackError = null;
|
||
});
|
||
widget.onChanged();
|
||
},
|
||
minLines: 3,
|
||
maxLines: 4,
|
||
decoration: InputDecoration(
|
||
labelText: '你的答案',
|
||
hintText: widget.showHelp
|
||
? widget.activity.writingExample
|
||
: '请输入完整英文答案',
|
||
filled: true,
|
||
fillColor: AppColors.surface,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: widget.onToggleHelp,
|
||
child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: widget.canContinue && !requestingAiFeedback
|
||
? _requestAiFeedback
|
||
: null,
|
||
icon: requestingAiFeedback
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.auto_awesome_outlined),
|
||
label: Text(requestingAiFeedback ? '正在获取反馈…' : '获取 AI 写作建议(可选)'),
|
||
),
|
||
if (aiFeedback != null)
|
||
SectionCard(
|
||
tint: aiFeedback!.verdict == 'accepted'
|
||
? AppColors.softGreen
|
||
: AppColors.warm,
|
||
child: SpacedColumn(
|
||
spacing: 6,
|
||
children: [
|
||
Text('AI 建议:${aiFeedback!.feedback}'),
|
||
if (aiFeedback!.missing.isNotEmpty)
|
||
Text('还可补充:${aiFeedback!.missing.join('、')}'),
|
||
if (aiFeedback!.suggestion != null)
|
||
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||
const Text(
|
||
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (aiFeedbackError != null)
|
||
SectionCard(
|
||
tint: AppColors.warm,
|
||
child: Text(
|
||
aiFeedbackError!,
|
||
style: const TextStyle(color: AppColors.warmInk),
|
||
),
|
||
),
|
||
if (result != null)
|
||
SectionCard(
|
||
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
|
||
child: Text(
|
||
result!.message,
|
||
style: TextStyle(
|
||
color: result!.complete ? AppColors.green : AppColors.warmInk,
|
||
),
|
||
),
|
||
),
|
||
PrimaryButton(
|
||
label: result?.complete == true ? '进入课程对话' : '检查句子',
|
||
onPressed: widget.canContinue ? _checkOrContinue : null,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _DialoguePendingStep extends StatelessWidget {
|
||
const _DialoguePendingStep({required this.onOpenDialogue});
|
||
final VoidCallback onOpenDialogue;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _LessonScaffold(
|
||
step: 6,
|
||
child: SpacedColumn(
|
||
children: [
|
||
const Eyebrow('课程对话'),
|
||
Text('在情境中用上刚学的句子。', style: Theme.of(context).textTheme.headlineMedium),
|
||
const Text('完成姓名、地点、一个状态或喜好,并反问对方。'),
|
||
PrimaryButton(label: '开始文字对话', onPressed: onOpenDialogue),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _IndependentStep extends StatefulWidget {
|
||
const _IndependentStep({
|
||
required this.state,
|
||
required this.segmentId,
|
||
required this.keepRecording,
|
||
required this.activity,
|
||
required this.controller,
|
||
required this.showHelp,
|
||
required this.canContinue,
|
||
required this.onChanged,
|
||
required this.onNeedHelp,
|
||
required this.onLookup,
|
||
required this.onContinue,
|
||
required this.onLater,
|
||
});
|
||
final AppState state;
|
||
final LessonActivity activity;
|
||
final String segmentId;
|
||
final bool keepRecording;
|
||
final TextEditingController controller;
|
||
final bool showHelp;
|
||
final bool canContinue;
|
||
final VoidCallback onChanged;
|
||
final VoidCallback onNeedHelp;
|
||
final VoidCallback onLookup;
|
||
final void Function(bool spoken, String? recordingPath) onContinue;
|
||
final VoidCallback onLater;
|
||
|
||
@override
|
||
State<_IndependentStep> createState() => _IndependentStepState();
|
||
}
|
||
|
||
class _IndependentStepState extends State<_IndependentStep>
|
||
with VoiceAnswerMixin<_IndependentStep> {
|
||
bool usedVoice = false;
|
||
bool transcriptEdited = false;
|
||
String lastTranscript = '';
|
||
String? validationError;
|
||
|
||
@override
|
||
AppState get voiceState => widget.state;
|
||
|
||
@override
|
||
void dispose() {
|
||
disposeVoiceAnswer(keepRecording: widget.keepRecording);
|
||
super.dispose();
|
||
}
|
||
|
||
void _submit() {
|
||
if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) {
|
||
setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。');
|
||
return;
|
||
}
|
||
widget.onContinue(
|
||
usedVoice && !transcriptEdited,
|
||
widget.keepRecording ? recordingPath : null,
|
||
);
|
||
}
|
||
|
||
Future<void> _toggleMic() async {
|
||
if (aiVoiceRecording) {
|
||
await finishVoiceInput(
|
||
onTranscript: (text) {
|
||
widget.controller.text = text;
|
||
usedVoice = true;
|
||
lastTranscript = text;
|
||
transcriptEdited = false;
|
||
},
|
||
afterTranscribe: widget.onChanged,
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (listening) {
|
||
await VoiceService.instance.stopListening();
|
||
if (mounted) setState(() => listening = false);
|
||
return;
|
||
}
|
||
|
||
final ready = await VoiceService.instance.startListening(
|
||
(text, _) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
widget.controller.text = text;
|
||
usedVoice = true;
|
||
lastTranscript = text;
|
||
transcriptEdited = false;
|
||
});
|
||
widget.onChanged();
|
||
},
|
||
onStatus: (status) {
|
||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||
setState(() => listening = false);
|
||
}
|
||
},
|
||
onError: (err) {
|
||
if (mounted) {
|
||
setState(() => listening = false);
|
||
}
|
||
},
|
||
);
|
||
|
||
if (!ready) {
|
||
final recordStarted = await startVoiceInput();
|
||
if (recordStarted && mounted) {
|
||
showVoiceMessage('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mounted) setState(() => listening = ready);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _LessonScaffold(
|
||
step: 6,
|
||
child: SpacedColumn(
|
||
children: [
|
||
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
||
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
||
Text(widget.activity.independentPrompt),
|
||
if (widget.showHelp)
|
||
SectionCard(
|
||
tint: AppColors.warm,
|
||
child: Text(
|
||
'帮助:${widget.activity.independentHelp}',
|
||
style: TextStyle(color: AppColors.warmInk),
|
||
),
|
||
),
|
||
TextField(
|
||
controller: widget.controller,
|
||
onChanged: (value) {
|
||
if (usedVoice && value != lastTranscript) transcriptEdited = true;
|
||
setState(() {});
|
||
widget.onChanged();
|
||
},
|
||
minLines: 2,
|
||
maxLines: 4,
|
||
decoration: InputDecoration(
|
||
hintText: '输入完整英文句子',
|
||
filled: true,
|
||
fillColor: AppColors.surface,
|
||
prefixIcon: IconButton(
|
||
tooltip: listening ? '停止录音' : '语音输入',
|
||
icon: Icon(
|
||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||
),
|
||
onPressed: _toggleMic,
|
||
),
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
SectionCard(
|
||
tint: AppColors.surfaceMuted,
|
||
child: SpacedColumn(
|
||
spacing: 8,
|
||
children: [
|
||
Text(
|
||
widget.keepRecording
|
||
? '可录下这次尝试并保存在本机;不会发送给 AI。'
|
||
: '可录下这次尝试并回听;离开本页后会自动删除。',
|
||
),
|
||
RecordingControls(
|
||
recording: recording,
|
||
playing: playingRecording,
|
||
hasRecording: recordingPath != null,
|
||
onToggleRecording: toggleRecording,
|
||
onPlay: playRecording,
|
||
onDelete: deleteRecording,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (usedVoice)
|
||
SectionCard(
|
||
tint: transcriptEdited ? AppColors.warm : AppColors.softGreen,
|
||
child: Text(
|
||
transcriptEdited
|
||
? '你修改了设备转写:这次会按文字练习保存,不计口语练习。'
|
||
: '这是设备转写。未修改并确认后,会保留为本次语音练习记录。',
|
||
style: TextStyle(
|
||
color: transcriptEdited ? AppColors.warmInk : AppColors.green,
|
||
),
|
||
),
|
||
),
|
||
if (validationError != null)
|
||
SectionCard(
|
||
tint: AppColors.warm,
|
||
child: Text(
|
||
validationError!,
|
||
style: const TextStyle(color: AppColors.warmInk),
|
||
),
|
||
),
|
||
if (!widget.showHelp)
|
||
Wrap(
|
||
spacing: 8,
|
||
children: [
|
||
TextButton(
|
||
onPressed: widget.onNeedHelp,
|
||
child: const Text('需要帮助'),
|
||
),
|
||
TextButton(
|
||
onPressed: widget.onLookup,
|
||
child: const Text('查词或短语'),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
||
PrimaryButton(
|
||
label: widget.showHelp ? '带帮助完成' : '独立完成',
|
||
onPressed: widget.canContinue ? _submit : null,
|
||
),
|
||
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _CompletionStep extends StatelessWidget {
|
||
const _CompletionStep({
|
||
required this.assisted,
|
||
required this.isFinalSegment,
|
||
required this.nextSegmentNumber,
|
||
required this.onFinish,
|
||
});
|
||
final bool assisted;
|
||
final bool isFinalSegment;
|
||
final int nextSegmentNumber;
|
||
final VoidCallback onFinish;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _LessonScaffold(
|
||
step: 6,
|
||
child: SpacedColumn(
|
||
children: [
|
||
const Eyebrow('本段已保存'),
|
||
Text(
|
||
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
|
||
style: Theme.of(context).textTheme.headlineMedium,
|
||
),
|
||
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
|
||
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
|
||
PrimaryButton(label: '回到首页', onPressed: onFinish),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _AudioRow extends StatelessWidget {
|
||
const _AudioRow({required this.label, this.speech, this.onPlayed});
|
||
final String label;
|
||
final String? speech;
|
||
final VoidCallback? onPlayed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Row(
|
||
children: [
|
||
IconButton.filled(
|
||
onPressed: () async {
|
||
try {
|
||
await VoiceService.instance.speak(speech ?? label);
|
||
} finally {
|
||
onPlayed?.call();
|
||
}
|
||
},
|
||
icon: const Icon(Icons.play_arrow),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: InkWell(
|
||
onTap: () async {
|
||
try {
|
||
await VoiceService.instance.speak(speech ?? label);
|
||
} finally {
|
||
onPlayed?.call();
|
||
}
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||
child: Text(label),
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () async {
|
||
try {
|
||
await VoiceService.instance.speak(speech ?? label, slow: true);
|
||
} finally {
|
||
onPlayed?.call();
|
||
}
|
||
},
|
||
child: const Text('慢速'),
|
||
),
|
||
],
|
||
);
|
||
}
|