Skip to content

Commit

Permalink
chore: add snaphot tests in CI
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
boxdot committed Jan 27, 2025
1 parent 837a722 commit 6c3e6fb
Show file tree
Hide file tree
Showing 32 changed files with 1,159 additions and 131 deletions.
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH <[email protected]>
#
# SPDX-License-Identifier: AGPL-3.0-or-later

**/goldens/** filter=lfs diff=lfs merge=lfs -text
9 changes: 9 additions & 0 deletions .github/workflows/flutter_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
steps:
- name: Clone repository
uses: actions/checkout@v4
with:
lfs: true

- name: Set up Just
uses: extractions/setup-just@v2
Expand Down Expand Up @@ -50,3 +52,10 @@ jobs:

- name: Run Flutter tests
run: just test-flutter

- name: Upload golden failures
if: failure()
uses: actions/upload-artifact@v4
with:
name: golden-failures
path: app/test/**/failures/*
12 changes: 12 additions & 0 deletions .github/workflows/ios_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
lfs: true

- name: Set up Just
uses: extractions/setup-just@v2
Expand Down Expand Up @@ -47,6 +49,16 @@ jobs:
- name: Generate Dart files
run: just generate-dart-files

- name: Test flutter
run: just test-flutter

- name: Upload golden failures
if: failure()
uses: actions/upload-artifact@v4
with:
name: golden-failures
path: app/test/**/failures/*

- name: Build iOS app
env:
APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
Expand Down
45 changes: 36 additions & 9 deletions app/lib/conversation_details/conversation_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:prototype/core/core.dart';
import 'package:prototype/message_list/message_list.dart';
import 'package:prototype/navigation/navigation.dart';
import 'package:prototype/theme/theme.dart';
import 'package:prototype/user/user.dart';
import 'package:prototype/widgets/widgets.dart';

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

return BlocProvider(
// rebuilds the cubit when the conversation changes
key: ValueKey(conversationId),
create: (context) => ConversationDetailsCubit(
userCubit: context.read(),
conversationId: conversationId,
),
return MultiBlocProvider(
providers: [
BlocProvider(
// rebuilds the cubit when the conversation changes
key: ValueKey("conversation-detail-cubit-$conversationId"),
create: (context) => ConversationDetailsCubit(
userCubit: context.read<UserCubit>(),
conversationId: conversationId,
),
),
BlocProvider(
// rebuilds the cubit when the conversation changes
key: ValueKey("message-list-cubit-$conversationId"),
create: (context) => MessageListCubit(
userCubit: context.read<UserCubit>(),
conversationId: conversationId,
),
),
],
child: const ConversationScreenView(),
);
}
Expand All @@ -53,18 +66,32 @@ class _EmptyConversationPane extends StatelessWidget {
}

class ConversationScreenView extends StatelessWidget {
const ConversationScreenView({super.key});
const ConversationScreenView({
super.key,
this.createMessageCubit = MessageCubit.new,
});

final MessageCubitCreate createMessageCubit;

@override
Widget build(BuildContext context) {
final conversationId =
context.select((NavigationCubit cubit) => cubit.state.conversationId);

final conversationTitle = context.select(
(ConversationDetailsCubit cubit) => cubit.state.conversation?.title);

if (conversationId == null) {
return const _EmptyConversationPane();
}

return Scaffold(
body: Stack(children: <Widget>[
Column(
children: [
const MessageListContainer(),
Expanded(
child: MessageListView(createMessageCubit: createMessageCubit),
),
const MessageComposer(),
],
),
Expand Down
42 changes: 5 additions & 37 deletions app/lib/conversation_list/conversation_list_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class _ListTile extends StatelessWidget {
Widget build(BuildContext context) {
final currentConversationId =
context.select((NavigationCubit cubit) => cubit.state.conversationId);
final isSelected = currentConversationId == conversation.id;
return ListTile(
horizontalTitleGap: 0,
contentPadding: const EdgeInsets.symmetric(
Expand All @@ -79,11 +80,7 @@ class _ListTile extends StatelessWidget {
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: _selectionColor(
context,
conversation.id,
currentConversationId,
),
color: isSelected ? convPaneFocusColor : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Expand All @@ -110,31 +107,12 @@ class _ListTile extends StatelessWidget {
],
),
),
selected: _isConversationSelected(
conversation.id,
currentConversationId,
context,
),
selected: isSelected,
focusColor: convListItemSelectedColor,
onTap: () => _onSelectConversation(context, conversation.id),
onTap: () =>
context.read<NavigationCubit>().openConversation(conversation.id),
);
}

void _onSelectConversation(
BuildContext context,
ConversationId conversationId,
) {
context.read<NavigationCubit>().openConversation(conversationId);
}

Color? _selectionColor(
BuildContext context,
ConversationId conversationId,
ConversationId? currentConversationId,
) =>
isLargeScreen(context) && currentConversationId == conversationId
? convPaneFocusColor
: null;
}

class _ListTileTop extends StatelessWidget {
Expand Down Expand Up @@ -320,16 +298,6 @@ class _ConversationTitle extends StatelessWidget {
}
}

bool _isConversationSelected(
ConversationId conversationId,
ConversationId? currentConversationId,
BuildContext context,
) {
return isLargeScreen(context)
? currentConversationId == conversationId
: false;
}

String formatTimestamp(String t, {DateTime? now}) {
DateTime timestamp;
try {
Expand Down
7 changes: 3 additions & 4 deletions app/lib/conversation_list/conversation_list_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ class ConversationListContainer extends StatelessWidget {

@override
Widget build(BuildContext context) {
final userCubit = context.read<UserCubit>();
return BlocProvider(
create: (context) {
return ConversationListCubit(userCubit: userCubit);
},
create: (context) => ConversationListCubit(
userCubit: context.read<UserCubit>(),
),
child: const ConversationListView(),
);
}
Expand Down
35 changes: 27 additions & 8 deletions app/lib/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,40 @@ class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
const mobileLayout = ConversationListContainer();
const desktopLayout = Row(
const desktopLayout = HomeScreenDesktopLayout(
conversationList: ConversationListContainer(),
conversation: ConversationScreen(),
);
return const ResponsiveScreen(
mobile: mobileLayout,
tablet: desktopLayout,
desktop: desktopLayout,
);
}
}

class HomeScreenDesktopLayout extends StatelessWidget {
const HomeScreenDesktopLayout({
required this.conversationList,
required this.conversation,
super.key,
});

final Widget conversationList;
final Widget conversation;

@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 300,
child: ConversationListContainer(),
child: conversationList,
),
Expanded(
child: ConversationScreen(),
child: conversation,
),
],
);
return const ResponsiveScreen(
mobile: mobileLayout,
tablet: desktopLayout,
desktop: desktopLayout,
);
}
}
2 changes: 2 additions & 0 deletions app/lib/message_list/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ library;

export 'message_list_view.dart';
export 'message_composer.dart';
export 'message_list_cubit.dart';
export 'message_cubit.dart';
100 changes: 40 additions & 60 deletions app/lib/message_list/message_list_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,82 +8,62 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:prototype/conversation_details/conversation_details.dart';
import 'package:prototype/core/core.dart';
import 'package:prototype/navigation/navigation.dart';
import 'package:prototype/user/user.dart';
import 'package:visibility_detector/visibility_detector.dart';

import 'conversation_tile.dart';
import 'message_cubit.dart';
import 'message_list_cubit.dart';

class MessageListContainer extends StatelessWidget {
const MessageListContainer({
super.key,
});

@override
Widget build(BuildContext context) {
final conversationId =
context.select((NavigationCubit cubit) => cubit.state.conversationId);

if (conversationId == null) {
throw StateError("an active conversation is obligatory");
}

return BlocProvider<MessageListCubit>(
create: (context) => MessageListCubit(
userCubit: context.read(),
conversationId: conversationId,
),
child: const MessageListView(),
);
}
}
typedef MessageCubitCreate = MessageCubit Function({
required UserCubit userCubit,
required MessageState initialState,
});

class MessageListView extends StatelessWidget {
const MessageListView({
super.key,
this.createMessageCubit = MessageCubit.new,
});

final MessageCubitCreate createMessageCubit;

@override
Widget build(BuildContext context) {
final state = context.select((MessageListCubit cubit) => cubit.state);

return Expanded(
child: SelectionArea(
child: ListView.custom(
physics: _scrollPhysics,
reverse: true,
childrenDelegate: SliverChildBuilderDelegate(
(context, reverseIndex) {
final index = state.loadedMessagesCount - reverseIndex - 1;
final message = state.messageAt(index);
return message != null
? BlocProvider(
key: ValueKey(message.id),
create: (context) {
return MessageCubit(
userCubit: context.read(),
initialState: MessageState(message: message),
);
},
child: _VisibilityConversationTile(
messageId: message.id,
timestamp: DateTime.parse(message.timestamp),
),
)
: const SizedBox.shrink();
},
findChildIndexCallback: (key) {
final messageKey = key as ValueKey<ConversationMessageId>;
final messageId = messageKey.value;
final index = state.messageIdIndex(messageId);
// reverse index
return index != null
? state.loadedMessagesCount - index - 1
: null;
},
childCount: state.loadedMessagesCount,
),
return SelectionArea(
child: ListView.custom(
physics: _scrollPhysics,
reverse: true,
childrenDelegate: SliverChildBuilderDelegate(
(context, reverseIndex) {
final index = state.loadedMessagesCount - reverseIndex - 1;
final message = state.messageAt(index);
return message != null
? BlocProvider(
key: ValueKey(message.id),
create: (context) {
return createMessageCubit(
userCubit: context.read<UserCubit>(),
initialState: MessageState(message: message),
);
},
child: _VisibilityConversationTile(
messageId: message.id,
timestamp: DateTime.parse(message.timestamp),
),
)
: const SizedBox.shrink();
},
findChildIndexCallback: (key) {
final messageKey = key as ValueKey<ConversationMessageId>;
final messageId = messageKey.value;
final index = state.messageIdIndex(messageId);
// reverse index
return index != null ? state.loadedMessagesCount - index - 1 : null;
},
childCount: state.loadedMessagesCount,
),
),
);
Expand Down
Loading

0 comments on commit 6c3e6fb

Please sign in to comment.