Files
English/kouyu_english/lib/features/lesson/lesson_flow.dart
T
shenleiandClaude Opus 5 6c0aeec628 refactor: 抽出语音作答与录音回听的公共逻辑
对话页、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>
2026-09-16 17:18:45 +09:00

1396 lines
45 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.
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('慢速'),
),
],
);
}