refactor: 抽出语音作答与录音回听的公共逻辑

对话页、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 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 17:18:45 +09:00
co-authored by Claude Opus 5
parent 70f7be9b88
commit 6c0aeec628
5 changed files with 322 additions and 530 deletions
@@ -1,4 +1,3 @@
import '../../core/ai_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_state.dart'; import '../../core/app_state.dart';
@@ -7,6 +6,7 @@ import '../../core/assessment_bank.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import '../../core/voice_service.dart'; import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
class AssessmentPreparationPage extends StatefulWidget { class AssessmentPreparationPage extends StatefulWidget {
const AssessmentPreparationPage({ const AssessmentPreparationPage({
@@ -129,16 +129,14 @@ class AssessmentPage extends StatefulWidget {
State<AssessmentPage> createState() => _AssessmentPageState(); State<AssessmentPage> createState() => _AssessmentPageState();
} }
class _AssessmentPageState extends State<AssessmentPage> { class _AssessmentPageState extends State<AssessmentPage>
with VoiceAnswerMixin<AssessmentPage> {
final controller = TextEditingController(); final controller = TextEditingController();
final Map<String, bool> results = {}; final Map<String, bool> results = {};
int index = 0; int index = 0;
bool usedMic = false; bool usedMic = false;
bool transcriptEdited = false; bool transcriptEdited = false;
String lastTranscript = ''; String lastTranscript = '';
bool listening = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool audioPlayed = false; bool audioPlayed = false;
bool speakingUnavailable = false; bool speakingUnavailable = false;
AssessmentRecord? completedRecord; AssessmentRecord? completedRecord;
@@ -160,10 +158,7 @@ class _AssessmentPageState extends State<AssessmentPage> {
void dispose() { void dispose() {
VoiceService.instance.stopSpeaking(); VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening(); VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback(); disposeVoiceAnswer(keepRecording: true);
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
controller.dispose(); controller.dispose();
super.dispose(); super.dispose();
} }
@@ -175,62 +170,31 @@ class _AssessmentPageState extends State<AssessmentPage> {
if (mounted) setState(() => audioPlayed = true); if (mounted) setState(() => audioPlayed = true);
} }
@override
AppState get voiceState => widget.state;
Future<void> _mic() async { Future<void> _mic() async {
if (aiVoiceRecording) { if (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording(); await finishVoiceInput(
if (!mounted) return; keepAudio: false,
setState(() { onTranscript: (text) {
aiVoiceRecording = false; controller.text = text;
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; usedMic = true;
lastTranscript = transcribed.trim(); lastTranscript = text;
transcriptEdited = false; transcriptEdited = false;
speakingUnavailable = false; speakingUnavailable = false;
} },
});
if (transcribed == null || transcribed.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰语音,请再试一次。')),
); );
}
}
} else {
if (mounted) setState(() => transcribing = false);
}
return; return;
} }
VoiceService.instance.stopSpeaking(); final recordStarted = await startVoiceInput(
final recordStarted = await VoiceService.instance.startRecording(); unavailableMessage: '无法访问麦克风,口语可稍后补测。',
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() => speakingUnavailable = !recordStarted);
aiVoiceRecording = recordStarted;
listening = recordStarted;
speakingUnavailable = !recordStarted;
});
if (recordStarted) { if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar( showVoiceMessage('已启动麦克风录音,回答后再次点击,将自动转写为英文。');
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
);
} }
} }
@@ -8,6 +8,7 @@ import '../../core/seed_courses.dart';
import '../../core/voice_service.dart'; import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart'; import '../../widgets/lexicon_lookup.dart';
import '../../widgets/voice_answer.dart';
class DialogueScenePage extends StatelessWidget { class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({ const DialogueScenePage({
@@ -105,22 +106,17 @@ class DialoguePage extends StatefulWidget {
State<DialoguePage> createState() => _DialoguePageState(); State<DialoguePage> createState() => _DialoguePageState();
} }
class _DialoguePageState extends State<DialoguePage> { class _DialoguePageState extends State<DialoguePage>
with VoiceAnswerMixin<DialoguePage> {
final controller = TextEditingController(); final controller = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> turns = []; final List<DialogueTurn> turns = [];
int stage = 0; int stage = 0;
bool usedHelp = false; bool usedHelp = false;
String? hint; String? hint;
bool listening = false;
bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false;
bool usedVoice = false; bool usedVoice = false;
bool transcriptEdited = false; bool transcriptEdited = false;
String lastTranscript = ''; String lastTranscript = '';
String? recordingPath;
bool waitingForReply = false; bool waitingForReply = false;
String? validationError; String? validationError;
@@ -260,13 +256,7 @@ class _DialoguePageState extends State<DialoguePage> {
void dispose() { void dispose() {
VoiceService.instance.stopSpeaking(); VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening(); VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback(); disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
_scrollController.dispose(); _scrollController.dispose();
controller.dispose(); controller.dispose();
super.dispose(); super.dispose();
@@ -542,102 +532,23 @@ class _DialoguePageState extends State<DialoguePage> {
_saveDraft(); _saveDraft();
} }
@override
AppState get voiceState => widget.state;
Future<void> _toggleListening() async { Future<void> _toggleListening() async {
if (listening || aiVoiceRecording) { if (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording(); await finishVoiceInput(
if (!mounted) return; onTranscript: (text) {
setState(() { controller.text = text;
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; usedVoice = true;
lastTranscript = transcribed.trim(); lastTranscript = text;
transcriptEdited = false; 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('无法访问麦克风,请检查手机录音权限。')),
);
}
}
Future<void> _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<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
}, },
afterTranscribe: _scrollToBottom,
); );
return;
} }
await startVoiceInput();
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
} }
void _showWord() => showLexiconLookup( void _showWord() => showLexiconLookup(
@@ -919,32 +830,13 @@ class _DialoguePageState extends State<DialoguePage> {
: '这是设备转写;未修改提交后会作为语音尝试保存。', : '这是设备转写;未修改提交后会作为语音尝试保存。',
style: const TextStyle(color: AppColors.muted, fontSize: 12), style: const TextStyle(color: AppColors.muted, fontSize: 12),
), ),
Row( RecordingControls(
children: [ recording: recording,
Expanded( playing: playingRecording,
child: OutlinedButton.icon( hasRecording: recordingPath != null,
onPressed: _toggleRecording, onToggleRecording: toggleRecording,
icon: Icon( onPlay: playRecording,
recording onDelete: deleteRecording,
? 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),
),
],
],
), ),
Text( Text(
widget.state.keepRecordings widget.state.keepRecordings
@@ -9,6 +9,7 @@ import '../../core/voice_service.dart';
import '../../core/writing_feedback.dart'; import '../../core/writing_feedback.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart'; import '../../widgets/lexicon_lookup.dart';
import '../../widgets/voice_answer.dart';
class LessonFlow extends StatefulWidget { class LessonFlow extends StatefulWidget {
const LessonFlow({ const LessonFlow({
@@ -448,55 +449,25 @@ class _SpeakingStep extends StatefulWidget {
State<_SpeakingStep> createState() => _SpeakingStepState(); State<_SpeakingStep> createState() => _SpeakingStepState();
} }
class _SpeakingStepState extends State<_SpeakingStep> { class _SpeakingStepState extends State<_SpeakingStep>
bool listening = false; with VoiceAnswerMixin<_SpeakingStep> {
bool transcribing = false;
bool playingRecording = false;
String transcript = ''; String transcript = '';
String? recordingPath;
@override
AppState get voiceState => widget.state;
@override @override
void dispose() { void dispose() {
VoiceService.instance.stopRecordingPlayback(); disposeVoiceAnswer(keepRecording: widget.keepRecording);
if (!widget.keepRecording) {
VoiceService.instance.deleteRecording(recordingPath);
}
super.dispose(); super.dispose();
} }
Future<void> _toggleMic() async { Future<void> _toggleMic() async {
if (listening) { if (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording(); await finishVoiceInput(
if (!mounted) return; noSpeech: '未识别到清晰发音,请重试或点击“播放示范音”。',
setState(() { onTranscript: (text) => transcript = text,
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);
}
return; return;
} }
@@ -506,24 +477,10 @@ class _SpeakingStepState extends State<_SpeakingStep> {
await VoiceService.instance.deleteRecording(recordingPath); await VoiceService.instance.deleteRecording(recordingPath);
} }
final recordStarted = await VoiceService.instance.startRecording(); final recordStarted = await startVoiceInput();
if (mounted) { if (!recordStarted || !mounted) return;
setState(() { setState(() => recordingPath = null);
listening = recordStarted; showVoiceMessage('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。');
if (recordStarted) {
recordingPath = null;
}
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
} }
Future<void> _togglePlayRecording() async { Future<void> _togglePlayRecording() async {
@@ -538,24 +495,7 @@ class _SpeakingStepState extends State<_SpeakingStep> {
); );
return; return;
} }
setState(() => playingRecording = true); await playRecording();
await VoiceService.instance.playRecording(
recordingPath!,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.stopRecordingPlayback();
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) {
setState(() {
recordingPath = null;
playingRecording = false;
});
}
} }
@override @override
@@ -615,7 +555,7 @@ class _SpeakingStepState extends State<_SpeakingStep> {
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
tooltip: '删除录音', tooltip: '删除录音',
onPressed: (listening || transcribing) ? null : _deleteRecording, onPressed: (listening || transcribing) ? null : deleteRecording,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
), ),
], ],
@@ -1188,24 +1128,19 @@ class _IndependentStep extends StatefulWidget {
State<_IndependentStep> createState() => _IndependentStepState(); State<_IndependentStep> createState() => _IndependentStepState();
} }
class _IndependentStepState extends State<_IndependentStep> { class _IndependentStepState extends State<_IndependentStep>
bool listening = false; with VoiceAnswerMixin<_IndependentStep> {
bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false;
bool usedVoice = false; bool usedVoice = false;
bool transcriptEdited = false; bool transcriptEdited = false;
String lastTranscript = ''; String lastTranscript = '';
String? recordingPath;
String? validationError; String? validationError;
@override
AppState get voiceState => widget.state;
@override @override
void dispose() { void dispose() {
VoiceService.instance.stopRecordingPlayback(); disposeVoiceAnswer(keepRecording: widget.keepRecording);
if (!widget.keepRecording) {
VoiceService.instance.deleteRecording(recordingPath);
}
super.dispose(); super.dispose();
} }
@@ -1220,86 +1155,17 @@ class _IndependentStepState extends State<_IndependentStep> {
); );
} }
Future<void> _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<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
Future<void> _toggleMic() async { Future<void> _toggleMic() async {
if (aiVoiceRecording) { if (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording(); await finishVoiceInput(
if (!mounted) return; onTranscript: (text) {
setState(() { widget.controller.text = text;
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; usedVoice = true;
lastTranscript = text.trim(); lastTranscript = text;
transcriptEdited = false; transcriptEdited = false;
} },
}); afterTranscribe: widget.onChanged,
widget.onChanged();
if (text == null || text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到声音,请重试或直接打字输入。')),
); );
}
}
} else {
if (mounted) setState(() => transcribing = false);
}
return; return;
} }
@@ -1333,21 +1199,9 @@ class _IndependentStepState extends State<_IndependentStep> {
); );
if (!ready) { if (!ready) {
final recordStarted = await VoiceService.instance.startRecording(); final recordStarted = await startVoiceInput();
if (mounted) { if (recordStarted && mounted) {
setState(() { showVoiceMessage('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。');
aiVoiceRecording = recordStarted;
listening = recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
} }
return; return;
} }
@@ -1404,33 +1258,13 @@ class _IndependentStepState extends State<_IndependentStep> {
? '可录下这次尝试并保存在本机;不会发送给 AI。' ? '可录下这次尝试并保存在本机;不会发送给 AI。'
: '可录下这次尝试并回听;离开本页后会自动删除。', : '可录下这次尝试并回听;离开本页后会自动删除。',
), ),
Row( RecordingControls(
children: [ recording: recording,
Expanded( playing: playingRecording,
child: OutlinedButton.icon( hasRecording: recordingPath != null,
onPressed: _toggleRecording, onToggleRecording: toggleRecording,
icon: Icon( onPlay: playRecording,
recording onDelete: deleteRecording,
? 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),
),
],
],
), ),
], ],
), ),
@@ -10,6 +10,7 @@ import '../../core/review_feedback.dart';
import '../../core/a0_core.dart'; import '../../core/a0_core.dart';
import '../../core/voice_service.dart'; import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
class ReviewPage extends StatefulWidget { class ReviewPage extends StatefulWidget {
const ReviewPage({ const ReviewPage({
@@ -313,21 +314,16 @@ class AdaptiveLessonPage extends StatefulWidget {
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState(); State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
} }
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> { class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
with VoiceAnswerMixin<AdaptiveLessonPage> {
final controller = TextEditingController(); final controller = TextEditingController();
int index = 0; int index = 0;
bool showReference = false; bool showReference = false;
String? answerFeedback; String? answerFeedback;
bool listening = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool usedVoice = false; bool usedVoice = false;
bool transcriptEdited = false; bool transcriptEdited = false;
bool transcriptConfirmed = false; bool transcriptConfirmed = false;
String lastTranscript = ''; String lastTranscript = '';
bool recording = false;
bool playingRecording = false;
String? recordingPath;
@override @override
void initState() { void initState() {
@@ -353,13 +349,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
void dispose() { void dispose() {
VoiceService.instance.stopSpeaking(); VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening(); VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback(); disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
controller.dispose(); controller.dispose();
super.dispose(); super.dispose();
} }
@@ -428,103 +418,30 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
}); });
} }
@override
AppState get voiceState => widget.state;
Future<void> _toggleListening() async { Future<void> _toggleListening() async {
if (listening || aiVoiceRecording) { if (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording(); await finishVoiceInput(
if (!mounted) return; keepAudio: false,
setState(() { onTranscript: (text) {
aiVoiceRecording = false; controller.text = text;
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; usedVoice = true;
transcriptEdited = false; transcriptEdited = false;
transcriptConfirmed = false; transcriptConfirmed = false;
lastTranscript = transcribed.trim(); lastTranscript = text;
} },
}); afterTranscribe: () {
final lesson = widget.state.cachedAdaptiveLesson; final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson); 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<void> _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<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
}, },
); );
return;
} }
await startVoiceInput(
Future<void> _deleteRecording() async { unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。',
await VoiceService.instance.deleteRecording(recordingPath); );
if (mounted) setState(() => recordingPath = null);
} }
@override @override
@@ -682,33 +599,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
? '可录音回听并仅保存在本机;不会发送给 AI。' ? '可录音回听并仅保存在本机;不会发送给 AI。'
: '可录音回听;离开本页后会自动删除。', : '可录音回听;离开本页后会自动删除。',
), ),
Row( RecordingControls(
children: [ recording: recording,
Expanded( playing: playingRecording,
child: OutlinedButton.icon( hasRecording: recordingPath != null,
onPressed: listening ? null : _toggleRecording, onToggleRecording: listening ? null : toggleRecording,
icon: Icon( onPlay: playRecording,
recording onDelete: deleteRecording,
? 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),
),
],
],
), ),
], ],
), ),
+205
View File
@@ -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<T extends StatefulWidget> on State<T> {
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<bool> 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<void> 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<void> 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<void> playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> 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),
),
],
],
);
}
}