lesson_flow.dart 保留流程状态、公共脚手架与小组件,预习/听/说/读/写/独立表达 六个步骤各自成为 part 文件。代码仅搬移并经 dart format 格式化,无用户可见变化。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379 lines
12 KiB
Dart
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('慢速'),
|
|
),
|
|
],
|
|
);
|
|
}
|