feat: 重构 Home Screen 视觉设计与消息输入组件

- 新增 Home Screen 视觉设计 token (背景、工具栏、对话区、输入框等)
- 重构首页布局为浮动式底部输入栈结构
- 新增 HomeBackgroundField、HomeFloatingHeader、HomeAttachmentStrip 组件
- 优化 MessageComposer 视觉样式为悬浮 shell 设计
- 添加相关测试用例
This commit is contained in:
qzl
2026-03-13 17:25:29 +08:00
parent 4c10929498
commit 3273d63b23
10 changed files with 1212 additions and 259 deletions
@@ -0,0 +1,17 @@
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);
});
}
@@ -2,6 +2,7 @@ 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({
@@ -50,8 +51,11 @@ void main() {
);
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);
@@ -63,6 +67,29 @@ void main() {
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 {
@@ -85,7 +112,9 @@ void main() {
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!;
}
@@ -0,0 +1,112 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.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/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';
class _TestApiClient implements IApiClient {
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) async {
return Response<T>(requestOptions: RequestOptions(path: path));
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
return Response<T>(
requestOptions: RequestOptions(path: path),
data: <dynamic>[] as T,
);
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
}) async {
return const Stream<String>.empty();
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) async {
return Response<T>(requestOptions: RequestOptions(path: path));
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
return Response<T>(requestOptions: RequestOptions(path: path));
}
}
void main() {
late ChatBloc chatBloc;
setUp(() {
final apiClient = _TestApiClient();
if (sl.isRegistered<InboxApi>()) {
sl.unregister<InboxApi>();
}
sl.registerSingleton<InboxApi>(InboxApi(apiClient));
chatBloc = ChatBloc(apiClient: apiClient);
});
tearDown(() async {
await chatBloc.close();
if (sl.isRegistered<InboxApi>()) {
await sl.unregister<InboxApi>();
}
});
Future<void> pumpHomeScreen(
WidgetTester tester, {
List<XFile> initialSelectedImages = const [],
}) async {
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
chatBloc: chatBloc,
autoLoadHistory: false,
initialSelectedImages: initialSelectedImages,
),
),
);
await tester.pump();
}
testWidgets(
'home screen shows floating header, conversation stage, and bottom input stack',
(tester) async {
await pumpHomeScreen(tester);
expect(find.byKey(homeFloatingHeaderKey), findsOneWidget);
expect(find.byKey(homeConversationStageKey), findsOneWidget);
expect(find.byKey(homeBottomInputStackKey), findsOneWidget);
},
);
testWidgets('empty state keeps clean stage without helper copy', (
tester,
) async {
await pumpHomeScreen(tester);
expect(find.byKey(homeConversationStageKey), findsOneWidget);
expect(find.byKey(homeEmptyStateAmbientKey), findsOneWidget);
expect(find.text('开始对话吧'), findsNothing);
});
testWidgets('selected images render in attachment strip above composer', (
tester,
) async {
await pumpHomeScreen(
tester,
initialSelectedImages: [XFile('/tmp/mock-image-a.png')],
);
expect(find.byKey(homeAttachmentStripKey), findsOneWidget);
});
}