feat(apps): update UI screens and shared components

- Update home screen with new composer and interactions
- Update settings screens with new profile flow
- Update calendar share dialog
- Update contacts screen
- Add new shared widgets: confirm_sheet, phone_prefix_selector
- Add new formatters: phone_display_formatter
- Update tests for modified components
This commit is contained in:
qzl
2026-03-19 18:43:08 +08:00
parent f0af44d840
commit 8d4a14150b
24 changed files with 868 additions and 989 deletions
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/api/i_api_client.dart';
@@ -5,9 +7,15 @@ import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
class _FakeApiClient implements IApiClient {
_FakeApiClient({required this.sseLines});
_FakeApiClient({
required this.sseLines,
this.sseLineStreamFactory,
this.runIdFactory,
});
final List<String> sseLines;
final Stream<String> Function()? sseLineStreamFactory;
final String Function()? runIdFactory;
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
@@ -24,6 +32,10 @@ class _FakeApiClient implements IApiClient {
String path, {
Map<String, String>? headers,
}) async {
final streamFactory = sseLineStreamFactory;
if (streamFactory != null) {
return streamFactory();
}
return Stream<String>.fromIterable(sseLines);
}
@@ -34,10 +46,11 @@ class _FakeApiClient implements IApiClient {
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
final runIdFactory = this.runIdFactory;
final payload = <String, dynamic>{
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-new',
'runId': runIdFactory != null ? runIdFactory() : 'run-new',
'created': true,
};
return Response<T>(
@@ -149,4 +162,110 @@ void main() {
expect(events[2], isA<RunFinishedEvent>());
},
);
test('cancelCurrentRun actively closes current SSE subscription', () async {
var streamCancelled = false;
final streamController = StreamController<String>(
onCancel: () {
streamCancelled = true;
},
);
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: const <String>[],
sseLineStreamFactory: () => streamController.stream,
),
);
final sendFuture = service.sendMessage('hello');
await Future<void>.delayed(Duration.zero);
await service.cancelCurrentRun();
await sendFuture;
expect(streamCancelled, isTrue);
await streamController.close();
});
test(
'new sendMessage cancels previous SSE subscription explicitly',
() async {
var firstStreamCancelled = false;
final firstController = StreamController<String>(
onCancel: () {
firstStreamCancelled = true;
},
);
final secondController = StreamController<String>();
final streamQueue = <StreamController<String>>[
firstController,
secondController,
];
var streamIndex = 0;
var runIndex = 0;
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: const <String>[],
sseLineStreamFactory: () => streamQueue[streamIndex++].stream,
runIdFactory: () {
runIndex += 1;
return 'run-$runIndex';
},
),
);
final firstSendFuture = service.sendMessage('first');
await Future<void>.delayed(Duration.zero);
final secondSendFuture = service.sendMessage('second');
await Future<void>.delayed(Duration.zero);
for (final line in _buildSseEvent(
id: '21',
type: AgUiEventTypeWire.runStarted,
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
)) {
secondController.add(line);
}
for (final line in _buildSseEvent(
id: '22',
type: AgUiEventTypeWire.runFinished,
payload:
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
)) {
secondController.add(line);
}
await secondController.close();
await firstSendFuture;
await secondSendFuture;
expect(firstStreamCancelled, isTrue);
await firstController.close();
},
);
test('sendMessage surfaces event callback exceptions', () async {
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: <String>[
..._buildSseEvent(
id: '31',
type: AgUiEventTypeWire.runStarted,
payload:
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
),
..._buildSseEvent(
id: '32',
type: AgUiEventTypeWire.runFinished,
payload:
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
),
],
),
);
service.onEvent = (_) => throw StateError('event callback failed');
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
});
}
@@ -1,17 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/home/ui/widgets/home_background_field.dart';
void main() {
testWidgets('home background field renders layered glow surfaces', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: HomeBackgroundField())),
);
expect(find.byKey(homeBackgroundFieldKey), findsOneWidget);
expect(find.byKey(homeTopGlowKey), findsOneWidget);
expect(find.byKey(homeBottomGlowKey), findsOneWidget);
});
}
@@ -1,266 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/message_composer.dart';
Widget _buildTestApp({
required MessageComposerMode mode,
required MessageComposerProcess process,
required bool hasMessage,
required bool isWaitingAgent,
VoidCallback? onHoldStart,
VoidCallback? onHoldEnd,
VoidCallback? onHoldCancel,
}) {
return MaterialApp(
home: Scaffold(
body: MessageComposer(
mode: mode,
process: process,
hasMessage: hasMessage,
isWaitingAgent: isWaitingAgent,
iconSize: 24,
composerMinHeight: 48,
onTapPlus: () {},
onTapRightAction: () {},
onHoldToSpeakStart: onHoldStart ?? () {},
onHoldToSpeakEnd: onHoldEnd ?? () {},
onHoldToSpeakMoveUpdate: (_) {},
onHoldToSpeakCancel: onHoldCancel ?? () {},
textInputChild: const SizedBox.shrink(),
recordingAnimation: const SizedBox.shrink(),
),
),
);
}
void main() {
group('MessageComposer', () {
testWidgets('renders one unified rounded composer container', (
tester,
) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerContainerKey), findsOneWidget);
expect(find.byKey(messageComposerShellKey), findsOneWidget);
expect(find.byKey(messageComposerInnerKey), findsOneWidget);
final containerFinder = find.byKey(messageComposerContainerKey);
final shellFinder = find.byKey(messageComposerShellKey);
final plusFinder = find.byKey(messageComposerPlusButtonKey);
final rightFinder = find.byKey(messageComposerRightButtonKey);
expect(
find.descendant(of: containerFinder, matching: plusFinder),
findsOneWidget,
);
expect(
find.descendant(of: containerFinder, matching: rightFinder),
findsOneWidget,
);
final container = tester.widget<Container>(shellFinder);
final decoration = container.decoration! as BoxDecoration;
expect(decoration.color, AppColors.homeComposerShell);
expect(
(decoration.border! as Border).top.color,
AppColors.homeComposerBorder,
);
});
testWidgets('recording state keeps unified floating shell', (tester) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.recording,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerShellKey), findsOneWidget);
expect(find.byKey(messageComposerInnerKey), findsOneWidget);
expect(find.text('松开发送'), findsOneWidget);
});
testWidgets('right action icon follows state priority', (tester) async {
Future<IconData> rightIconFor({
required MessageComposerMode mode,
required MessageComposerProcess process,
required bool hasMessage,
required bool isWaitingAgent,
}) async {
await tester.pumpWidget(
_buildTestApp(
mode: mode,
process: process,
hasMessage: hasMessage,
isWaitingAgent: isWaitingAgent,
),
);
final iconFinder = find.descendant(
of: find.byKey(messageComposerRightButtonKey),
matching: find.byType(Icon),
);
expect(iconFinder, findsOneWidget);
final iconWidget = tester.widget<Icon>(iconFinder.first);
expect(iconWidget.icon, isNotNull);
return iconWidget.icon!;
}
expect(
await rightIconFor(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: true,
),
LucideIcons.square,
);
expect(
await rightIconFor(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: true,
isWaitingAgent: false,
),
LucideIcons.send,
);
expect(
await rightIconFor(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
LucideIcons.keyboard,
);
expect(
await rightIconFor(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
LucideIcons.mic,
);
});
testWidgets('recording hint appears only while recording', (tester) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerRecordingHintKey), findsNothing);
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.recording,
hasMessage: false,
isWaitingAgent: false,
),
);
expect(find.byKey(messageComposerRecordingHintKey), findsOneWidget);
expect(find.text('松开发送,上滑取消'), findsOneWidget);
});
testWidgets('composer height remains stable across mode switches', (
tester,
) async {
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.text,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
final textHeight = tester.getSize(
find.byKey(messageComposerContainerKey),
);
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
),
);
final holdHeight = tester.getSize(
find.byKey(messageComposerContainerKey),
);
expect(textHeight.height, holdHeight.height);
});
testWidgets('invokes long press start/end callbacks in hold mode', (
tester,
) async {
var started = false;
var ended = false;
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
onHoldStart: () => started = true,
onHoldEnd: () => ended = true,
),
);
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
final gesture = await tester.startGesture(center);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
await gesture.up();
await tester.pump();
expect(started, isTrue);
expect(ended, isTrue);
});
testWidgets('invokes long press cancel callback when gesture canceled', (
tester,
) async {
var canceled = false;
await tester.pumpWidget(
_buildTestApp(
mode: MessageComposerMode.holdToSpeak,
process: MessageComposerProcess.idle,
hasMessage: false,
isWaitingAgent: false,
onHoldCancel: () => canceled = true,
),
);
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
final gesture = await tester.startGesture(center);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
await gesture.cancel();
await tester.pump();
expect(canceled, isTrue);
});
});
}
@@ -195,9 +195,7 @@ void main() {
},
);
testWidgets('switching to text mode does not auto focus input', (
tester,
) async {
testWidgets('switching to text mode auto focuses input', (tester) async {
await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey));
@@ -205,12 +203,10 @@ void main() {
await tester.pump();
final editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.focusNode.hasFocus, isFalse);
expect(editable.focusNode.hasFocus, isTrue);
});
testWidgets('single tap on input focuses text field after mode switch', (
tester,
) async {
testWidgets('single tap on input keeps text field focused', (tester) async {
await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey));
@@ -224,7 +220,9 @@ void main() {
expect(editable.focusNode.hasFocus, isTrue);
});
testWidgets('tap focused input triggers keyboard show once', (tester) async {
testWidgets('switching to text mode triggers keyboard show fallback', (
tester,
) async {
var showCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
@@ -242,15 +240,92 @@ void main() {
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 130));
await tester.tap(find.byType(EditableText));
expect(showCalls, greaterThanOrEqualTo(1));
});
testWidgets('tap center of input lane focuses text field', (tester) async {
await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.pump();
final composerRect = tester.getRect(find.byKey(messageComposerInnerKey));
final centerLaneTap = Offset(
composerRect.left + composerRect.width * 0.5,
composerRect.center.dy,
);
await tester.tapAt(centerLaneTap);
await tester.pump();
final editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.focusNode.hasFocus, isTrue);
});
testWidgets('tap focused input triggers at most one keyboard show', (
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.pump(const Duration(milliseconds: 130));
showCalls = 0;
await tester.tap(find.byType(EditableText));
await tester.pump();
expect(showCalls, 1);
expect(showCalls, lessThanOrEqualTo(1));
});
testWidgets('double toggle returns to hold-to-speak mode', (tester) async {
await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.pump();
expect(find.byType(EditableText), findsOneWidget);
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.pump();
expect(find.byType(EditableText), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('rapid triple toggle ends in text mode with focused input', (
tester,
) async {
await pumpHomeScreen(tester);
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.tap(find.byKey(messageComposerRightButtonKey));
await tester.pump();
await tester.pump();
final editable = tester.widget<EditableText>(find.byType(EditableText));
expect(editable.focusNode.hasFocus, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('release during delayed start continues to transcribe path', (
@@ -1,82 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/core/di/injection.dart';
import 'package:social_app/features/auth/data/auth_repository.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:social_app/features/settings/ui/screens/change_password_screen.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late MockAuthRepository mockAuthRepository;
late AuthBloc authBloc;
setUp(() async {
mockAuthRepository = MockAuthRepository();
await sl.reset();
sl.registerSingleton<AuthRepository>(mockAuthRepository);
authBloc = AuthBloc(mockAuthRepository);
authBloc.add(
const AuthLoggedIn(
user: AuthUser(id: 'user-1', email: 'tester@example.com'),
),
);
});
tearDown(() async {
await authBloc.close();
await sl.reset();
});
Future<void> pumpScreen(WidgetTester tester) async {
await tester.pumpWidget(
BlocProvider<AuthBloc>.value(
value: authBloc,
child: const MaterialApp(home: ChangePasswordScreen()),
),
);
await tester.pump();
}
testWidgets('确认修改按钮在验证码发送前不可点击', (tester) async {
when(
() => mockAuthRepository.requestPasswordReset(any()),
).thenAnswer((_) async {});
await pumpScreen(tester);
final confirmButton = tester.widget<ElevatedButton>(
find.widgetWithText(ElevatedButton, '确认修改'),
);
expect(confirmButton.onPressed, isNull);
expect(find.text('设置新密码'), findsNothing);
});
testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async {
final completer = Completer<void>();
when(
() => mockAuthRepository.requestPasswordReset(any()),
).thenAnswer((_) => completer.future);
await pumpScreen(tester);
await tester.tap(find.widgetWithText(ElevatedButton, '发送验证码'));
await tester.pump();
expect(find.text('60 秒后可重发'), findsOneWidget);
expect(find.text('设置新密码'), findsOneWidget);
verify(
() => mockAuthRepository.requestPasswordReset('tester@example.com'),
).called(1);
completer.complete();
});
}
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/settings/ui/widgets/account_section_card.dart';
import 'package:social_app/features/settings/ui/widgets/settings_page_scaffold.dart';
void main() {
testWidgets('AccountSectionCard renders title and description', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: AccountSectionCard(
title: '基础信息',
description: '请填写公开展示资料',
child: Text('内容区'),
),
),
),
);
expect(find.text('基础信息'), findsOneWidget);
expect(find.text('请填写公开展示资料'), findsOneWidget);
expect(find.text('内容区'), findsOneWidget);
});
testWidgets('SettingsPageScaffold renders header and footer', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: SettingsPageScaffold(
title: '编辑资料',
body: const Text('主体内容'),
footer: const Text('底部操作区'),
onBack: () {},
),
),
);
expect(find.text('编辑资料'), findsOneWidget);
expect(find.text('主体内容'), findsOneWidget);
expect(find.text('底部操作区'), findsOneWidget);
});
testWidgets('SettingsPageScaffold renders body without footer', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: SettingsPageScaffold(
title: '账户',
body: const Text('主体内容'),
onBack: () {},
),
),
);
expect(find.text('账户'), findsOneWidget);
expect(find.text('主体内容'), findsOneWidget);
expect(find.text('底部操作区'), findsNothing);
});
}