Skip to content

Commit 6c3e6fb

Browse files
committed
chore: add snaphot tests in CI
Tests are added for the following widgets: - `HomeScreen` (desktop layout) - `ConverstationList` - `ConversationListContent` - `Conversation` - `MessageList` By default, snapshots are created with loaded fonts, on physical screen size of a Pixel 8 and pixel density of 3. The snapshot tests tolerance is 0.022 for ignoring the differences in rendering on different platforms. The snapshots are stored in git LFS. The tests are run in CI on macOS and Linux runners. The line height of a message text is now set to default, because otherwise it creates inconsistent rendering on different platforms.
1 parent 837a722 commit 6c3e6fb

32 files changed

+1159
-131
lines changed

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH <[email protected]>
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
**/goldens/** filter=lfs diff=lfs merge=lfs -text

.github/workflows/flutter_test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
steps:
2323
- name: Clone repository
2424
uses: actions/checkout@v4
25+
with:
26+
lfs: true
2527

2628
- name: Set up Just
2729
uses: extractions/setup-just@v2
@@ -50,3 +52,10 @@ jobs:
5052

5153
- name: Run Flutter tests
5254
run: just test-flutter
55+
56+
- name: Upload golden failures
57+
if: failure()
58+
uses: actions/upload-artifact@v4
59+
with:
60+
name: golden-failures
61+
path: app/test/**/failures/*

.github/workflows/ios_build.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ jobs:
1919
steps:
2020
- name: Checkout code
2121
uses: actions/checkout@v4
22+
with:
23+
lfs: true
2224

2325
- name: Set up Just
2426
uses: extractions/setup-just@v2
@@ -47,6 +49,16 @@ jobs:
4749
- name: Generate Dart files
4850
run: just generate-dart-files
4951

52+
- name: Test flutter
53+
run: just test-flutter
54+
55+
- name: Upload golden failures
56+
if: failure()
57+
uses: actions/upload-artifact@v4
58+
with:
59+
name: golden-failures
60+
path: app/test/**/failures/*
61+
5062
- name: Build iOS app
5163
env:
5264
APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}

app/lib/conversation_details/conversation_screen.dart

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:prototype/core/core.dart';
88
import 'package:prototype/message_list/message_list.dart';
99
import 'package:prototype/navigation/navigation.dart';
1010
import 'package:prototype/theme/theme.dart';
11+
import 'package:prototype/user/user.dart';
1112
import 'package:prototype/widgets/widgets.dart';
1213

1314
import 'conversation_details_cubit.dart';
@@ -24,13 +25,25 @@ class ConversationScreen extends StatelessWidget {
2425
return const _EmptyConversationPane();
2526
}
2627

27-
return BlocProvider(
28-
// rebuilds the cubit when the conversation changes
29-
key: ValueKey(conversationId),
30-
create: (context) => ConversationDetailsCubit(
31-
userCubit: context.read(),
32-
conversationId: conversationId,
33-
),
28+
return MultiBlocProvider(
29+
providers: [
30+
BlocProvider(
31+
// rebuilds the cubit when the conversation changes
32+
key: ValueKey("conversation-detail-cubit-$conversationId"),
33+
create: (context) => ConversationDetailsCubit(
34+
userCubit: context.read<UserCubit>(),
35+
conversationId: conversationId,
36+
),
37+
),
38+
BlocProvider(
39+
// rebuilds the cubit when the conversation changes
40+
key: ValueKey("message-list-cubit-$conversationId"),
41+
create: (context) => MessageListCubit(
42+
userCubit: context.read<UserCubit>(),
43+
conversationId: conversationId,
44+
),
45+
),
46+
],
3447
child: const ConversationScreenView(),
3548
);
3649
}
@@ -53,18 +66,32 @@ class _EmptyConversationPane extends StatelessWidget {
5366
}
5467

5568
class ConversationScreenView extends StatelessWidget {
56-
const ConversationScreenView({super.key});
69+
const ConversationScreenView({
70+
super.key,
71+
this.createMessageCubit = MessageCubit.new,
72+
});
73+
74+
final MessageCubitCreate createMessageCubit;
5775

5876
@override
5977
Widget build(BuildContext context) {
78+
final conversationId =
79+
context.select((NavigationCubit cubit) => cubit.state.conversationId);
80+
6081
final conversationTitle = context.select(
6182
(ConversationDetailsCubit cubit) => cubit.state.conversation?.title);
6283

84+
if (conversationId == null) {
85+
return const _EmptyConversationPane();
86+
}
87+
6388
return Scaffold(
6489
body: Stack(children: <Widget>[
6590
Column(
6691
children: [
67-
const MessageListContainer(),
92+
Expanded(
93+
child: MessageListView(createMessageCubit: createMessageCubit),
94+
),
6895
const MessageComposer(),
6996
],
7097
),

app/lib/conversation_list/conversation_list_content.dart

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class _ListTile extends StatelessWidget {
6565
Widget build(BuildContext context) {
6666
final currentConversationId =
6767
context.select((NavigationCubit cubit) => cubit.state.conversationId);
68+
final isSelected = currentConversationId == conversation.id;
6869
return ListTile(
6970
horizontalTitleGap: 0,
7071
contentPadding: const EdgeInsets.symmetric(
@@ -79,11 +80,7 @@ class _ListTile extends StatelessWidget {
7980
padding: const EdgeInsets.all(10),
8081
decoration: BoxDecoration(
8182
borderRadius: BorderRadius.circular(10),
82-
color: _selectionColor(
83-
context,
84-
conversation.id,
85-
currentConversationId,
86-
),
83+
color: isSelected ? convPaneFocusColor : null,
8784
),
8885
child: Row(
8986
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -110,31 +107,12 @@ class _ListTile extends StatelessWidget {
110107
],
111108
),
112109
),
113-
selected: _isConversationSelected(
114-
conversation.id,
115-
currentConversationId,
116-
context,
117-
),
110+
selected: isSelected,
118111
focusColor: convListItemSelectedColor,
119-
onTap: () => _onSelectConversation(context, conversation.id),
112+
onTap: () =>
113+
context.read<NavigationCubit>().openConversation(conversation.id),
120114
);
121115
}
122-
123-
void _onSelectConversation(
124-
BuildContext context,
125-
ConversationId conversationId,
126-
) {
127-
context.read<NavigationCubit>().openConversation(conversationId);
128-
}
129-
130-
Color? _selectionColor(
131-
BuildContext context,
132-
ConversationId conversationId,
133-
ConversationId? currentConversationId,
134-
) =>
135-
isLargeScreen(context) && currentConversationId == conversationId
136-
? convPaneFocusColor
137-
: null;
138116
}
139117

140118
class _ListTileTop extends StatelessWidget {
@@ -320,16 +298,6 @@ class _ConversationTitle extends StatelessWidget {
320298
}
321299
}
322300

323-
bool _isConversationSelected(
324-
ConversationId conversationId,
325-
ConversationId? currentConversationId,
326-
BuildContext context,
327-
) {
328-
return isLargeScreen(context)
329-
? currentConversationId == conversationId
330-
: false;
331-
}
332-
333301
String formatTimestamp(String t, {DateTime? now}) {
334302
DateTime timestamp;
335303
try {

app/lib/conversation_list/conversation_list_view.dart

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ class ConversationListContainer extends StatelessWidget {
1717

1818
@override
1919
Widget build(BuildContext context) {
20-
final userCubit = context.read<UserCubit>();
2120
return BlocProvider(
22-
create: (context) {
23-
return ConversationListCubit(userCubit: userCubit);
24-
},
21+
create: (context) => ConversationListCubit(
22+
userCubit: context.read<UserCubit>(),
23+
),
2524
child: const ConversationListView(),
2625
);
2726
}

app/lib/home_screen.dart

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,40 @@ class HomeScreen extends StatelessWidget {
1313
@override
1414
Widget build(BuildContext context) {
1515
const mobileLayout = ConversationListContainer();
16-
const desktopLayout = Row(
16+
const desktopLayout = HomeScreenDesktopLayout(
17+
conversationList: ConversationListContainer(),
18+
conversation: ConversationScreen(),
19+
);
20+
return const ResponsiveScreen(
21+
mobile: mobileLayout,
22+
tablet: desktopLayout,
23+
desktop: desktopLayout,
24+
);
25+
}
26+
}
27+
28+
class HomeScreenDesktopLayout extends StatelessWidget {
29+
const HomeScreenDesktopLayout({
30+
required this.conversationList,
31+
required this.conversation,
32+
super.key,
33+
});
34+
35+
final Widget conversationList;
36+
final Widget conversation;
37+
38+
@override
39+
Widget build(BuildContext context) {
40+
return Row(
1741
children: [
1842
SizedBox(
1943
width: 300,
20-
child: ConversationListContainer(),
44+
child: conversationList,
2145
),
2246
Expanded(
23-
child: ConversationScreen(),
47+
child: conversation,
2448
),
2549
],
2650
);
27-
return const ResponsiveScreen(
28-
mobile: mobileLayout,
29-
tablet: desktopLayout,
30-
desktop: desktopLayout,
31-
);
3251
}
3352
}

app/lib/message_list/message_list.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ library;
77

88
export 'message_list_view.dart';
99
export 'message_composer.dart';
10+
export 'message_list_cubit.dart';
11+
export 'message_cubit.dart';

app/lib/message_list/message_list_view.dart

Lines changed: 40 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,82 +8,62 @@ import 'package:flutter/material.dart';
88
import 'package:flutter_bloc/flutter_bloc.dart';
99
import 'package:prototype/conversation_details/conversation_details.dart';
1010
import 'package:prototype/core/core.dart';
11-
import 'package:prototype/navigation/navigation.dart';
11+
import 'package:prototype/user/user.dart';
1212
import 'package:visibility_detector/visibility_detector.dart';
1313

1414
import 'conversation_tile.dart';
1515
import 'message_cubit.dart';
1616
import 'message_list_cubit.dart';
1717

18-
class MessageListContainer extends StatelessWidget {
19-
const MessageListContainer({
20-
super.key,
21-
});
22-
23-
@override
24-
Widget build(BuildContext context) {
25-
final conversationId =
26-
context.select((NavigationCubit cubit) => cubit.state.conversationId);
27-
28-
if (conversationId == null) {
29-
throw StateError("an active conversation is obligatory");
30-
}
31-
32-
return BlocProvider<MessageListCubit>(
33-
create: (context) => MessageListCubit(
34-
userCubit: context.read(),
35-
conversationId: conversationId,
36-
),
37-
child: const MessageListView(),
38-
);
39-
}
40-
}
18+
typedef MessageCubitCreate = MessageCubit Function({
19+
required UserCubit userCubit,
20+
required MessageState initialState,
21+
});
4122

4223
class MessageListView extends StatelessWidget {
4324
const MessageListView({
4425
super.key,
26+
this.createMessageCubit = MessageCubit.new,
4527
});
4628

29+
final MessageCubitCreate createMessageCubit;
30+
4731
@override
4832
Widget build(BuildContext context) {
4933
final state = context.select((MessageListCubit cubit) => cubit.state);
5034

51-
return Expanded(
52-
child: SelectionArea(
53-
child: ListView.custom(
54-
physics: _scrollPhysics,
55-
reverse: true,
56-
childrenDelegate: SliverChildBuilderDelegate(
57-
(context, reverseIndex) {
58-
final index = state.loadedMessagesCount - reverseIndex - 1;
59-
final message = state.messageAt(index);
60-
return message != null
61-
? BlocProvider(
62-
key: ValueKey(message.id),
63-
create: (context) {
64-
return MessageCubit(
65-
userCubit: context.read(),
66-
initialState: MessageState(message: message),
67-
);
68-
},
69-
child: _VisibilityConversationTile(
70-
messageId: message.id,
71-
timestamp: DateTime.parse(message.timestamp),
72-
),
73-
)
74-
: const SizedBox.shrink();
75-
},
76-
findChildIndexCallback: (key) {
77-
final messageKey = key as ValueKey<ConversationMessageId>;
78-
final messageId = messageKey.value;
79-
final index = state.messageIdIndex(messageId);
80-
// reverse index
81-
return index != null
82-
? state.loadedMessagesCount - index - 1
83-
: null;
84-
},
85-
childCount: state.loadedMessagesCount,
86-
),
35+
return SelectionArea(
36+
child: ListView.custom(
37+
physics: _scrollPhysics,
38+
reverse: true,
39+
childrenDelegate: SliverChildBuilderDelegate(
40+
(context, reverseIndex) {
41+
final index = state.loadedMessagesCount - reverseIndex - 1;
42+
final message = state.messageAt(index);
43+
return message != null
44+
? BlocProvider(
45+
key: ValueKey(message.id),
46+
create: (context) {
47+
return createMessageCubit(
48+
userCubit: context.read<UserCubit>(),
49+
initialState: MessageState(message: message),
50+
);
51+
},
52+
child: _VisibilityConversationTile(
53+
messageId: message.id,
54+
timestamp: DateTime.parse(message.timestamp),
55+
),
56+
)
57+
: const SizedBox.shrink();
58+
},
59+
findChildIndexCallback: (key) {
60+
final messageKey = key as ValueKey<ConversationMessageId>;
61+
final messageId = messageKey.value;
62+
final index = state.messageIdIndex(messageId);
63+
// reverse index
64+
return index != null ? state.loadedMessagesCount - index - 1 : null;
65+
},
66+
childCount: state.loadedMessagesCount,
8767
),
8868
),
8969
);

0 commit comments

Comments
 (0)