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'; 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: content = _ListeningStep( state: widget.state, activity: activity, 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 ? 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.selectedAnswer, required this.audioPlayed, required this.onSelected, required this.onPlayed, required this.onLookup, required this.onContinue, }); final LessonActivity activity; final AppState state; 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 = activity.answers; 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 && selectedAnswer != 0) 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> { bool listening = false; bool transcribing = false; bool playingRecording = false; String transcript = ''; String? recordingPath; @override void dispose() { VoiceService.instance.stopRecordingPlayback(); if (!widget.keepRecording) { VoiceService.instance.deleteRecording(recordingPath); } super.dispose(); } Future _toggleMic() async { if (listening) { final path = await VoiceService.instance.stopRecording(); if (!mounted) return; setState(() { listening = false; transcribing = true; recordingPath = path; }); if (path != null) { final config = widget.state.aiConfig; final text = await AiService.instance.transcribeAudio( filePath: path, provider: config.provider, endpoint: config.endpoint, model: config.model, ); if (mounted) { setState(() { transcribing = false; if (text != null && text.trim().isNotEmpty) { transcript = text.trim(); } }); if (text == null || text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('未识别到清晰发音,请重试或点击“播放示范音”。')), ); } } } else { if (mounted) setState(() => transcribing = false); } return; } await VoiceService.instance.stopRecordingPlayback(); if (mounted) setState(() => playingRecording = false); if (!widget.keepRecording) { await VoiceService.instance.deleteRecording(recordingPath); } final recordStarted = await VoiceService.instance.startRecording(); if (mounted) { setState(() { listening = recordStarted; if (recordStarted) { recordingPath = null; } }); if (recordStarted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')), ); } } } 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; } setState(() => playingRecording = true); await VoiceService.instance.playRecording( recordingPath!, onComplete: () { if (mounted) setState(() => playingRecording = false); }, ); } Future _deleteRecording() async { await VoiceService.instance.stopRecordingPlayback(); await VoiceService.instance.deleteRecording(recordingPath); if (mounted) { setState(() { recordingPath = null; playingRecording = false; }); } } @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(); bool showAnswer = false; @override void dispose() { controller.dispose(); super.dispose(); } bool get isCorrect { final answer = widget.activity.readingAnswer.toLowerCase().replaceAll( RegExp(r'[^a-z0-9]'), '', ); final response = controller.text.toLowerCase().replaceAll( RegExp(r'[^a-z0-9]'), '', ); return response.isNotEmpty && response.contains(answer); } @override Widget build(BuildContext context) => _LessonScaffold( step: 4, child: SpacedColumn( children: [ const Eyebrow('读一读'), Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium), SectionCard( tint: AppColors.softGreen, child: LexiconText( widget.activity.reading, state: widget.state, style: TextStyle(fontSize: 16, height: 1.6), ), ), Text(widget.activity.readingQuestion), TextButton.icon( onPressed: widget.onLookup, icon: const Icon(Icons.menu_book_outlined), label: const Text('查词或短语'), ), 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 && controller.text.isNotEmpty && !isCorrect) TextButton( onPressed: () => setState(() => showAnswer = true), child: const Text('查看答案后继续学习'), ), PrimaryButton( label: showAnswer ? '继续写一写' : '检查并继续', onPressed: showAnswer || isCorrect ? 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> { bool listening = false; bool recording = false; bool transcribing = false; bool aiVoiceRecording = false; bool playingRecording = false; bool usedVoice = false; bool transcriptEdited = false; String lastTranscript = ''; String? recordingPath; String? validationError; @override void dispose() { VoiceService.instance.stopRecordingPlayback(); if (!widget.keepRecording) { VoiceService.instance.deleteRecording(recordingPath); } super.dispose(); } void _submit() { if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) { setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。'); return; } widget.onContinue( usedVoice && !transcriptEdited, widget.keepRecording ? recordingPath : null, ); } Future _toggleRecording() async { if (recording) { final path = await VoiceService.instance.stopRecording(); if (mounted) { setState(() { recording = false; recordingPath = path; }); } return; } await VoiceService.instance.deleteRecording(recordingPath); final ready = await VoiceService.instance.startRecording(); if (!mounted) return; setState(() { recording = ready; if (ready) recordingPath = null; }); if (!ready) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。'))); } } Future _playRecording() async { final path = recordingPath; if (path == null) return; setState(() => playingRecording = true); await VoiceService.instance.playRecording( path, onComplete: () { if (mounted) setState(() => playingRecording = false); }, ); } Future _deleteRecording() async { await VoiceService.instance.deleteRecording(recordingPath); if (mounted) setState(() => recordingPath = null); } Future _toggleMic() async { if (aiVoiceRecording) { final path = await VoiceService.instance.stopRecording(); if (!mounted) return; setState(() { aiVoiceRecording = false; listening = false; transcribing = true; recordingPath = path; }); if (path != null) { final config = widget.state.aiConfig; final text = await AiService.instance.transcribeAudio( filePath: path, provider: config.provider, endpoint: config.endpoint, model: config.model, ); if (mounted) { setState(() { transcribing = false; if (text != null && text.trim().isNotEmpty) { widget.controller.text = text.trim(); usedVoice = true; lastTranscript = text.trim(); transcriptEdited = false; } }); widget.onChanged(); if (text == null || text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('未识别到声音,请重试或直接打字输入。')), ); } } } else { if (mounted) setState(() => transcribing = false); } 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 VoiceService.instance.startRecording(); if (mounted) { setState(() { aiVoiceRecording = recordStarted; listening = recordStarted; }); if (recordStarted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')), ); } } 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, 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。' : '可录下这次尝试并回听;离开本页后会自动删除。', ), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _toggleRecording, icon: Icon( recording ? Icons.stop_circle_outlined : Icons.fiber_manual_record, ), label: Text(recording ? '停止录音' : '录音回听'), ), ), if (recordingPath != null) ...[ const SizedBox(width: 8), IconButton( tooltip: playingRecording ? '正在播放' : '回听录音', onPressed: playingRecording ? null : _playRecording, icon: const Icon(Icons.play_arrow), ), IconButton( tooltip: '删除录音', onPressed: _deleteRecording, icon: const Icon(Icons.delete_outline), ), ], ], ), ], ), ), 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('慢速'), ), ], ); }