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:
@@ -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<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
|
||||
}
|
||||
|
||||
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
with VoiceAnswerMixin<AdaptiveLessonPage> {
|
||||
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<AdaptiveLessonPage> {
|
||||
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<AdaptiveLessonPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@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;
|
||||
});
|
||||
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<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);
|
||||
},
|
||||
await startVoiceInput(
|
||||
unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<AdaptiveLessonPage> {
|
||||
? '可录音回听并仅保存在本机;不会发送给 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user