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) 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(); } void _submit() { final shortfall = independentShortfall( widget.segmentId, widget.controller.text, ); if (shortfall != null) { setState(() => validationError = shortfall); return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( aiCheck?.verdict == 'accepted' ? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。' : '通过:用上了本段要练的表达。', ), ), ); widget.onContinue( usedVoice && !transcriptEdited, widget.keepRecording ? recordingPath : null, aiCorrected, ); } void _clearChecks() { validationError = null; aiCheck = null; aiCheckError = null; } Future _checkWithAi() async { final answer = widget.controller.text.trim(); if (answer.isEmpty || checkingWithAi) return; if (widget.state.aiProvider == AiProviderType.mock) { setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。'); return; } setState(() { checkingWithAi = true; 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 ? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。' : null; if (feedback != null && feedback.verdict != 'accepted') { 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.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, ), ), ), OutlinedButton.icon( onPressed: widget.canContinue && !checkingWithAi ? _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}'), Text( aiCheck!.verdict == 'accepted' ? 'AI 仅作参考;是否完成仍由本地检查判断。' : '看过改正后再提交,本次会记为带提示完成。', 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: widget.showHelp || aiCorrected ? '带帮助完成' : '独立完成', onPressed: widget.canContinue ? _submit : null, ), TextButton(onPressed: widget.onLater, child: const Text('稍后继续')), ], ), ); }