feat: add AI voice transcription fallback for domestic Android ROMs
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import '../../core/ai_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
@@ -32,10 +33,11 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
|
||||
|
||||
Future<void> _checkMicrophone() async {
|
||||
setState(() => checkingMicrophone = true);
|
||||
final ready = await VoiceService.instance.initializeSpeech();
|
||||
final sttReady = await VoiceService.instance.initializeSpeech();
|
||||
final recReady = await VoiceService.instance.hasRecordPermission();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
microphoneReady = ready;
|
||||
microphoneReady = sttReady || recReady;
|
||||
checkingMicrophone = false;
|
||||
});
|
||||
}
|
||||
@@ -135,6 +137,8 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool audioPlayed = false;
|
||||
bool speakingUnavailable = false;
|
||||
AssessmentRecord? completedRecord;
|
||||
@@ -166,31 +170,98 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedMic = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ready) {
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedMic = true;
|
||||
lastTranscript = text;
|
||||
aiVoiceRecording = recordStarted;
|
||||
listening = recordStarted;
|
||||
speakingUnavailable = !recordStarted;
|
||||
});
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,AI 将自动转写为英文。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
listening = ready;
|
||||
speakingUnavailable = !ready;
|
||||
});
|
||||
}
|
||||
if (!ready && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _openCorrect() {
|
||||
@@ -409,8 +480,10 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
),
|
||||
if (task.skill == AssessmentSkill.speaking)
|
||||
SecondaryButton(
|
||||
label: listening ? '停止录音' : '使用麦克风回答',
|
||||
onPressed: _mic,
|
||||
label: transcribing
|
||||
? '正在 AI 识别…'
|
||||
: (listening ? '停止录音并识别' : '使用麦克风回答'),
|
||||
onPressed: transcribing ? null : _mic,
|
||||
),
|
||||
if (task.skill == AssessmentSkill.speaking && speakingUnavailable)
|
||||
SecondaryButton(
|
||||
|
||||
@@ -113,6 +113,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
String? hint;
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
@@ -368,27 +370,94 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() 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();
|
||||
usedVoice = true;
|
||||
lastTranscript = transcribed.trim();
|
||||
transcriptEdited = false;
|
||||
}
|
||||
});
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final available = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
});
|
||||
|
||||
final available = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!available) {
|
||||
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('无法访问麦克风,请检查手机录音权限。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => listening = available);
|
||||
if (!available) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
@@ -572,11 +641,22 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止录音' : '语音输入',
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
onPressed: _toggleListening,
|
||||
tooltip: transcribing
|
||||
? '正在 AI 识别…'
|
||||
: (listening ? '停止录音并识别' : '语音输入'),
|
||||
icon: transcribing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
listening
|
||||
? Icons.stop_circle
|
||||
: Icons.mic_none,
|
||||
color: listening ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: transcribing ? null : _toggleListening,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
|
||||
@@ -147,6 +147,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
|
||||
case LessonStep.independent:
|
||||
content = _IndependentStep(
|
||||
state: widget.state,
|
||||
segmentId: segment.id,
|
||||
keepRecording: widget.state.keepRecordings,
|
||||
activity: activity,
|
||||
@@ -438,6 +439,8 @@ class _SpeakingStep extends StatefulWidget {
|
||||
class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool playingRecording = false;
|
||||
String transcript = '';
|
||||
String? recordingPath;
|
||||
@@ -452,14 +455,83 @@ class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
}
|
||||
|
||||
Future<void> _toggleMic() 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 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;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (mounted) setState(() => transcript = text);
|
||||
});
|
||||
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (mounted) setState(() => transcript = text);
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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('无法访问麦克风,请检查手机录音权限。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) setState(() => listening = ready);
|
||||
}
|
||||
|
||||
@@ -526,8 +598,10 @@ class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
),
|
||||
),
|
||||
SecondaryButton(
|
||||
label: listening ? '停止录音' : '使用麦克风跟读',
|
||||
onPressed: _toggleMic,
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
: (listening ? '停止录音并识别' : '使用麦克风跟读'),
|
||||
onPressed: transcribing ? null : _toggleMic,
|
||||
),
|
||||
SecondaryButton(
|
||||
label: recording ? '停止本机录音' : '录音后回听',
|
||||
@@ -871,6 +945,7 @@ class _DialoguePendingStep extends StatelessWidget {
|
||||
|
||||
class _IndependentStep extends StatefulWidget {
|
||||
const _IndependentStep({
|
||||
required this.state,
|
||||
required this.segmentId,
|
||||
required this.keepRecording,
|
||||
required this.activity,
|
||||
@@ -883,6 +958,7 @@ class _IndependentStep extends StatefulWidget {
|
||||
required this.onContinue,
|
||||
required this.onLater,
|
||||
});
|
||||
final AppState state;
|
||||
final LessonActivity activity;
|
||||
final String segmentId;
|
||||
final bool keepRecording;
|
||||
@@ -902,6 +978,8 @@ class _IndependentStep extends StatefulWidget {
|
||||
class _IndependentStepState extends State<_IndependentStep> {
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
@@ -968,27 +1046,95 @@ class _IndependentStepState extends State<_IndependentStep> {
|
||||
}
|
||||
|
||||
Future<void> _toggleMic() 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 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;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
widget.controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
widget.onChanged();
|
||||
});
|
||||
if (mounted) setState(() => listening = ready);
|
||||
if (!ready && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成写作练习。')));
|
||||
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
widget.controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
widget.onChanged();
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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('无法访问麦克风,请检查手机录音权限。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) setState(() => listening = ready);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -309,6 +309,8 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
bool showReference = false;
|
||||
String? answerFeedback;
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
bool transcriptConfirmed = false;
|
||||
@@ -413,30 +415,100 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() 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();
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = transcribed.trim();
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = text;
|
||||
});
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
});
|
||||
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = text;
|
||||
});
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => listening = ready);
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
@@ -590,11 +662,22 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止语音输入' : '语音输入',
|
||||
tooltip: transcribing
|
||||
? '正在 AI 识别…'
|
||||
: (listening ? '停止录音并识别' : '语音输入'),
|
||||
onPressed: _toggleListening,
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
icon: transcribing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
listening
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.mic_none,
|
||||
color: listening ? AppColors.green : null,
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user