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:
@@ -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;
|
usedMic = true;
|
||||||
transcribing = true;
|
lastTranscript = text;
|
||||||
});
|
transcriptEdited = false;
|
||||||
if (path != null) {
|
speakingUnavailable = false;
|
||||||
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);
|
|
||||||
}
|
|
||||||
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;
|
usedVoice = true;
|
||||||
listening = false;
|
lastTranscript = text;
|
||||||
transcribing = true;
|
transcriptEdited = false;
|
||||||
recordingPath = path;
|
},
|
||||||
});
|
afterTranscribe: _scrollToBottom,
|
||||||
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('无法访问麦克风,请检查手机录音权限。')),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleRecording() async {
|
|
||||||
if (recording) {
|
|
||||||
final path = await VoiceService.instance.stopRecording();
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
recording = false;
|
|
||||||
recordingPath = path;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await VoiceService.instance.deleteRecording(recordingPath);
|
await startVoiceInput();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
usedVoice = true;
|
||||||
listening = false;
|
lastTranscript = text;
|
||||||
transcribing = true;
|
transcriptEdited = false;
|
||||||
recordingPath = path;
|
},
|
||||||
});
|
afterTranscribe: widget.onChanged,
|
||||||
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);
|
|
||||||
}
|
|
||||||
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,105 +418,32 @@ 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;
|
usedVoice = true;
|
||||||
transcribing = true;
|
transcriptEdited = false;
|
||||||
});
|
transcriptConfirmed = false;
|
||||||
if (path != null) {
|
lastTranscript = text;
|
||||||
final config = widget.state.aiConfig;
|
},
|
||||||
final transcribed = await AiService.instance.transcribeAudio(
|
afterTranscribe: () {
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
await VoiceService.instance.deleteRecording(recordingPath);
|
await startVoiceInput(
|
||||||
final ready = await VoiceService.instance.startRecording();
|
unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。',
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final lesson = widget.state.cachedAdaptiveLesson;
|
final lesson = widget.state.cachedAdaptiveLesson;
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user