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 createState() => _LessonFlowState(); } class _LessonFlowState extends State { 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 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 options; final String correctAnswer; final int selectedAnswer; final bool audioPlayed; final ValueChanged 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 _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 _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 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 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 _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 _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('慢速'), ), ], ); }