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 '../../widgets/app_widgets.dart'; import '../../widgets/lexicon_lookup.dart'; import '../../widgets/voice_answer.dart'; class DialogueScenePage extends StatelessWidget { const DialogueScenePage({super.key, required this.onStart, this.onBack}); final VoidCallback onStart; final VoidCallback? onBack; @override Widget build(BuildContext context) => AppPage( appBar: onBack != null ? AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "返回", onPressed: onBack, ), title: const Text("AI 情境对话"), ) : null, child: SpacedColumn( children: [ const Eyebrow('按当前水平推荐'), Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium), Text( '每轮 4 次回答,完成明确任务后结束。', style: Theme.of(context).textTheme.bodyMedium, ), SectionCard( tint: AppColors.softGreen, child: SpacedColumn( children: [ const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '初次见面', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), Text('A0', style: TextStyle(color: AppColors.green)), ], ), Text( '介绍姓名、地点、状态或喜好,并反问对方', style: Theme.of(context).textTheme.bodyMedium, ), PrimaryButton(label: '开始对话', onPressed: onStart), ], ), ), const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'), const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'), ], ), ); } class _LockedScene extends StatelessWidget { const _LockedScene({required this.title, required this.note}); final String title; final String note; @override Widget build(BuildContext context) => SectionCard( child: Row( children: [ const Icon(Icons.lock_outline, color: AppColors.muted), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), Text(note, style: Theme.of(context).textTheme.bodyMedium), ], ), ), ], ), ); } class DialoguePage extends StatefulWidget { const DialoguePage({ super.key, required this.state, required this.onFinished, this.isLessonDialogue = false, }); final AppState state; final ValueChanged onFinished; final bool isLessonDialogue; @override State createState() => _DialoguePageState(); } class _DialoguePageState extends State with VoiceAnswerMixin { final controller = TextEditingController(); final ScrollController _scrollController = ScrollController(); final List turns = []; int stage = 0; bool usedHelp = false; String? hint; bool usedVoice = false; bool transcriptEdited = false; String lastTranscript = ''; bool waitingForReply = false; String? validationError; /// Shown when the reply on screen came from the built-in script instead of /// the AI, so a canned line is never mistaken for a real answer. String? aiNotice; /// The AI's short Chinese comment on the learner's English. It is kept until /// the dialogue ends: the spec forbids interrupting a beginner turn by turn. String? latestFeedback; /// The free scene stores its draft under its own id. static const _sceneDraftId = 'scene-a0-meet'; /// Closing line for the turn after the last scripted prompt. It stays inside /// taught A0 language instead of the old "Wonderful — nice meeting you!". static const _closingLine = 'Bye! Nice to meet you.'; static const _closingTranslation = '再见!很高兴认识你。'; final Set _shownTranslations = {}; LessonDialogue get script => widget.isLessonDialogue ? dialogueBySegmentId( lessonById(widget.state.activeLessonId) .segments[widget.state.activeSegmentIndexFor( widget.state.activeLessonId, )] .id, widget.state.activeLessonId, ) : a0MeetDialogue; /// Only exact sentence matches are trusted. The old positional fallback /// attached `script.translations[stage]` to whatever the AI happened to say, /// which produced Chinese that did not match the English on screen. String? _resolveTranslationFor(String text) { final cleanText = text.trim(); // 1. Check current script prompts for (var i = 0; i < script.prompts.length; i++) { if (script.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) { if (i < script.translations.length) { return script.translations[i]; } } } // 2. Check the free scene script for (var i = 0; i < a0MeetDialogue.prompts.length; i++) { if (a0MeetDialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) { if (i < a0MeetDialogue.translations.length) { return a0MeetDialogue.translations[i]; } } } // 3. Check all lesson dialogues for (final dialogue in a0Dialogues.values) { for (var i = 0; i < dialogue.prompts.length; i++) { if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) { if (i < dialogue.translations.length) { return dialogue.translations[i]; } } } } // 4. Check all segment dialogues for (final dialogue in a0SegmentDialogues.values) { for (var i = 0; i < dialogue.prompts.length; i++) { if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) { if (i < dialogue.translations.length) { return dialogue.translations[i]; } } } } // 5. Common fallback phrases if (cleanText == _closingLine) return _closingTranslation; if (cleanText.toLowerCase().contains("wonderful") && cleanText.toLowerCase().contains("nice meeting you")) { return "太棒了 — 很高兴认识你!"; } if (cleanText.toLowerCase().contains("goodbye") || cleanText.toLowerCase().contains("bye")) { return "再见!"; } return null; } @override void initState() { super.initState(); final draft = widget.isLessonDialogue ? widget.state.dialogueDraft : widget.state.sceneDialogueDraft; final expectedDraftId = widget.isLessonDialogue ? widget.state.activeLessonId : _sceneDraftId; final canRestore = draft != null && draft.lessonId == expectedDraftId && draft.stage >= 0 && draft.stage <= script.prompts.length && draft.turns.isNotEmpty; if (canRestore) { stage = draft.stage; usedHelp = draft.usedHelp; for (var i = 0; i < draft.turns.length; i++) { final t = draft.turns[i]; if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) { final trans = _resolveTranslationFor(t.text); turns.add(t.copyWith(translation: trans)); } else { turns.add(t); } } } else { final initialPrompt = script.prompts.first; final initialTranslation = script.translations.firstOrNull ?? _resolveTranslationFor(initialPrompt); turns.add( DialogueTurn( text: initialPrompt, isLearner: false, translation: initialTranslation, ), ); } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _playLatestAi(slow: false); _scrollToBottom(); } }); } @override void dispose() { VoiceService.instance.stopSpeaking(); VoiceService.instance.stopListening(); disposeVoiceAnswer(keepRecording: widget.state.keepRecordings); _scrollController.dispose(); controller.dispose(); super.dispose(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } Future send() async { final text = controller.text.trim(); if (text.isEmpty || stage >= script.prompts.length || waitingForReply) { return; } if (!_matchesCurrentTask(text)) { setState( () => validationError = '这一轮要“${_currentTaskLabel()}”,这句还没做到。' '可以点“提示”看示范,再补充一次。', ); return; } widget.state.recordDialogueAttempt( taskId: 'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage', rawAnswer: text, assisted: usedHelp, spoken: usedVoice && !transcriptEdited, recordingPath: widget.state.keepRecordings ? recordingPath : null, ); if (!widget.state.keepRecordings) { VoiceService.instance.deleteRecording(recordingPath); recordingPath = null; } final nextStage = stage + 1; setState(() { turns.add(DialogueTurn(text: text, isLearner: true)); controller.clear(); stage = nextStage; waitingForReply = true; hint = null; validationError = null; }); _saveDraft(); _scrollToBottom(); final aiResponse = await AiService.instance.dialogueReply( provider: widget.state.aiProvider, endpoint: widget.state.aiEndpoint, model: widget.state.aiModel, aiGoal: nextStage < script.prompts.length ? script.prompts[nextStage] : 'Say goodbye warmly and end the conversation.', learnerTask: nextStage < script.hints.length ? 'answer with something like "${script.hints[nextStage]}"' : 'nothing more, the conversation is finished', allowedLanguage: widget.isLessonDialogue ? taughtLanguageUpTo(widget.state.activeLessonId) : allTaughtLanguage, history: turns .map( (turn) => { 'role': turn.isLearner ? 'user' : 'assistant', 'content': turn.text, }, ) .toList(), ); if (!mounted) return; final replyText = aiResponse?.reply ?? (nextStage < script.prompts.length ? script.prompts[nextStage] : _closingLine); var replyTranslation = aiResponse?.translation; if (replyTranslation == null || replyTranslation.isEmpty) { replyTranslation = _resolveTranslationFor(replyText); } setState(() { turns.add( DialogueTurn( text: replyText, isLearner: false, translation: replyTranslation, ), ); waitingForReply = false; final feedback = aiResponse?.feedback; if (feedback != null && feedback.trim().isNotEmpty) { latestFeedback = feedback.trim(); } aiNotice = aiResponse != null ? null : (widget.state.aiProvider == AiProviderType.mock ? '当前未连接 AI,正在按示范脚本对话。' : 'AI 暂时无法连接,这一句来自示范脚本。'); }); _saveDraft(); _scrollToBottom(); VoiceService.instance.speak(replyText); } void _saveDraft() { final draft = DialogueDraft( lessonId: widget.isLessonDialogue ? widget.state.activeLessonId : _sceneDraftId, stage: stage, turns: List.unmodifiable(turns), usedHelp: usedHelp, ); if (widget.isLessonDialogue) { widget.state.saveDialogueDraft(draft); } else { widget.state.saveSceneDialogueDraft(draft); } } String _currentTaskLabel() => dialogueTaskLabel(script, stage); bool _matchesCurrentTask(String response) { // Every dialogue now checks the language the turn is teaching. The old // whole-lesson branch matched bare keywords such as 'it' anywhere in the // sentence, so an off-task answer passed every stage. if (widget.isLessonDialogue && lessonById(widget.state.activeLessonId).segments.length > 1) { return matchesSegmentDialogue(_lessonSegmentId, stage, response); } return matchesDialogueStage(script, stage, response); } String get _lessonSegmentId => lessonById(widget.state.activeLessonId) .segments[widget.state.activeSegmentIndexFor(widget.state.activeLessonId)] .id; void _finish() { if (widget.isLessonDialogue) { widget.state.clearDialogueDraft(); widget.state.completeLessonDialogue(); widget.onFinished(null); return; } widget.state.clearSceneDialogueDraft(); final learnerTurns = turns.where((turn) => turn.isLearner).toList(); final personalSentence = learnerTurns.isEmpty ? 'My name is …' : learnerTurns.first.text; widget.state.addDialogueRecap(personalSentence); widget.onFinished( DialogueSummaryData( // Only the turns the learner actually passed are reported. completedTasks: _completedTaskLabels(), personalSentence: personalSentence, usedHelp: usedHelp, improvement: latestFeedback, ), ); } /// A learner turn is only added after it passes [_matchesCurrentTask], so the /// number of learner turns is the number of tasks actually completed. List _completedTaskLabels() { final done = turns.where((turn) => turn.isLearner).length; return [ for (var i = 0; i < done && i < script.taskLabels.length; i++) script.taskLabels[i], ]; } Future _toggleTurnTranslation(int index) async { if (index < 0 || index >= turns.length) return; final turn = turns[index]; if (turn.isLearner) return; if (_shownTranslations.contains(index)) { setState(() { _shownTranslations.remove(index); }); return; } String? trans = turn.translation; if (trans == null || trans.isEmpty) { trans = _resolveTranslationFor(turn.text); } if (trans != null && trans.isNotEmpty) { setState(() { turns[index] = turn.copyWith(translation: trans); _shownTranslations.add(index); }); _saveDraft(); _scrollToBottom(); return; } setState(() { _shownTranslations.add(index); turns[index] = turn.copyWith(translation: "正在翻译…"); }); final fetched = await AiService.instance.temporaryDefinition( provider: widget.state.aiProvider, endpoint: widget.state.aiEndpoint, model: widget.state.aiModel, text: turn.text, ); if (!mounted) return; final finalTrans = (fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译"; setState(() { turns[index] = turn.copyWith(translation: finalTrans); }); _saveDraft(); _scrollToBottom(); } Future _showLatestAiTranslation() async { final latestAiIndex = turns.lastIndexWhere((turn) => !turn.isLearner); if (latestAiIndex == -1) return; final latestAi = turns[latestAiIndex]; String? trans = latestAi.translation; if (trans == null || trans.isEmpty) { trans = _resolveTranslationFor(latestAi.text); } if (trans != null && trans.isNotEmpty) { setState(() { usedHelp = true; hint = "对方说:$trans"; _shownTranslations.add(latestAiIndex); turns[latestAiIndex] = latestAi.copyWith(translation: trans); }); _saveDraft(); _scrollToBottom(); return; } setState(() { usedHelp = true; hint = "正在获取对方英文翻译…"; _shownTranslations.add(latestAiIndex); }); final fetched = await AiService.instance.temporaryDefinition( provider: widget.state.aiProvider, endpoint: widget.state.aiEndpoint, model: widget.state.aiModel, text: latestAi.text, ); if (!mounted) return; final finalTrans = (fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译"; setState(() { hint = "对方说:$finalTrans"; turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans); }); _saveDraft(); _scrollToBottom(); } Future _playLatestAi({required bool slow}) async { final latest = turns.where((turn) => !turn.isLearner).lastOrNull; if (latest == null) return; await VoiceService.instance.speak(latest.text, slow: slow); if (!slow || !mounted) return; setState(() => usedHelp = true); _saveDraft(); } @override AppState get voiceState => widget.state; Future _toggleListening() async { if (aiVoiceRecording) { await finishVoiceInput( onTranscript: (text) { controller.text = text; usedVoice = true; lastTranscript = text; transcriptEdited = false; }, afterTranscribe: _scrollToBottom, ); return; } await startVoiceInput(); } void _showHint() { setState(() { usedHelp = true; final hintIdx = stage < script.hints.length ? stage : (script.hints.isNotEmpty ? script.hints.length - 1 : 0); hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null; }); _saveDraft(); _scrollToBottom(); } void _showWord() => showLexiconLookup( context, state: widget.state, initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '', ); @override Widget build(BuildContext context) { final finished = stage == script.prompts.length; final totalStages = script.prompts.length; return AppPage( scrollController: _scrollController, appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "返回", onPressed: () => widget.onFinished(null), ), title: Text( '${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages', ), ), child: SpacedColumn( children: [ Eyebrow('目标:${script.goal}'), Container( constraints: const BoxConstraints(minHeight: 250), child: ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: turns.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) => _TurnBubble( turn: turns[index], state: widget.state, showTranslation: _shownTranslations.contains(index), onToggleTranslation: () => _toggleTurnTranslation(index), ), ), ), if (!finished) ...[ Wrap( spacing: 8, runSpacing: 8, children: [ _AssistChip(label: '提示', onTap: _showHint), _AssistChip(label: '翻译', onTap: _showLatestAiTranslation), _AssistChip( label: '慢一点', onTap: () => _playLatestAi(slow: true), ), _AssistChip( label: '重说', onTap: () => _playLatestAi(slow: false), ), _AssistChip(label: '查词', onTap: _showWord), ], ), if (hint != null) SectionCard( tint: AppColors.warm, child: Text( hint!, style: const TextStyle(color: AppColors.warmInk), ), ), if (aiNotice != null) SectionCard( tint: AppColors.warm, child: Row( children: [ const Icon( Icons.cloud_off_outlined, size: 18, color: AppColors.warmInk, ), const SizedBox(width: 8), Expanded( child: Text( aiNotice!, style: const TextStyle( color: AppColors.warmInk, fontSize: 13, ), ), ), ], ), ), if (validationError != null) Text( validationError!, style: const TextStyle(color: AppColors.warmInk), ), TextField( controller: controller, onChanged: (value) => setState(() { if (usedVoice && value != lastTranscript) { transcriptEdited = true; } }), onSubmitted: (_) => send(), decoration: InputDecoration( hintText: '输入你的英文回答', filled: true, fillColor: AppColors.surface, prefixIcon: IconButton( tooltip: transcribing ? '正在 AI 识别…' : (listening ? '停止录音并识别' : '语音输入'), icon: transcribing ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( listening ? Icons.stop_circle : Icons.mic_none, color: listening ? Colors.redAccent : null, ), onPressed: transcribing ? null : _toggleListening, ), suffixIcon: IconButton( icon: const Icon(Icons.send), onPressed: waitingForReply ? null : send, ), border: const OutlineInputBorder(), ), ), if (usedVoice) Text( transcriptEdited ? '你修改了设备转写:这轮按文字作答保存。' : '这是设备转写;未修改提交后会作为语音尝试保存。', style: const TextStyle(color: AppColors.muted, fontSize: 12), ), RecordingControls( recording: recording, playing: playingRecording, hasRecording: recordingPath != null, onToggleRecording: toggleRecording, onPlay: playRecording, onDelete: deleteRecording, ), Text( widget.state.keepRecordings ? '录音只保存在本机,不会发送给 AI。' : '录音仅供本次回听,离开后自动删除。', style: const TextStyle(color: AppColors.muted, fontSize: 12), ), const Text( '文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。', style: TextStyle(color: AppColors.muted, fontSize: 12), ), if (waitingForReply) const LinearProgressIndicator(), ] else ...[ SectionCard( tint: usedHelp ? AppColors.warm : AppColors.softGreen, child: Text( usedHelp ? '本次使用过提示,课程会把关键表达安排到后续复习。' : '你完成了 ${_completedTaskLabels().length} 个交际任务,接下来试着不看帮助独立表达。', ), ), if (latestFeedback != null) SectionCard( tint: AppColors.warm, child: Text( '下次可以注意:${latestFeedback!}', style: const TextStyle(color: AppColors.warmInk), ), ), PrimaryButton( label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结', onPressed: _finish, ), ], ], ), ); } } class _TurnBubble extends StatelessWidget { const _TurnBubble({ required this.turn, required this.state, required this.showTranslation, required this.onToggleTranslation, }); final DialogueTurn turn; final AppState state; final bool showTranslation; final VoidCallback onToggleTranslation; @override Widget build(BuildContext context) { final translation = turn.translation; return Align( alignment: turn.isLearner ? Alignment.centerRight : Alignment.centerLeft, child: Container( constraints: const BoxConstraints(maxWidth: 290), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: turn.isLearner ? AppColors.warm : AppColors.softGreen, borderRadius: BorderRadius.circular(15), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LexiconText(turn.text, state: state), if (!turn.isLearner) ...[ if (showTranslation && translation != null && translation.isNotEmpty) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(6), ), child: Text( translation, style: const TextStyle( fontSize: 13, color: Color(0xFF2D3748), ), ), ), ], const SizedBox(height: 6), Row( mainAxisSize: MainAxisSize.min, children: [ _TurnAction( icon: Icons.volume_up_outlined, label: '播放', onTap: () => VoiceService.instance.speak(turn.text), ), const SizedBox(width: 14), _TurnAction( icon: showTranslation ? Icons.translate : Icons.translate_outlined, label: showTranslation ? '隐藏翻译' : '翻译', onTap: onToggleTranslation, ), const SizedBox(width: 14), _TurnAction( icon: Icons.psychology_alt_outlined, label: '句型解析', onTap: () => showLexiconLookup( context, state: state, initialText: turn.text, ), ), ], ), ], ], ), ), ); } } class _TurnAction extends StatelessWidget { const _TurnAction({ required this.icon, required this.label, required this.onTap, }); final IconData icon; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: AppColors.green), const SizedBox(width: 4), Text( label, style: const TextStyle( fontSize: 12, color: AppColors.green, fontWeight: FontWeight.w500, ), ), ], ), ); } class _AssistChip extends StatelessWidget { const _AssistChip({required this.label, required this.onTap}); final String label; final VoidCallback onTap; @override Widget build(BuildContext context) => ActionChip( label: Text(label), backgroundColor: AppColors.surface, side: const BorderSide(color: AppColors.line), onPressed: onTap, ); } class DialogueSummaryPage extends StatelessWidget { const DialogueSummaryPage({ super.key, required this.summary, required this.onHome, required this.onLesson, required this.onRetry, this.onBack, }); final DialogueSummaryData summary; final VoidCallback onHome; final VoidCallback onLesson; final VoidCallback onRetry; final VoidCallback? onBack; @override Widget build(BuildContext context) => AppPage( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "返回", onPressed: onBack ?? onHome, ), title: const Text("对话完成"), ), child: SpacedColumn( children: [ const Eyebrow('对话完成'), Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium), Text( summary.completedTasks.isEmpty ? '这次还没有完成完整的交际任务,可以再练一次。' : '你完成了 ${summary.completedTasks.join('、')}。', ), SectionCard( child: _SummaryLine( icon: Icons.check_circle_outline, title: '你的个人复习卡(明天出现)', sentence: summary.personalSentence, tint: AppColors.green, ), ), if (summary.improvement != null && summary.improvement!.isNotEmpty) SectionCard( tint: AppColors.warm, child: _SummaryLine( icon: Icons.tips_and_updates_outlined, title: '下一次说得更好', sentence: summary.improvement!, tint: AppColors.warmInk, ), ), Text( summary.usedHelp ? '本次使用过提示。下次可以先不看提示,再试一次。' : '下次试着换一个名字、地点或喜好,再完成同一任务。', ), SecondaryButton(label: '再练一次初次见面', onPressed: onRetry), PrimaryButton(label: '开始一节课程', onPressed: onLesson), SecondaryButton(label: '回到首页', onPressed: onHome), ], ), ); } class _SummaryLine extends StatelessWidget { const _SummaryLine({ required this.icon, required this.title, required this.sentence, required this.tint, }); final IconData icon; final String title; final String sentence; final Color tint; @override Widget build(BuildContext context) => Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: tint), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle(color: tint, fontWeight: FontWeight.w600), ), const SizedBox(height: 3), Text(sentence), ], ), ), ], ); }