part of '../lesson_flow.dart'; class _IndependentStep extends StatefulWidget { const _IndependentStep({ required this.state, required this.lessonId, 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 String lessonId; 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, bool aiCorrected, bool verified, ) 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; bool checkingWithAi = false; WritingAiFeedback? aiCheck; String? aiCheckError; /// A shown AI correction is help, so finishing afterwards is assisted. bool aiCorrected = false; @override AppState get voiceState => widget.state; @override void dispose() { disposeVoiceAnswer(keepRecording: widget.keepRecording); super.dispose(); } Future _submit() async { if (!widget.canContinue || checkingWithAi) return; final shortfall = independentShortfall( widget.segmentId, widget.controller.text, ); if (shortfall != null) { setState(() => validationError = shortfall); return; } if (widget.state.aiProvider == AiProviderType.mock) { _finish(verified: false); return; } await _checkWithAi(); if (!mounted) return; if (aiCheck?.verdict == 'accepted') _finish(verified: true); } void _finish({required bool verified}) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( verified ? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。' : '练习已保存;答案未经核验,不计入独立掌握。', ), ), ); widget.onContinue( usedVoice && !transcriptEdited, widget.keepRecording ? recordingPath : null, aiCorrected, verified, ); } void _clearChecks() { validationError = null; aiCheck = null; aiCheckError = null; } Future _checkWithAi() async { final answer = widget.controller.text.trim(); setState(() { checkingWithAi = true; aiCheck = null; aiCheckError = null; }); final feedback = await AiService.instance.capabilities.evaluation .evaluateAnswer( provider: widget.state.aiProvider, endpoint: widget.state.aiEndpoint, model: widget.state.aiModel, answerId: widget.segmentId, target: widget.activity.independentHelp, taskPrompt: widget.activity.independentPrompt, answer: answer, level: lessonLevel(widget.lessonId), ); // The learner may have kept typing while the request ran. if (!mounted || widget.controller.text.trim() != answer) { if (mounted) setState(() => checkingWithAi = false); return; } setState(() { checkingWithAi = false; aiCheck = feedback; aiCheckError = feedback == null ? '暂时无法核验答案。可重试,或仅保存练习继续。' : feedback.verdict == 'uncertain' ? 'AI 无法确定答案是否正确。可重试,或仅保存练习继续。' : null; if (feedback?.verdict == 'rewrite') { aiCorrected = true; } }); } Future _toggleMic() async { if (aiVoiceRecording) { await finishVoiceInput( onTranscript: (text) { widget.controller.text = text; usedVoice = true; lastTranscript = text; transcriptEdited = false; _clearChecks(); }, 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; _clearChecks(); }); 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.state.aiProvider == AiProviderType.mock) SectionCard( tint: AppColors.warm, child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'), ), 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(_clearChecks); 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 (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}'), Text( aiCheck!.verdict == 'accepted' ? '本地目标与 AI 检查均通过。' : aiCheck!.verdict == 'rewrite' ? '修改后再次提交;看过改正,本次会记为辅助练习。' : '答案尚未核验,可重试或仅保存练习。', style: TextStyle(fontSize: 12, color: AppColors.muted), ), ], ), ), if (aiCheckError != null) SectionCard( tint: AppColors.warm, child: Text( aiCheckError!, style: TextStyle(color: AppColors.warmInk), ), ), if (validationError != null) SectionCard( tint: AppColors.warm, child: Text( validationError!, style: 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: checkingWithAi ? '正在自动检查…' : '提交独立尝试', onPressed: widget.canContinue && !checkingWithAi ? _submit : null, ), if (aiCheckError != null) TextButton( onPressed: () => _finish(verified: false), child: const Text('未核验,保存练习并继续'), ), TextButton(onPressed: widget.onLater, child: const Text('稍后继续')), ], ), ); }