From 2ec09653227460d8c1471e945b4ac06e381aaf4c Mon Sep 17 00:00:00 2001 From: qzl Date: Tue, 10 Mar 2026 17:43:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BD=AC=E5=86=99=E4=B8=AD=E7=8A=B6=E6=80=81=E5=92=8C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/home/ui/screens/home_screen.dart | 74 ++++++++++--- .../home/ui/screens/home_screen_test.dart | 103 ++++++++++++++++-- 2 files changed, 153 insertions(+), 24 deletions(-) diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 151ca68..3269590 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -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 Expanded( child: _isRecording ? _buildListeningIndicator() + : _isTranscribing + ? _buildTranscribingIndicator() : TextField( controller: _messageController, minLines: 1, @@ -474,15 +478,24 @@ class _HomeScreenState extends State : _hasMessage ? () => _sendMessage(context) : _startRecording, - child: Icon( - _isRecording || _hasMessage - ? LucideIcons.send - : LucideIcons.mic, - size: _iconSize, - color: _isRecording || _hasMessage - ? AppColors.blue600 - : AppColors.slate500, - ), + child: _isTranscribing + ? const SizedBox( + width: _transcribingSpinnerSize, + height: _transcribingSpinnerSize, + child: CircularProgressIndicator( + strokeWidth: _transcribingStrokeWidth, + color: AppColors.blue600, + ), + ) + : Icon( + _isRecording || _hasMessage + ? LucideIcons.send + : LucideIcons.mic, + size: _iconSize, + color: _isRecording || _hasMessage + ? AppColors.blue600 + : AppColors.slate500, + ), ), ], ), @@ -496,6 +509,7 @@ class _HomeScreenState extends State Future _sendMessage(BuildContext context) async { final content = _messageController.text.trim(); if (content.isEmpty) return; + FocusScope.of(context).unfocus(); _messageController.clear(); context.read().sendMessage(content); @@ -548,6 +562,27 @@ class _HomeScreenState extends State ); } + Widget _buildTranscribingIndicator() { + return TweenAnimationBuilder( + tween: Tween(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 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,11 +653,15 @@ class _HomeScreenState extends State } Toast.show(context, _readableError(error), type: ToastType.error); } finally { - if (audioPath != null) { - final file = File(audioPath); - if (await file.exists()) { - await file.delete(); + 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(() { diff --git a/apps/test/features/home/ui/screens/home_screen_test.dart b/apps/test/features/home/ui/screens/home_screen_test.dart index dc39b56..37de2be 100644 --- a/apps/test/features/home/ui/screens/home_screen_test.dart +++ b/apps/test/features/home/ui/screens/home_screen_test.dart @@ -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 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(); + + 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( + find.byType(EditableText), + ); + expect(editableBefore.widget.focusNode.hasFocus, isTrue); + + await tester.tap(find.byIcon(LucideIcons.send)); + await tester.pump(); + + final editableAfter = tester.state( + find.byType(EditableText), + ); + expect(editableAfter.widget.focusNode.hasFocus, isFalse); + + await tester.pump(const Duration(milliseconds: 300)); + }); }); }