fix: 优化语音识别功能,增加转写中状态和错误处理
This commit is contained in:
@@ -29,6 +29,8 @@ const _inputRadius = 24.0;
|
||||
const _scrollDurationMs = 300;
|
||||
const _rippleDurationMs = 1200;
|
||||
const _recordingDotSize = 10.0;
|
||||
const _transcribingSpinnerSize = 18.0;
|
||||
const _transcribingStrokeWidth = 2.0;
|
||||
|
||||
/// 颜色常量
|
||||
const _chatBgColor = Color(0xFFF8FAFC);
|
||||
@@ -446,6 +448,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Expanded(
|
||||
child: _isRecording
|
||||
? _buildListeningIndicator()
|
||||
: _isTranscribing
|
||||
? _buildTranscribingIndicator()
|
||||
: TextField(
|
||||
controller: _messageController,
|
||||
minLines: 1,
|
||||
@@ -474,7 +478,16 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
: _hasMessage
|
||||
? () => _sendMessage(context)
|
||||
: _startRecording,
|
||||
child: Icon(
|
||||
child: _isTranscribing
|
||||
? const SizedBox(
|
||||
width: _transcribingSpinnerSize,
|
||||
height: _transcribingSpinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isRecording || _hasMessage
|
||||
? LucideIcons.send
|
||||
: LucideIcons.mic,
|
||||
@@ -496,6 +509,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Future<void> _sendMessage(BuildContext context) async {
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
_messageController.clear();
|
||||
context.read<ChatBloc>().sendMessage(content);
|
||||
|
||||
@@ -548,6 +562,27 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTranscribingIndicator() {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 180),
|
||||
builder: (context, value, child) {
|
||||
return Opacity(opacity: value, child: child);
|
||||
},
|
||||
child: const SizedBox(
|
||||
key: ValueKey('transcribing_indicator'),
|
||||
height: _inputMinHeight,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'语音识别中...',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveDot({required double scale}) {
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
@@ -599,13 +634,18 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final normalizedTranscript = transcript.trim();
|
||||
if (normalizedTranscript.isEmpty) {
|
||||
Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
_messageController.text = transcript;
|
||||
_messageController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: transcript.length),
|
||||
);
|
||||
if (autoSendAfterTranscribe && transcript.trim().isNotEmpty) {
|
||||
if (autoSendAfterTranscribe) {
|
||||
_messageController.clear();
|
||||
await _autoSendTranscript(transcript.trim());
|
||||
await _autoSendTranscript(normalizedTranscript);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
@@ -613,12 +653,16 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
Toast.show(context, _readableError(error), type: ToastType.error);
|
||||
} finally {
|
||||
try {
|
||||
if (audioPath != null) {
|
||||
final file = File(audioPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore temp file cleanup errors to avoid blocking UI state recovery.
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isTranscribing = false;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
@@ -17,7 +20,8 @@ class _FakeVoiceRecorder implements VoiceRecorder {
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
started = false;
|
||||
stoppedPath = '/tmp/test-audio.wav';
|
||||
stoppedPath ??=
|
||||
'${Directory.systemTemp.path}/test-audio-${DateTime.now().microsecondsSinceEpoch}.wav';
|
||||
return stoppedPath;
|
||||
}
|
||||
|
||||
@@ -90,7 +94,7 @@ void main() {
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (filePath) async {
|
||||
expect(filePath, '/tmp/test-audio.wav');
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音自动发送';
|
||||
},
|
||||
onAutoSendTranscript: (transcript) async {
|
||||
@@ -104,13 +108,13 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.send));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(sentTranscript, '语音自动发送');
|
||||
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap stop transcribes audio and fills input', (
|
||||
testWidgets('tap stop enters transcribing state', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
@@ -120,7 +124,7 @@ void main() {
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (filePath) async {
|
||||
expect(filePath, '/tmp/test-audio.wav');
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音转文字结果';
|
||||
},
|
||||
),
|
||||
@@ -131,10 +135,10 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音转文字结果'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows readable unauthorized message', (
|
||||
@@ -157,10 +161,91 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('请重新登录'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows message when transcript is empty', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) async => '',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('shows transcribing indicator while waiting ASR result', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
final completer = Completer<String>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) => completer.future,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
completer.complete('识别完成');
|
||||
});
|
||||
|
||||
testWidgets('tap send unfocuses text input after sending', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), 'hello');
|
||||
await tester.pump();
|
||||
|
||||
final editableBefore = tester.state<EditableTextState>(
|
||||
find.byType(EditableText),
|
||||
);
|
||||
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.send));
|
||||
await tester.pump();
|
||||
|
||||
final editableAfter = tester.state<EditableTextState>(
|
||||
find.byType(EditableText),
|
||||
);
|
||||
expect(editableAfter.widget.focusNode.hasFocus, isFalse);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user