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/courses/courses.dart'; import '../../core/dialogue_decision.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.state, required this.onStart, this.onBack, }); final AppState state; final ValueChanged onStart; final VoidCallback? onBack; @override Widget build(BuildContext context) { final recommended = state.recommendedScene; return 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, ), for (final scene in allScenes) if (state.isSceneUnlocked(scene)) _OpenScene( scene: scene, recommended: scene.id == recommended.id, onStart: () => onStart(scene.id), ) else _LockedScene( title: scene.title, note: 'A0 · 学完第 ${lessonById(scene.unlockAfterLessonId!).number} 课后开放', ), const _LockedScene(title: '咖啡店点单', note: 'A1 · 后续版本开放'), ], ), ); } } class _OpenScene extends StatelessWidget { const _OpenScene({ required this.scene, required this.recommended, required this.onStart, }); final DialogueScene scene; final bool recommended; final VoidCallback onStart; @override Widget build(BuildContext context) => SectionCard( tint: recommended ? AppColors.softGreen : null, child: SpacedColumn( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( scene.title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), Text( recommended ? 'A0 · 推荐' : 'A0', style: TextStyle(color: AppColors.green), ), ], ), Text(scene.summary, style: Theme.of(context).textTheme.bodyMedium), recommended ? PrimaryButton(label: '开始对话', onPressed: onStart) : SecondaryButton(label: '开始对话', onPressed: onStart), ], ), ); } 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: [ 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, this.sceneId = 'a0-meet', }); final AppState state; final ValueChanged onFinished; final bool isLessonDialogue; /// The free scene to run when this is not a lesson dialogue. final String sceneId; @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; bool checkingWithAi = false; WritingAiFeedback? aiCheck; String? aiCheckError; bool interveningWithAi = false; DialogueAiIntervention? aiIntervention; int _validationGeneration = 0; bool _turnHintUsed = false; bool _turnTranslationUsed = false; bool _turnCorrectionUsed = false; /// 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; DialogueScene get _scene => sceneById(widget.sceneId); /// The lesson whose language the AI partner speaks: the active lesson, or /// the unit a free scene belongs to. Null for the always-open A0 scene. String? get _languageLessonId => widget.isLessonDialogue ? widget.state.activeLessonId : _scene.unlockAfterLessonId; /// Each free scene stores its draft under its own id. String get _sceneDraftId => 'scene-${_scene.id}'; /// 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, ) : _scene.script; /// 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 every free scene and lesson dialogue for (final dialogue in [ for (final scene in allScenes) scene.script, ...segmentDialogues.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]; } } } } // 3. 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; _turnHintUsed = draft.currentTurnHintUsed; _turnTranslationUsed = draft.currentTurnTranslationUsed; _turnCorrectionUsed = draft.currentTurnCorrectionUsed; 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 || interveningWithAi) { return; } final local = _evaluateCurrentTaskLocally(text); if (local == LocalDialogueVerdict.rejected) { setState(() { validationError = '请输入一个有效的英文回答。'; aiIntervention = null; }); return; } final requestedStage = stage; final requestGeneration = ++_validationGeneration; final turnId = '${widget.isLessonDialogue ? _lessonSegmentId : _scene.id}' '-$requestedStage-$requestGeneration'; final aiWasAttempted = widget.state.aiProvider != AiProviderType.mock; DialogueAiIntervention? intervention; if (aiWasAttempted) { setState(() { interveningWithAi = true; validationError = null; aiIntervention = null; }); _scrollToBottom(); final partnerLine = turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? ''; final hintText = stage < script.hints.length ? script.hints[stage] : null; intervention = await AiService.instance.capabilities.dialogue .evaluateTurn( provider: widget.state.aiProvider, endpoint: widget.state.aiEndpoint, model: widget.state.aiModel, partnerLine: partnerLine, taskLabel: _currentTaskLabel(), learnerText: text, turnId: turnId, hint: hintText, level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!), ); if (!mounted) return; final stale = requestGeneration != _validationGeneration || requestedStage != stage || controller.text.trim() != text; if (stale) { if (requestGeneration == _validationGeneration) { setState(() => interveningWithAi = false); } return; } setState(() => interveningWithAi = false); } final decision = resolveDialogueTurnDecision( local: local, ai: intervention, aiWasAttempted: aiWasAttempted, isFreeScene: !widget.isLessonDialogue, supportLevel: _currentSupportLevel, ); if (!decision.canAdvance) { setState(() { aiIntervention = intervention; validationError = intervention == null ? decision.feedback : null; if (intervention?.suggestion != null) { _turnCorrectionUsed = true; usedHelp = true; } }); _saveDraft(); _scrollToBottom(); return; } await _executeSend(text, decision: decision); } Future _executeSend( String text, { required DialogueTurnDecision decision, }) async { if (decision.assisted) usedHelp = true; widget.state.recordDialogueAttempt( taskId: widget.isLessonDialogue ? 'dialogue-$_lessonSegmentId-$stage' : 'dialogue-scene-${_scene.id}-$stage', sceneId: widget.isLessonDialogue ? null : _scene.id, rawAnswer: text, assisted: decision.assisted, evidenceOutcome: decision.evidenceOutcome, 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; aiIntervention = null; aiCheck = null; aiCheckError = null; final feedback = decision.feedback; if (feedback != null && feedback.trim().isNotEmpty) { latestFeedback = feedback.trim(); } _turnHintUsed = false; _turnTranslationUsed = false; _turnCorrectionUsed = false; usedVoice = false; transcriptEdited = false; lastTranscript = ''; recordingPath = null; }); _saveDraft(); _scrollToBottom(); final aiResponse = await AiService.instance.capabilities.dialogue.reply( 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', level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!), // The always-open scene mixes every A0 topic, as its script does. allowedLanguage: _languageLessonId == null ? a0TaughtLanguage : taughtLanguageUpTo(_languageLessonId!), 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, currentTurnHintUsed: _turnHintUsed, currentTurnTranslationUsed: _turnTranslationUsed, currentTurnCorrectionUsed: _turnCorrectionUsed, ); if (widget.isLessonDialogue) { widget.state.saveDialogueDraft(draft); } else { widget.state.saveSceneDialogueDraft(draft); } } String _currentTaskLabel() => dialogueTaskLabel(script, stage); /// Optional spelling and grammar check of the turn before it is sent. It /// never decides whether the turn passes; the combined local/AI decision does. Future _checkWithAi() async { final answer = controller.text.trim(); if (answer.isEmpty || checkingWithAi || stage >= script.prompts.length) { return; } if (widget.state.aiProvider == AiProviderType.mock) { setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续对话。'); return; } final checkedStage = stage; setState(() { checkingWithAi = true; aiCheckError = null; }); final partnerLine = turns.where((turn) => !turn.isLearner).lastOrNull; final feedback = await AiService.instance.capabilities.evaluation .evaluateAnswer( provider: widget.state.aiProvider, endpoint: widget.state.aiEndpoint, model: widget.state.aiModel, answerId: 'dialogue-$checkedStage', target: checkedStage < script.hints.length ? script.hints[checkedStage] : _currentTaskLabel(), taskPrompt: '对方说:${partnerLine?.text ?? ''} 本轮任务:${_currentTaskLabel()}', answer: answer, level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!), ); if (!mounted) return; // Drop a result for a turn already sent or an answer since changed. if (stage != checkedStage || controller.text.trim() != answer) { setState(() => checkingWithAi = false); return; } setState(() { checkingWithAi = false; aiCheck = feedback; aiCheckError = feedback == null ? '暂时无法获得 AI 检查结果。你的回答保留在这里,可稍后重试或直接发送。' : null; // A shown correction is help, like the hint and translation chips. if (feedback != null && feedback.verdict != 'accepted') { usedHelp = true; _turnCorrectionUsed = true; } }); _saveDraft(); _scrollToBottom(); } LocalDialogueVerdict _evaluateCurrentTaskLocally(String response) { if (widget.isLessonDialogue && lessonById(widget.state.activeLessonId).segments.length > 1) { return evaluateSegmentDialogueLocally(_lessonSegmentId, stage, response); } return evaluateDialogueStageLocally(script, stage, response); } DialogueSupportLevel get _currentSupportLevel { if (_turnCorrectionUsed) return DialogueSupportLevel.correction; if (_turnTranslationUsed) return DialogueSupportLevel.translation; if (_turnHintUsed) return DialogueSupportLevel.hint; return DialogueSupportLevel.none; } 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, sceneId: _scene.id); 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(() { usedHelp = true; _turnTranslationUsed = true; turns[index] = turn.copyWith(translation: trans); _shownTranslations.add(index); }); _saveDraft(); _scrollToBottom(); return; } setState(() { usedHelp = true; _turnTranslationUsed = true; _shownTranslations.add(index); turns[index] = turn.copyWith(translation: "正在翻译…"); }); final fetched = await AiService.instance.capabilities.lexicon.define( 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; _turnTranslationUsed = true; hint = "对方说:$trans"; _shownTranslations.add(latestAiIndex); turns[latestAiIndex] = latestAi.copyWith(translation: trans); }); _saveDraft(); _scrollToBottom(); return; } setState(() { usedHelp = true; _turnTranslationUsed = true; hint = "正在获取对方英文翻译…"; _shownTranslations.add(latestAiIndex); }); final fetched = await AiService.instance.capabilities.lexicon.define( 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; _turnHintUsed = 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; aiCheck = null; aiCheckError = null; }, afterTranscribe: _scrollToBottom, ); return; } await startVoiceInput(); } void _showHint() { setState(() { usedHelp = true; _turnHintUsed = 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 ?? '', ); Future _acceptAiCorrection() async { final intervention = aiIntervention; final fix = intervention?.suggestion?.trim(); if (intervention == null || fix == null || fix.isEmpty) return; setState(() { controller.text = fix; _turnCorrectionUsed = true; usedHelp = true; aiIntervention = null; validationError = null; }); await _executeSend( fix, decision: DialogueTurnDecision( canAdvance: true, evidenceOutcome: EvidenceKind.assisted, validationSource: DialogueValidationSource.ai, supportLevel: DialogueSupportLevel.correction, feedback: intervention.explanation, suggestion: fix, ), ); } void _continueEditing() { setState(() { aiIntervention = null; validationError = null; }); } @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 ? '课程对话' : _scene.title} · ${finished ? totalStages : stage + 1} / $totalStages', ), ), child: SpacedColumn( children: [ Eyebrow('目标:${script.goal}'), RepaintBoundary( child: 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: TextStyle(color: AppColors.warmInk)), ), if (aiNotice != null) SectionCard( tint: AppColors.warm, child: Row( children: [ Icon( Icons.cloud_off_outlined, size: 18, color: AppColors.warmInk, ), const SizedBox(width: 8), Expanded( child: Text( aiNotice!, style: TextStyle( color: AppColors.warmInk, fontSize: 13, ), ), ), ], ), ), if (validationError != null) Text( validationError!, style: TextStyle(color: AppColors.warmInk), ), if (interveningWithAi) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), Text( 'AI 正在理解你的回答…', style: TextStyle(fontSize: 13, color: AppColors.muted), ), ], ), ), if (aiIntervention != null) SectionCard( tint: AppColors.warm, child: SpacedColumn( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ Row( children: [ Icon( Icons.auto_awesome, size: 18, color: AppColors.warmInk, ), const SizedBox(width: 6), Text( 'AI 助手干预与建议', style: TextStyle( fontWeight: FontWeight.bold, color: AppColors.warmInk, ), ), ], ), Text( aiIntervention!.explanation, style: TextStyle(color: AppColors.warmInk, fontSize: 13), ), if (aiIntervention!.suggestion != null && aiIntervention!.suggestion!.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(6), border: Border.all(color: AppColors.line), ), child: Row( children: [ const Text('建议表达:', style: TextStyle(fontSize: 12)), Expanded( child: Text( aiIntervention!.suggestion!, style: TextStyle( fontWeight: FontWeight.w600, color: AppColors.green, ), ), ), ], ), ), Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.icon( onPressed: _acceptAiCorrection, icon: const Icon(Icons.check, size: 16), label: Text( '修正为 "${aiIntervention!.suggestion}" 并发送', ), style: FilledButton.styleFrom( backgroundColor: AppColors.green, visualDensity: VisualDensity.compact, ), ), OutlinedButton( onPressed: _continueEditing, style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, ), child: const Text('继续修改'), ), ], ), ] else ...[ OutlinedButton( onPressed: _continueEditing, style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, ), child: const Text('继续修改'), ), ], ], ), ), TextField( controller: controller, onChanged: (value) { final needResetVoice = usedVoice && value != lastTranscript && !transcriptEdited; final needClearAi = aiCheck != null || aiCheckError != null || aiIntervention != null || validationError != null; if (needResetVoice || needClearAi) { setState(() { if (needResetVoice) transcriptEdited = true; if (needClearAi) { aiCheck = null; aiCheckError = null; aiIntervention = null; validationError = null; } }); } }, 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: interveningWithAi ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), onPressed: (waitingForReply || interveningWithAi) ? null : send, ), border: const OutlineInputBorder(), ), ), ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { final canCheck = value.text.trim().isNotEmpty && !checkingWithAi && !waitingForReply && !interveningWithAi; return OutlinedButton.icon( onPressed: canCheck ? _checkWithAi : null, icon: checkingWithAi ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.spellcheck), label: Text(checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)'), ); }, ), if (aiCheck != null) SectionCard( tint: aiCheck!.verdict == 'accepted' ? AppColors.softGreen : AppColors.warm, child: SpacedColumn( spacing: 6, children: [ Text('AI 检查:${aiCheck!.feedback}'), for (final note in aiCheck!.missing) Text('· $note'), if (aiCheck!.suggestion != null) ...[ Text('参考改正:${aiCheck!.suggestion}'), Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () { controller.text = aiCheck!.suggestion!; setState(() { usedHelp = true; _turnCorrectionUsed = true; aiCheck = null; }); }, icon: const Icon(Icons.done, size: 16), label: const Text('采纳该表达'), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), ), ), ], Text( aiCheck!.verdict == 'accepted' ? 'AI 辅助判定;若词汇超出预设,发送时 AI 会自动进行语义理解与干预。' : '看过改正后再发送,本次对话会记为使用过提示。', style: TextStyle(fontSize: 12, color: AppColors.muted), ), ], ), ), if (aiCheckError != null) SectionCard( tint: AppColors.warm, child: Text( aiCheckError!, style: TextStyle(color: AppColors.warmInk), ), ), if (usedVoice) Text( transcriptEdited ? '你修改了设备转写:这轮按文字作答保存。' : '这是设备转写;未修改提交后会作为语音尝试保存。', style: 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: TextStyle(color: AppColors.muted, fontSize: 12), ), 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: 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: 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: 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), ], ), ), ], ); }