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
@@ -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<DialoguePage> createState() => _DialoguePageState();
}
class _DialoguePageState extends State<DialoguePage> {
class _DialoguePageState extends State<DialoguePage>
with VoiceAnswerMixin<DialoguePage> {
final controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> 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<DialoguePage> {
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<DialoguePage> {
_saveDraft();
}
@override
AppState get voiceState => widget.state;
Future<void> _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<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);
await startVoiceInput();
}
void _showWord() => showLexiconLookup(
@@ -919,32 +830,13 @@ class _DialoguePageState extends State<DialoguePage> {
: '这是设备转写;未修改提交后会作为语音尝试保存。',
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