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 createState() => _LessonFlowState(); } class _LessonFlowState extends State { final writingController = TextEditingController(); final independentController = TextEditingController(); int selectedAnswer = -1; /// Set once a wrong meaning is picked, so only a first-try choice counts as /// recognition evidence. bool listeningMissed = false; 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 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; if (listeningOptions[value] != activity.answers.first) { listeningMissed = true; } }), onPlayed: () => setState(() => listeningAudioPlayed = true), onLookup: () => showLexiconLookup( context, state: widget.state, initialText: activity.listening, ), onContinue: selectedAnswer >= 0 && listeningOptions[selectedAnswer] == activity.answers.first ? () => widget.state.completeListening(recognized: !listeningMissed) : null, ); case LessonStep.speaking: content = _SpeakingStep( state: widget.state, text: activity.speaking, keepRecording: widget.state.keepRecordings, onContinue: widget.state.completeSpeaking, ); 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('慢速'), ), ], ); }