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:
qzl
2026-03-18 17:03:22 +08:00
parent b34697660d
commit 8539f05a66
13 changed files with 578 additions and 143 deletions
@@ -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);
});
}