From 4142af09e16b1bdc0e705a1d16fd5d3d43631436 Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 16 Sep 2026 17:18:45 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=87=BA=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E4=BD=9C=E7=AD=94=E4=B8=8E=E5=BD=95=E9=9F=B3=E5=9B=9E?= =?UTF-8?q?=E5=90=AC=E7=9A=84=E5=85=AC=E5=85=B1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对话页、AI 补练页、课程跟读步骤、独立表达步骤和评估页各自复制了一套 "录音 → AI 转写 → 填入答案"的流程,其中 3 页还复制了"录音回听/播放/删除" 的按钮和方法。现在统一到 lib/widgets/voice_answer.dart: - VoiceAnswerMixin:startVoiceInput / finishVoiceInput / toggleRecording / playRecording / deleteRecording / disposeVoiceAnswer,状态字段名沿用各页原名, 页面的 build 代码基本不动; - RecordingControls:录音回听按钮行。 用户可见的变化: - "未识别到语音"提示统一为「未识别到清晰语音,请再试一次或直接输入文字。」 (对话页、补练页、独立表达步骤、评估页原本各有不同说法;跟读步骤保留 原来提示"播放示范音"的文案)。麦克风不可用、开始录音的提示保持各页原样。 - 对话页的录音回听按钮与播放按钮之间增加 8px 间距,与其他页面一致。 - 删除录音时先停止正在播放的录音(原来只有跟读步骤这样做)。 - 离开页面时,如果还在录音或设备识别中,会先停止;不保留录音时一并删除 这段录音文件。原来课程两个步骤离开时不停止录音,各页离开时也不停止 "录音回听"的录音。 flutter analyze 无问题,flutter test 142 个测试通过;现有测试未覆盖麦克风流程。 Co-Authored-By: Claude Opus 5 --- .../features/assessment/assessment_page.dart | 80 ++---- .../lib/features/dialogue/dialogue_flow.dart | 156 ++--------- .../lib/features/lesson/lesson_flow.dart | 252 +++--------------- .../lib/features/review/review_page.dart | 159 ++--------- kouyu_english/lib/widgets/voice_answer.dart | 205 ++++++++++++++ 5 files changed, 322 insertions(+), 530 deletions(-) create mode 100644 kouyu_english/lib/widgets/voice_answer.dart diff --git a/kouyu_english/lib/features/assessment/assessment_page.dart b/kouyu_english/lib/features/assessment/assessment_page.dart index 6d47677..f12d58b 100644 --- a/kouyu_english/lib/features/assessment/assessment_page.dart +++ b/kouyu_english/lib/features/assessment/assessment_page.dart @@ -1,4 +1,3 @@ -import '../../core/ai_service.dart'; import 'package:flutter/material.dart'; import '../../core/app_state.dart'; @@ -7,6 +6,7 @@ import '../../core/assessment_bank.dart'; import '../../core/models.dart'; import '../../core/voice_service.dart'; import '../../widgets/app_widgets.dart'; +import '../../widgets/voice_answer.dart'; class AssessmentPreparationPage extends StatefulWidget { const AssessmentPreparationPage({ @@ -129,16 +129,14 @@ class AssessmentPage extends StatefulWidget { State createState() => _AssessmentPageState(); } -class _AssessmentPageState extends State { +class _AssessmentPageState extends State + with VoiceAnswerMixin { final controller = TextEditingController(); final Map results = {}; int index = 0; bool usedMic = false; bool transcriptEdited = false; String lastTranscript = ''; - bool listening = false; - bool transcribing = false; - bool aiVoiceRecording = false; bool audioPlayed = false; bool speakingUnavailable = false; AssessmentRecord? completedRecord; @@ -160,10 +158,7 @@ class _AssessmentPageState extends State { void dispose() { VoiceService.instance.stopSpeaking(); VoiceService.instance.stopListening(); - VoiceService.instance.stopRecordingPlayback(); - if (listening || aiVoiceRecording) { - VoiceService.instance.stopRecording(); - } + disposeVoiceAnswer(keepRecording: true); controller.dispose(); super.dispose(); } @@ -175,62 +170,31 @@ class _AssessmentPageState extends State { if (mounted) setState(() => audioPlayed = true); } + @override + AppState get voiceState => widget.state; + Future _mic() async { if (aiVoiceRecording) { - final path = await VoiceService.instance.stopRecording(); - if (!mounted) return; - setState(() { - aiVoiceRecording = false; - listening = false; - transcribing = true; - }); - if (path != null) { - final config = widget.state.aiConfig; - final transcribed = await AiService.instance.transcribeAudio( - filePath: path, - provider: config.provider, - endpoint: config.endpoint, - model: config.model, - ); - if (mounted) { - setState(() { - transcribing = false; - if (transcribed != null && transcribed.trim().isNotEmpty) { - controller.text = transcribed.trim(); - usedMic = true; - lastTranscript = transcribed.trim(); - transcriptEdited = false; - speakingUnavailable = false; - } - }); - if (transcribed == null || transcribed.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未识别到清晰语音,请再试一次。')), - ); - } - } - } else { - if (mounted) setState(() => transcribing = false); - } + await finishVoiceInput( + keepAudio: false, + onTranscript: (text) { + controller.text = text; + usedMic = true; + lastTranscript = text; + transcriptEdited = false; + speakingUnavailable = false; + }, + ); return; } - VoiceService.instance.stopSpeaking(); - final recordStarted = await VoiceService.instance.startRecording(); + final recordStarted = await startVoiceInput( + unavailableMessage: '无法访问麦克风,口语可稍后补测。', + ); if (!mounted) return; - setState(() { - aiVoiceRecording = recordStarted; - listening = recordStarted; - speakingUnavailable = !recordStarted; - }); + setState(() => speakingUnavailable = !recordStarted); if (recordStarted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,将自动转写为英文。')), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')), - ); + showVoiceMessage('已启动麦克风录音,回答后再次点击,将自动转写为英文。'); } } diff --git a/kouyu_english/lib/features/dialogue/dialogue_flow.dart b/kouyu_english/lib/features/dialogue/dialogue_flow.dart index 2eaaff4..8a64b15 100644 --- a/kouyu_english/lib/features/dialogue/dialogue_flow.dart +++ b/kouyu_english/lib/features/dialogue/dialogue_flow.dart @@ -8,6 +8,7 @@ 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({ @@ -105,22 +106,17 @@ class DialoguePage extends StatefulWidget { State createState() => _DialoguePageState(); } -class _DialoguePageState extends State { +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 listening = false; - bool recording = false; - bool transcribing = false; - bool aiVoiceRecording = false; - bool playingRecording = false; bool usedVoice = false; bool transcriptEdited = false; String lastTranscript = ''; - String? recordingPath; bool waitingForReply = false; String? validationError; @@ -260,13 +256,7 @@ class _DialoguePageState extends State { void dispose() { VoiceService.instance.stopSpeaking(); VoiceService.instance.stopListening(); - VoiceService.instance.stopRecordingPlayback(); - if (listening || aiVoiceRecording) { - VoiceService.instance.stopRecording(); - } - if (!widget.state.keepRecordings) { - VoiceService.instance.deleteRecording(recordingPath); - } + disposeVoiceAnswer(keepRecording: widget.state.keepRecordings); _scrollController.dispose(); controller.dispose(); super.dispose(); @@ -542,102 +532,23 @@ class _DialoguePageState extends State { _saveDraft(); } + @override + AppState get voiceState => widget.state; + Future _toggleListening() async { - if (listening || 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 transcribed = await AiService.instance.transcribeAudio( - filePath: path, - provider: config.provider, - endpoint: config.endpoint, - model: config.model, - ); - if (mounted) { - setState(() { - transcribing = false; - if (transcribed != null && transcribed.trim().isNotEmpty) { - controller.text = transcribed.trim(); - usedVoice = true; - lastTranscript = transcribed.trim(); - transcriptEdited = false; - } - }); - _scrollToBottom(); - if (transcribed == null || transcribed.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')), - ); - } - } - } else { - if (mounted) setState(() => transcribing = false); - } - return; - } - - VoiceService.instance.stopSpeaking(); - final recordStarted = await VoiceService.instance.startRecording(); - if (!mounted) return; - if (recordStarted) { - setState(() { - aiVoiceRecording = true; - listening = true; - }); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')), + if (aiVoiceRecording) { + await finishVoiceInput( + onTranscript: (text) { + controller.text = text; + usedVoice = true; + lastTranscript = text; + transcriptEdited = false; + }, + afterTranscribe: _scrollToBottom, ); - } - } - - 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); + await startVoiceInput(); } void _showWord() => showLexiconLookup( @@ -919,32 +830,13 @@ class _DialoguePageState extends State { : '这是设备转写;未修改提交后会作为语音尝试保存。', style: const TextStyle(color: AppColors.muted, fontSize: 12), ), - 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) ...[ - IconButton( - tooltip: playingRecording ? '正在播放' : '回听录音', - onPressed: playingRecording ? null : _playRecording, - icon: const Icon(Icons.play_arrow), - ), - IconButton( - tooltip: '删除录音', - onPressed: _deleteRecording, - icon: const Icon(Icons.delete_outline), - ), - ], - ], + RecordingControls( + recording: recording, + playing: playingRecording, + hasRecording: recordingPath != null, + onToggleRecording: toggleRecording, + onPlay: playRecording, + onDelete: deleteRecording, ), Text( widget.state.keepRecordings diff --git a/kouyu_english/lib/features/lesson/lesson_flow.dart b/kouyu_english/lib/features/lesson/lesson_flow.dart index 4f5e9e7..ef51ef2 100644 --- a/kouyu_english/lib/features/lesson/lesson_flow.dart +++ b/kouyu_english/lib/features/lesson/lesson_flow.dart @@ -9,6 +9,7 @@ import '../../core/voice_service.dart'; import '../../core/writing_feedback.dart'; import '../../widgets/app_widgets.dart'; import '../../widgets/lexicon_lookup.dart'; +import '../../widgets/voice_answer.dart'; class LessonFlow extends StatefulWidget { const LessonFlow({ @@ -448,55 +449,25 @@ class _SpeakingStep extends StatefulWidget { State<_SpeakingStep> createState() => _SpeakingStepState(); } -class _SpeakingStepState extends State<_SpeakingStep> { - bool listening = false; - bool transcribing = false; - bool playingRecording = false; +class _SpeakingStepState extends State<_SpeakingStep> + with VoiceAnswerMixin<_SpeakingStep> { String transcript = ''; - String? recordingPath; + + @override + AppState get voiceState => widget.state; @override void dispose() { - VoiceService.instance.stopRecordingPlayback(); - if (!widget.keepRecording) { - VoiceService.instance.deleteRecording(recordingPath); - } + disposeVoiceAnswer(keepRecording: widget.keepRecording); 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); - } + if (aiVoiceRecording) { + await finishVoiceInput( + noSpeech: '未识别到清晰发音,请重试或点击“播放示范音”。', + onTranscript: (text) => transcript = text, + ); return; } @@ -506,24 +477,10 @@ class _SpeakingStepState extends State<_SpeakingStep> { 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('无法访问麦克风,请检查手机录音权限。')), - ); - } - } + final recordStarted = await startVoiceInput(); + if (!recordStarted || !mounted) return; + setState(() => recordingPath = null); + showVoiceMessage('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。'); } Future _togglePlayRecording() async { @@ -538,24 +495,7 @@ class _SpeakingStepState extends State<_SpeakingStep> { ); 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; - }); - } + await playRecording(); } @override @@ -615,7 +555,7 @@ class _SpeakingStepState extends State<_SpeakingStep> { const SizedBox(width: 8), IconButton( tooltip: '删除录音', - onPressed: (listening || transcribing) ? null : _deleteRecording, + onPressed: (listening || transcribing) ? null : deleteRecording, icon: const Icon(Icons.delete_outline), ), ], @@ -1188,24 +1128,19 @@ class _IndependentStep extends StatefulWidget { State<_IndependentStep> createState() => _IndependentStepState(); } -class _IndependentStepState extends State<_IndependentStep> { - bool listening = false; - bool recording = false; - bool transcribing = false; - bool aiVoiceRecording = false; - bool playingRecording = false; +class _IndependentStepState extends State<_IndependentStep> + with VoiceAnswerMixin<_IndependentStep> { bool usedVoice = false; bool transcriptEdited = false; String lastTranscript = ''; - String? recordingPath; String? validationError; + @override + AppState get voiceState => widget.state; + @override void dispose() { - VoiceService.instance.stopRecordingPlayback(); - if (!widget.keepRecording) { - VoiceService.instance.deleteRecording(recordingPath); - } + disposeVoiceAnswer(keepRecording: widget.keepRecording); super.dispose(); } @@ -1220,86 +1155,17 @@ class _IndependentStepState extends State<_IndependentStep> { ); } - 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); - } + await finishVoiceInput( + onTranscript: (text) { + widget.controller.text = text; + usedVoice = true; + lastTranscript = text; + transcriptEdited = false; + }, + afterTranscribe: widget.onChanged, + ); return; } @@ -1333,21 +1199,9 @@ class _IndependentStepState extends State<_IndependentStep> { ); 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('无法访问麦克风,请检查手机录音权限。')), - ); - } + final recordStarted = await startVoiceInput(); + if (recordStarted && mounted) { + showVoiceMessage('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。'); } return; } @@ -1404,33 +1258,13 @@ class _IndependentStepState extends State<_IndependentStep> { ? '可录下这次尝试并保存在本机;不会发送给 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), - ), - ], - ], + RecordingControls( + recording: recording, + playing: playingRecording, + hasRecording: recordingPath != null, + onToggleRecording: toggleRecording, + onPlay: playRecording, + onDelete: deleteRecording, ), ], ), diff --git a/kouyu_english/lib/features/review/review_page.dart b/kouyu_english/lib/features/review/review_page.dart index 2d33002..4fd9213 100644 --- a/kouyu_english/lib/features/review/review_page.dart +++ b/kouyu_english/lib/features/review/review_page.dart @@ -10,6 +10,7 @@ import '../../core/review_feedback.dart'; import '../../core/a0_core.dart'; import '../../core/voice_service.dart'; import '../../widgets/app_widgets.dart'; +import '../../widgets/voice_answer.dart'; class ReviewPage extends StatefulWidget { const ReviewPage({ @@ -313,21 +314,16 @@ class AdaptiveLessonPage extends StatefulWidget { State createState() => _AdaptiveLessonPageState(); } -class _AdaptiveLessonPageState extends State { +class _AdaptiveLessonPageState extends State + with VoiceAnswerMixin { final controller = TextEditingController(); int index = 0; bool showReference = false; String? answerFeedback; - bool listening = false; - bool transcribing = false; - bool aiVoiceRecording = false; bool usedVoice = false; bool transcriptEdited = false; bool transcriptConfirmed = false; String lastTranscript = ''; - bool recording = false; - bool playingRecording = false; - String? recordingPath; @override void initState() { @@ -353,13 +349,7 @@ class _AdaptiveLessonPageState extends State { void dispose() { VoiceService.instance.stopSpeaking(); VoiceService.instance.stopListening(); - VoiceService.instance.stopRecordingPlayback(); - if (listening || aiVoiceRecording) { - VoiceService.instance.stopRecording(); - } - if (!widget.state.keepRecordings) { - VoiceService.instance.deleteRecording(recordingPath); - } + disposeVoiceAnswer(keepRecording: widget.state.keepRecordings); controller.dispose(); super.dispose(); } @@ -428,105 +418,32 @@ class _AdaptiveLessonPageState extends State { }); } + @override + AppState get voiceState => widget.state; + Future _toggleListening() async { - if (listening || aiVoiceRecording) { - final path = await VoiceService.instance.stopRecording(); - if (!mounted) return; - setState(() { - aiVoiceRecording = false; - listening = false; - transcribing = true; - }); - if (path != null) { - final config = widget.state.aiConfig; - final transcribed = await AiService.instance.transcribeAudio( - filePath: path, - provider: config.provider, - endpoint: config.endpoint, - model: config.model, - ); - if (mounted) { - setState(() { - transcribing = false; - if (transcribed != null && transcribed.trim().isNotEmpty) { - controller.text = transcribed.trim(); - usedVoice = true; - transcriptEdited = false; - transcriptConfirmed = false; - lastTranscript = transcribed.trim(); - } - }); + if (aiVoiceRecording) { + await finishVoiceInput( + keepAudio: false, + onTranscript: (text) { + controller.text = text; + usedVoice = true; + transcriptEdited = false; + transcriptConfirmed = false; + lastTranscript = text; + }, + afterTranscribe: () { final lesson = widget.state.cachedAdaptiveLesson; if (lesson != null) _saveDraft(lesson); - if (transcribed == null || transcribed.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未识别到清晰语音,请再试一次或输入文本。')), - ); - } - } - } else { - if (mounted) setState(() => transcribing = false); - } - return; - } - - VoiceService.instance.stopSpeaking(); - final recordStarted = await VoiceService.instance.startRecording(); - if (!mounted) return; - if (recordStarted) { - setState(() { - aiVoiceRecording = true; - listening = true; - }); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')), + }, ); - } - } - - 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); - }, + await startVoiceInput( + unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。', ); } - Future _deleteRecording() async { - await VoiceService.instance.deleteRecording(recordingPath); - if (mounted) setState(() => recordingPath = null); - } - @override Widget build(BuildContext context) { final lesson = widget.state.cachedAdaptiveLesson; @@ -682,33 +599,13 @@ class _AdaptiveLessonPageState extends State { ? '可录音回听并仅保存在本机;不会发送给 AI。' : '可录音回听;离开本页后会自动删除。', ), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: listening ? null : _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), - ), - ], - ], + RecordingControls( + recording: recording, + playing: playingRecording, + hasRecording: recordingPath != null, + onToggleRecording: listening ? null : toggleRecording, + onPlay: playRecording, + onDelete: deleteRecording, ), ], ), diff --git a/kouyu_english/lib/widgets/voice_answer.dart b/kouyu_english/lib/widgets/voice_answer.dart new file mode 100644 index 0000000..4f7fe6f --- /dev/null +++ b/kouyu_english/lib/widgets/voice_answer.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; + +import '../core/ai_service.dart'; +import '../core/app_state.dart'; +import '../core/voice_service.dart'; + +/// Microphone handling shared by pages where the learner speaks an answer. +/// +/// Two independent flows use the microphone: +/// * voice input records an answer and transcribes it into text +/// ([aiVoiceRecording], [listening], [transcribing]); +/// * the playback recorder keeps an attempt to listen back to +/// ([recording], [playingRecording], [recordingPath]). +mixin VoiceAnswerMixin on State { + static const noSpeechMessage = '未识别到清晰语音,请再试一次或直接输入文字。'; + static const micUnavailableMessage = '无法访问麦克风,请检查手机录音权限。'; + + /// Supplies the AI configuration used for transcription. + AppState get voiceState; + + /// The microphone is recording an answer to transcribe. A page that also + /// uses device speech recognition sets [listening] alone for that. + bool aiVoiceRecording = false; + bool listening = false; + bool transcribing = false; + bool recording = false; + bool playingRecording = false; + String? recordingPath; + + void showVoiceMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + /// Starts recording an answer for transcription and reports whether the + /// microphone started. + Future startVoiceInput({ + String unavailableMessage = micUnavailableMessage, + }) async { + final started = await VoiceService.instance.startRecording(); + if (!mounted) return started; + if (started) { + setState(() { + aiVoiceRecording = true; + listening = true; + }); + } else { + showVoiceMessage(unavailableMessage); + } + return started; + } + + /// Stops the answer recording and transcribes it. + /// + /// [onTranscript] runs inside `setState` with the trimmed, non-empty text. + /// [afterTranscribe] runs once transcription has finished, whether or not + /// anything was recognized. With [keepAudio] the answer audio also becomes + /// the playback [recordingPath]. + Future finishVoiceInput({ + required void Function(String text) onTranscript, + VoidCallback? afterTranscribe, + bool keepAudio = true, + String noSpeech = noSpeechMessage, + }) async { + final path = await VoiceService.instance.stopRecording(); + if (!mounted) return; + setState(() { + aiVoiceRecording = false; + listening = false; + transcribing = path != null; + if (keepAudio) recordingPath = path; + }); + if (path == null) return; + final config = voiceState.aiConfig; + final transcribed = await AiService.instance.transcribeAudio( + filePath: path, + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + ); + if (!mounted) return; + final text = transcribed?.trim() ?? ''; + setState(() { + transcribing = false; + if (text.isNotEmpty) onTranscript(text); + }); + afterTranscribe?.call(); + if (text.isEmpty) showVoiceMessage(noSpeech); + } + + /// Starts a playback recording, replacing the previous one, or stops it. + 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) showVoiceMessage('无法使用麦克风录音;请检查系统权限。'); + } + + 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.stopRecordingPlayback(); + await VoiceService.instance.deleteRecording(recordingPath); + if (mounted) { + setState(() { + recordingPath = null; + playingRecording = false; + }); + } + } + + /// Releases the microphone and player; call from `dispose`. Unless + /// [keepRecording], the recorded attempt is deleted. + void disposeVoiceAnswer({required bool keepRecording}) { + final voice = VoiceService.instance; + voice.stopRecordingPlayback(); + if (listening && !aiVoiceRecording) voice.stopListening(); + if (aiVoiceRecording || recording) { + voice.stopRecording().then((path) { + if (!keepRecording) voice.deleteRecording(path); + }); + } + if (!keepRecording) voice.deleteRecording(recordingPath); + } +} + +/// Record / play back / delete buttons for [VoiceAnswerMixin]'s playback +/// recorder. +class RecordingControls extends StatelessWidget { + const RecordingControls({ + super.key, + required this.recording, + required this.playing, + required this.hasRecording, + required this.onToggleRecording, + required this.onPlay, + required this.onDelete, + }); + + final bool recording; + final bool playing; + final bool hasRecording; + + /// Null disables the record button. + final VoidCallback? onToggleRecording; + final VoidCallback onPlay; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onToggleRecording, + icon: Icon( + recording + ? Icons.stop_circle_outlined + : Icons.fiber_manual_record, + ), + label: Text(recording ? '停止录音' : '录音回听'), + ), + ), + if (hasRecording) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: playing ? '正在播放' : '回听录音', + onPressed: playing ? null : onPlay, + icon: const Icon(Icons.play_arrow), + ), + IconButton( + tooltip: '删除录音', + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + ), + ], + ], + ); + } +}