feat: 增强 HomeScreen 录音交互与 ChatBloc 状态管理
- 新增录音启动延迟处理,解决权限未就绪时的竞态问题 - 实现历史分页滚动位置保持,提升加载体验 - 添加文本输入框点击键盘显示与焦点管理 - 优化 ChatBloc provider 到 MultiBlocProvider 支持 - 修复 ApiException 429 错误详情解析(支持 JSON 字符串 body) - 改进 LocalNotificationService 精确闹钟权限请求 - 优化 UiSchemaRenderer GridView children 生成 - 支持导航 action 的 replace 参数 - 移除 Agent router 速率限制逻辑(_allow_run_request, _allow_transcribe_request) - 补充相关单元测试与集成测试
This commit is contained in:
@@ -1,14 +1,59 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'package:social_app/features/home/data/voice_recorder.dart';
|
||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_floating_header.dart';
|
||||
import 'package:social_app/features/messages/data/inbox_api.dart';
|
||||
import 'package:social_app/shared/widgets/message_composer.dart';
|
||||
|
||||
class _PermissionDeniedRecorder implements VoiceRecorder {
|
||||
_PermissionDeniedRecorder();
|
||||
|
||||
int stopCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
throw StateError('录音权限未授权');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
stopCalls += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _DelayedSuccessRecorder implements VoiceRecorder {
|
||||
_DelayedSuccessRecorder();
|
||||
|
||||
int stopCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
stopCalls += 1;
|
||||
return '/tmp/mock-recording.wav';
|
||||
}
|
||||
}
|
||||
|
||||
class _TestApiClient implements IApiClient {
|
||||
@override
|
||||
@@ -65,6 +110,8 @@ void main() {
|
||||
Future<void> pumpHomeScreen(
|
||||
WidgetTester tester, {
|
||||
List<XFile> initialSelectedImages = const [],
|
||||
VoiceRecorder? voiceRecorder,
|
||||
Future<String> Function(String filePath)? onTranscribeAudio,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -72,6 +119,8 @@ void main() {
|
||||
chatBloc: chatBloc,
|
||||
autoLoadHistory: false,
|
||||
initialSelectedImages: initialSelectedImages,
|
||||
voiceRecorder: voiceRecorder,
|
||||
onTranscribeAudio: onTranscribeAudio,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -111,4 +160,136 @@ void main() {
|
||||
|
||||
expect(find.byKey(homeAttachmentStripKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'long press release does not stop recorder before start succeeds',
|
||||
(tester) async {
|
||||
final recorder = _PermissionDeniedRecorder();
|
||||
await pumpHomeScreen(tester, voiceRecorder: recorder);
|
||||
|
||||
final holdArea = find.byKey(messageComposerHoldAreaKey);
|
||||
expect(holdArea, findsOneWidget);
|
||||
|
||||
final center = tester.getCenter(holdArea);
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(recorder.stopCalls, 0);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('switching to text mode does not auto focus input', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('single tap on input focuses text field after mode switch', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tap focused input triggers keyboard show once', (tester) async {
|
||||
var showCalls = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
|
||||
if (call.method == 'TextInput.show') {
|
||||
showCalls += 1;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.textInput, null);
|
||||
});
|
||||
|
||||
await pumpHomeScreen(tester);
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
showCalls = 0;
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
|
||||
expect(showCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('release during delayed start continues to transcribe path', (
|
||||
tester,
|
||||
) async {
|
||||
final recorder = _DelayedSuccessRecorder();
|
||||
var transcribeCalls = 0;
|
||||
await pumpHomeScreen(
|
||||
tester,
|
||||
voiceRecorder: recorder,
|
||||
onTranscribeAudio: (_) async {
|
||||
transcribeCalls += 1;
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
final holdArea = find.byKey(messageComposerHoldAreaKey);
|
||||
final center = tester.getCenter(holdArea);
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(recorder.stopCalls, 1);
|
||||
expect(transcribeCalls, 1);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('cancel during delayed start skips transcribe path', (
|
||||
tester,
|
||||
) async {
|
||||
final recorder = _DelayedSuccessRecorder();
|
||||
var transcribeCalls = 0;
|
||||
await pumpHomeScreen(
|
||||
tester,
|
||||
voiceRecorder: recorder,
|
||||
onTranscribeAudio: (_) async {
|
||||
transcribeCalls += 1;
|
||||
return 'ignored';
|
||||
},
|
||||
);
|
||||
|
||||
final holdArea = find.byKey(messageComposerHoldAreaKey);
|
||||
final center = tester.getCenter(holdArea);
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await gesture.moveBy(const Offset(0, -90));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(recorder.stopCalls, 1);
|
||||
expect(transcribeCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user