Files
English/kouyu_english/lib/features/lesson/lesson_flow.dart
T
shenleiandClaude Opus 5 f79ba6327e refactor: 将课程各步骤拆分到 features/lesson/steps/
lesson_flow.dart 保留流程状态、公共脚手架与小组件,预习/听/说/读/写/独立表达
六个步骤各自成为 part 文件。代码仅搬移并经 dart format 格式化,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:23:26 +09:00

379 lines
12 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';
part 'steps/independent_step.dart';
part 'steps/listening_step.dart';
part 'steps/preview_step.dart';
part 'steps/reading_step.dart';
part 'steps/speaking_step.dart';
part 'steps/writing_step.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 _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 _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('慢速'),
),
],
);
}