diff --git a/melos.yaml b/melos.yaml index 4ccbdd77b..b076e2c8b 100644 --- a/melos.yaml +++ b/melos.yaml @@ -33,6 +33,7 @@ command: cupertino_icons: ^1.0.3 desktop_drop: '>=0.5.0 <0.8.0' device_info_plus: '>=11.0.0 <13.0.0' + device_preview: ^1.2.0 diacritic: ^0.1.5 dio: ^5.4.3+1 drift: ^2.28.0 @@ -106,7 +107,7 @@ command: # List of all the dev_dependencies used in the project. dev_dependencies: - alchemist: ^0.13.0 + alchemist: ^0.14.0 build_runner: ^2.4.9 connectivity_plus_platform_interface: ^2.0.0 drift_dev: ^2.28.0 diff --git a/packages/docs_screenshots/.gitignore b/packages/docs_screenshots/.gitignore new file mode 100644 index 000000000..d9f8aaadf --- /dev/null +++ b/packages/docs_screenshots/.gitignore @@ -0,0 +1,3 @@ +# docs_screenshots uses platform (macOS) goldens only; CI variants are disabled in flutter_test_config. +**/goldens/ci/ +!**/goldens/macos/* \ No newline at end of file diff --git a/packages/docs_screenshots/dart_test.yaml b/packages/docs_screenshots/dart_test.yaml new file mode 100644 index 000000000..c329c9c85 --- /dev/null +++ b/packages/docs_screenshots/dart_test.yaml @@ -0,0 +1,5 @@ +# The existence of this file prevents warnings about unrecognized tags when running Alchemist tests. + +tags: + golden: + timeout: 15s \ No newline at end of file diff --git a/packages/docs_screenshots/pubspec.yaml b/packages/docs_screenshots/pubspec.yaml new file mode 100644 index 000000000..a9c57d5d4 --- /dev/null +++ b/packages/docs_screenshots/pubspec.yaml @@ -0,0 +1,32 @@ +name: docs_screenshots +description: Golden test screenshots for Stream Chat Flutter documentation. +publish_to: none + +# Note: The environment configuration and dependency versions are managed by Melos. +# +# Do not edit them manually. +# +# Steps to update dependencies: +# 1. Modify the version in the melos.yaml file. +# 2. Run `melos bootstrap` to apply changes. + +environment: + sdk: ^3.10.0 + flutter: ">=3.38.1" + +dependencies: + flutter: + sdk: flutter + stream_chat_flutter: ^10.0.0-beta.12 + +dev_dependencies: + alchemist: ^0.14.0 + device_preview: ^1.2.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.0 + plugin_platform_interface: ^2.0.0 + record: ">=5.2.0 <7.0.0" + +flutter: + uses-material-design: true diff --git a/packages/docs_screenshots/test/channel/channel_header_test.dart b/packages/docs_screenshots/test/channel/channel_header_test.dart new file mode 100644 index 000000000..a7d5afe73 --- /dev/null +++ b/packages/docs_screenshots/test/channel/channel_header_test.dart @@ -0,0 +1,85 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Widget _buildChannelHeaderScaffold({ + required MockClient client, + required MockChannel channel, + StreamChannelHeader? header, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + appBar: header ?? const StreamChannelHeader(showBackButton: false), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'channel header default', + fileName: 'channel_header', + constraints: const BoxConstraints.tightFor(width: 375, height: 56), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + return _buildChannelHeaderScaffold(client: client, channel: channel); + }, + ); + + goldenTest( + 'channel header with custom title', + fileName: 'channel_header_custom_title', + constraints: const BoxConstraints.tightFor(width: 375, height: 56), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + return _buildChannelHeaderScaffold( + client: client, + channel: channel, + header: const StreamChannelHeader( + showBackButton: false, + title: Text('My Custom Title'), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/channel/channel_list_header_test.dart b/packages/docs_screenshots/test/channel/channel_list_header_test.dart new file mode 100644 index 000000000..74c20d2df --- /dev/null +++ b/packages/docs_screenshots/test/channel/channel_list_header_test.dart @@ -0,0 +1,63 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Widget _buildListHeaderScaffold({ + required MockClient client, + StreamChannelListHeader? header, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: header ?? const StreamChannelListHeader(), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'channel list header default', + fileName: 'channel_list_header', + constraints: const BoxConstraints.tightFor(width: 375, height: 56), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id', name: 'Alice')); + + return _buildListHeaderScaffold(client: client); + }, + ); + + goldenTest( + 'channel list header with custom subtitle', + fileName: 'channel_list_header_custom_subtitle', + constraints: const BoxConstraints.tightFor(width: 375, height: 56), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id', name: 'Alice')); + + return _buildListHeaderScaffold( + client: client, + header: const StreamChannelListHeader( + subtitle: Text('12 channels'), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/channel/channel_preview_test.dart b/packages/docs_screenshots/test/channel/channel_preview_test.dart new file mode 100644 index 000000000..bcb78e174 --- /dev/null +++ b/packages/docs_screenshots/test/channel/channel_preview_test.dart @@ -0,0 +1,200 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:device_preview/device_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'channel preview tile', + fileName: 'channel_preview', + constraints: const BoxConstraints.tightFor(width: 375, height: 80), + builder: () { + final client = MockClient(); + final channel = fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey everyone!', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamChannelListItem(channel: channel), + ), + ), + ); + }, + ); + + goldenTest( + 'channel list view', + fileName: 'channel_list_view', + constraints: const BoxConstraints.tightFor(width: 430, height: 932), + builder: () { + final client = MockClient(); + + final channels = [ + fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey, how is everyone doing?', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + unreadCount: 2, + ), + fakeChannel( + client: client, + id: 'design', + name: 'Design', + messages: [ + Message( + id: 'msg-2', + text: 'New mockups are ready!', + user: User(id: 'user-3', name: 'Carol'), + createdAt: DateTime(2024, 6, 1, 9, 15), + ), + ], + ), + fakeChannel( + client: client, + id: 'random', + name: 'Random', + messages: [ + Message( + id: 'msg-3', + text: 'Anyone up for lunch?', + user: User(id: 'user-4', name: 'Dave'), + createdAt: DateTime(2024, 5, 31, 12, 0), + ), + ], + ), + fakeChannel( + client: client, + id: 'engineering', + name: 'Engineering', + messages: [ + Message( + id: 'msg-4', + text: 'PR #42 is ready for review', + user: User(id: 'user-5', name: 'Eve'), + createdAt: DateTime(2024, 5, 30, 15, 45), + ), + ], + ), + ]; + + final controller = StreamChannelListController.fromValue( + PagedValue(items: channels), + client: client, + ); + + stubQueryChannelsForGoldens(client, channels); + + return DeviceFrame( + device: Devices.ios.iPhone13, + isFrameVisible: true, + screen: MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + title: const Text('Stream Chat'), + actions: [ + IconButton(icon: const Icon(Icons.edit_outlined), onPressed: null), + ], + ), + body: StreamChannelListView( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'swipe channel to reveal actions', + fileName: 'swipe_channel', + constraints: const BoxConstraints.tightFor(width: 375, height: 80), + builder: () { + final client = MockClient(); + final channel = fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey, how is everyone doing?', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: Stack( + children: [ + Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete, color: Colors.white), + Text('Delete', style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ), + Transform.translate( + offset: const Offset(-80, 0), + child: StreamChannelListItem(channel: channel), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/channel/goldens/macos/channel_header.png b/packages/docs_screenshots/test/channel/goldens/macos/channel_header.png new file mode 100644 index 000000000..9aabb716e Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/channel_header.png differ diff --git a/packages/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png b/packages/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png new file mode 100644 index 000000000..9fe3bd3e8 Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png differ diff --git a/packages/docs_screenshots/test/channel/goldens/macos/channel_list_header.png b/packages/docs_screenshots/test/channel/goldens/macos/channel_list_header.png new file mode 100644 index 000000000..9c7ad1b6a Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/channel_list_header.png differ diff --git a/packages/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png b/packages/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png new file mode 100644 index 000000000..41afbd775 Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png differ diff --git a/packages/docs_screenshots/test/channel/goldens/macos/channel_list_view.png b/packages/docs_screenshots/test/channel/goldens/macos/channel_list_view.png new file mode 100644 index 000000000..85bd1cccd Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/channel_list_view.png differ diff --git a/packages/docs_screenshots/test/channel/goldens/macos/channel_preview.png b/packages/docs_screenshots/test/channel/goldens/macos/channel_preview.png new file mode 100644 index 000000000..820e94d51 Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/channel_preview.png differ diff --git a/packages/docs_screenshots/test/channel/goldens/macos/swipe_channel.png b/packages/docs_screenshots/test/channel/goldens/macos/swipe_channel.png new file mode 100644 index 000000000..d53c97da6 Binary files /dev/null and b/packages/docs_screenshots/test/channel/goldens/macos/swipe_channel.png differ diff --git a/packages/docs_screenshots/test/draft_list/draft_list_view_test.dart b/packages/docs_screenshots/test/draft_list/draft_list_view_test.dart new file mode 100644 index 000000000..7b928c870 --- /dev/null +++ b/packages/docs_screenshots/test/draft_list/draft_list_view_test.dart @@ -0,0 +1,181 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Draft _makeDraft({ + required String channelId, + required String channelName, + required String text, + String? parentId, + Message? parentMessage, +}) { + return Draft( + channelCid: 'messaging:$channelId', + createdAt: DateTime(2024, 6, 1, 10, 0), + message: DraftMessage(text: text, parentId: parentId), + channel: ChannelModel( + id: channelId, + type: 'messaging', + extraData: {'name': channelName}, + ), + parentId: parentId, + parentMessage: parentMessage, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'draft list view', + fileName: 'draft_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 550), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + final drafts = [ + _makeDraft( + channelId: 'general', + channelName: 'General', + text: 'Has anyone seen the latest release notes?', + ), + _makeDraft( + channelId: 'design', + channelName: 'Design', + text: 'I have some feedback on the new color scheme…', + ), + _makeDraft( + channelId: 'random', + channelName: 'Random', + text: 'Anyone up for lunch tomorrow?', + ), + ]; + + final controller = StreamDraftListController.fromValue( + PagedValue(items: drafts), + client: client, + ); + + stubQueryDraftsForGoldens(client, drafts); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + title: const Text('Stream Chat'), + actions: [ + IconButton(icon: const Icon(Icons.edit_outlined), onPressed: null), + ], + ), + body: StreamDraftListView(controller: controller), + bottomNavigationBar: BottomNavigationBar( + currentIndex: 3, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.chat_bubble_outline), + label: 'Chats', + ), + BottomNavigationBarItem( + icon: Icon(Icons.alternate_email), + label: 'Mentions', + ), + BottomNavigationBarItem( + icon: Icon(Icons.comment_outlined), + label: 'Threads', + ), + BottomNavigationBarItem( + icon: Icon(Icons.edit_note), + label: 'Drafts', + ), + ], + ), + ), + ), + ); + }, + ); + + goldenTest( + 'channel draft message tile', + fileName: 'channel_draft_message', + constraints: const BoxConstraints.tightFor(width: 375, height: 80), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + final draft = _makeDraft( + channelId: 'general', + channelName: 'General', + text: 'I was thinking about the new feature…', + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamDraftListTile( + draft: draft, + currentUser: User(id: 'user-1', name: 'Alice'), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'thread draft message tile', + fileName: 'thread_draft_message', + constraints: const BoxConstraints.tightFor(width: 375, height: 80), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Has anyone seen the latest release?', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 9, 0), + ); + + final draft = _makeDraft( + channelId: 'general', + channelName: 'General', + text: 'Yes, the new streaming API looks great!', + parentId: 'parent-msg', + parentMessage: parentMessage, + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamDraftListTile( + draft: draft, + currentUser: User(id: 'user-1', name: 'Alice'), + ), + ), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png b/packages/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png new file mode 100644 index 000000000..59231069a Binary files /dev/null and b/packages/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png differ diff --git a/packages/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png b/packages/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png new file mode 100644 index 000000000..f30ddb2b5 Binary files /dev/null and b/packages/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png differ diff --git a/packages/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png b/packages/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png new file mode 100644 index 000000000..c3932558f Binary files /dev/null and b/packages/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png differ diff --git a/packages/docs_screenshots/test/flutter_test_config.dart b/packages/docs_screenshots/test/flutter_test_config.dart new file mode 100644 index 000000000..0699054b4 --- /dev/null +++ b/packages/docs_screenshots/test/flutter_test_config.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Flutter tests default to the Ahem font unless real fonts are loaded. This loads + // Material/Cupertino fonts and every family listed in the merged FontManifest + // (including transitive packages such as stream_core_flutter's Stream Icons). + await loadFonts(); + + // Load the platform emoji font so emoji glyphs render in golden screenshots + // instead of appearing as boxes. System fonts are not in the asset manifest + // and therefore not picked up by loadFonts(); they must be loaded explicitly. + await _loadEmojiFont(); + + return AlchemistConfig.runWithConfig( + config: AlchemistConfig( + goldenTestTheme: GoldenTestTheme( + backgroundColor: Colors.transparent, + borderColor: Colors.transparent, + nameTextStyle: const TextStyle(fontSize: 18), + ), + ciGoldensConfig: const CiGoldensConfig(enabled: false), + platformGoldensConfig: const PlatformGoldensConfig(enabled: true), + ), + run: testMain, + ); +} + +/// Loads the platform's color emoji font into the Flutter test renderer. +/// +/// [DefaultStreamEmoji] sets `fontFamilyFallback` to platform emoji font names +/// (e.g. 'Apple Color Emoji'), but the Flutter test renderer only knows about +/// fonts loaded via [FontLoader] — system fonts are invisible to it. Without +/// this, every emoji glyph renders as a tofu box. +Future _loadEmojiFont() async { + // Each entry: (FontLoader family name, candidate file paths). + final candidates = [ + ( + 'Apple Color Emoji', + ['/System/Library/Fonts/Apple Color Emoji.ttc'], + ), + ( + 'Noto Color Emoji', + [ + '/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf', + '/usr/share/fonts/noto/NotoColorEmoji.ttf', + ], + ), + ]; + + for (final (family, paths) in candidates) { + for (final path in paths) { + final file = File(path); + if (!file.existsSync()) continue; + final loader = FontLoader(family); + loader.addFont(file.readAsBytes().then(ByteData.sublistView)); + await loader.load(); + return; // Stop after the first font successfully loaded. + } + } +} diff --git a/packages/docs_screenshots/test/message_input/goldens/macos/message_input.png b/packages/docs_screenshots/test/message_input/goldens/macos/message_input.png new file mode 100644 index 000000000..6a1f44d1f Binary files /dev/null and b/packages/docs_screenshots/test/message_input/goldens/macos/message_input.png differ diff --git a/packages/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png b/packages/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png new file mode 100644 index 000000000..86ee29a64 Binary files /dev/null and b/packages/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png differ diff --git a/packages/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png b/packages/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png new file mode 100644 index 000000000..045b4b1d0 Binary files /dev/null and b/packages/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png differ diff --git a/packages/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png b/packages/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png new file mode 100644 index 000000000..6a1f44d1f Binary files /dev/null and b/packages/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png differ diff --git a/packages/docs_screenshots/test/message_input/stream_message_input_test.dart b/packages/docs_screenshots/test/message_input/stream_message_input_test.dart new file mode 100644 index 000000000..e28821cda --- /dev/null +++ b/packages/docs_screenshots/test/message_input/stream_message_input_test.dart @@ -0,0 +1,168 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/fakes.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Widget _buildMessageInputScaffold({ + required MockClient client, + required MockChannel channel, + StreamMessageInput? messageInput, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded(child: Container()), + messageInput ?? const StreamMessageInput(), + ], + ), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + goldenTest( + 'default state', + fileName: 'stream_message_input_default', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + return _buildMessageInputScaffold(client: client, channel: channel); + }, + ); + + goldenTest( + 'message input default', + fileName: 'message_input', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + return _buildMessageInputScaffold(client: client, channel: channel); + }, + ); + + goldenTest( + 'message input actions on right', + fileName: 'message_input_change_position', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + final controller = StreamMessageInputController(); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageComposerInputTrailing: (context, props) => const SizedBox.shrink(), + messageComposerTrailing: (context, props) => DefaultStreamMessageComposerInputTrailing(props: props), + ), + ), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + const Expanded(child: SizedBox()), + StreamMessageInput(messageInputController: controller), + ], + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'message input with quoted message', + fileName: 'message_input_quoted_message', + constraints: const BoxConstraints.tightFor(width: 375, height: 160), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + final controller = StreamMessageInputController() + ..quotedMessage = Message( + id: 'quoted-msg', + text: 'This is the original message', + user: User(id: 'other-user', name: 'Alice'), + ); + + return _buildMessageInputScaffold( + client: client, + channel: channel, + messageInput: StreamMessageInput(messageInputController: controller), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view.png new file mode 100644 index 000000000..806369d4f Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png new file mode 100644 index 000000000..e311103aa Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png new file mode 100644 index 000000000..6b2579c8f Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png new file mode 100644 index 000000000..aefefa577 Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png new file mode 100644 index 000000000..eebaab66a Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_styles.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_styles.png new file mode 100644 index 000000000..1aef374d8 Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_styles.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_theming.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_theming.png new file mode 100644 index 000000000..274ef5781 Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_theming.png differ diff --git a/packages/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png b/packages/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png new file mode 100644 index 000000000..181511759 Binary files /dev/null and b/packages/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png differ diff --git a/packages/docs_screenshots/test/message_list/message_list_view_test.dart b/packages/docs_screenshots/test/message_list/message_list_view_test.dart new file mode 100644 index 000000000..cbab57cf1 --- /dev/null +++ b/packages/docs_screenshots/test/message_list/message_list_view_test.dart @@ -0,0 +1,187 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:device_preview/device_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _currentUser = User(id: 'user-1', name: 'Alice'); +final _otherUser = User(id: 'user-2', name: 'Bob'); + +List _buildMessages({bool withPinned = false, bool withThreads = false}) { + return [ + Message( + id: 'msg-1', + text: 'Hey there! How are you?', + user: _otherUser, + createdAt: DateTime(2024, 6, 1, 10, 0), + ), + Message( + id: 'msg-2', + text: 'Doing great, thanks!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 1), + ), + if (withPinned) + Message( + id: 'msg-pinned', + text: 'This is an important announcement', + user: _otherUser, + createdAt: DateTime(2024, 6, 1, 10, 2), + pinned: true, + pinnedAt: DateTime(2024, 6, 1, 10, 3), + pinnedBy: _currentUser, + ), + Message( + id: 'msg-3', + text: 'What are you up to today?', + user: _otherUser, + createdAt: DateTime(2024, 6, 1, 10, 3), + replyCount: withThreads ? 3 : 0, + ), + Message( + id: 'msg-4', + text: 'Working on some Flutter features!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 4), + ), + ]; +} + +Widget _buildMessageListViewInDevice({ + required MockClient client, + required MockChannel channel, +}) { + return DeviceFrame( + device: Devices.ios.iPhone13, + isFrameVisible: true, + screen: MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: const Scaffold( + body: StreamMessageListView(), + ), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'message list view default', + fileName: 'message_list_view', + constraints: const BoxConstraints.tightFor(width: 430, height: 932), + builder: () { + final messages = _buildMessages(); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + messages: messages, + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); + + return _buildMessageListViewInDevice(client: client, channel: channel); + }, + ); + + goldenTest( + 'message list view with pinned message', + fileName: 'message_list_view_pin', + constraints: const BoxConstraints.tightFor(width: 375, height: 600), + builder: () { + final messages = _buildMessages(withPinned: true); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + messages: messages, + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: const Scaffold( + body: StreamMessageListView(), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'message list view with threads', + fileName: 'message_list_view_threads', + constraints: const BoxConstraints.tightFor(width: 375, height: 600), + builder: () { + final messages = _buildMessages(withThreads: true); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + messages: messages, + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: const Scaffold( + body: StreamMessageListView(), + ), + ), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/message_list/message_widget_test.dart b/packages/docs_screenshots/test/message_list/message_widget_test.dart new file mode 100644 index 000000000..afbf136ad --- /dev/null +++ b/packages/docs_screenshots/test/message_list/message_widget_test.dart @@ -0,0 +1,365 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _sender = User(id: 'user-2', name: 'Bob'); +final _currentUser = User(id: 'user-1', name: 'Alice'); + +/// Custom reaction resolver that maps 'celebrate' to 🎉, demonstrating the +/// [ReactionIconResolver] API. +class _CelebrationReactionResolver extends DefaultReactionIconResolver { + const _CelebrationReactionResolver(); + + @override + String? emojiCode(String type) { + if (type == 'celebrate') return '🎉'; + return super.emojiCode(type); + } +} + +Widget _buildMessageScaffold({ + required MockClient client, + required MockChannel channel, + required Widget child, + StreamChatConfigurationData? configData, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + streamChatConfigData: configData, + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold(body: child), + ), + ), + ); +} + +void _setupBasicChannel(MockClient client, MockClientState clientState, MockChannel channel, MockChannelState channelState) { + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'message widget actions', + fileName: 'message_widget_actions', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + + final message = Message( + id: 'msg-1', + text: 'Hello! This message has actions.', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + reactionGroups: { + 'love': ReactionGroup( + count: 3, + sumScores: 3, + firstReactionAt: DateTime(2024, 6, 1, 10, 1), + lastReactionAt: DateTime(2024, 6, 1, 10, 2), + ), + }, + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + child: StreamMessageActionsModal( + message: message, + showReactionPicker: true, + messageWidget: StreamMessageWidget(message: message), + messageActions: [ + StreamContextMenuAction( + value: const _ReplyAction(), + leading: const Icon(Icons.reply), + label: const Text('Reply'), + ), + StreamContextMenuAction( + value: const _ThreadReplyAction(), + leading: const Icon(Icons.comment_outlined), + label: const Text('Thread Reply'), + ), + StreamContextMenuAction( + value: const _EditAction(), + leading: const Icon(Icons.edit_outlined), + label: const Text('Edit Message'), + ), + StreamContextMenuAction( + value: const _CopyAction(), + leading: const Icon(Icons.copy_outlined), + label: const Text('Copy Message'), + ), + StreamContextMenuAction( + value: const _PinAction(), + leading: const Icon(Icons.push_pin_outlined), + label: const Text('Pin to Conversation'), + ), + StreamContextMenuAction.destructive( + value: const _DeleteAction(), + leading: const Icon(Icons.delete_outlined), + label: const Text('Delete Message'), + ), + ], + ), + ); + }, + ); + + goldenTest( + 'message theming', + fileName: 'message_theming', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + final message = Message( + id: 'msg-2', + text: 'This message uses a custom theme!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Center( + child: core.StreamMessageLayout( + data: const core.StreamMessageLayoutData( + alignment: core.StreamMessageAlignment.end, + ), + child: core.StreamMessageItemTheme( + data: core.StreamMessageItemThemeData( + bubble: core.StreamMessageBubbleStyle.from( + backgroundColor: Colors.amber.shade300, + ), + text: core.StreamMessageTextStyle.from( + textColor: Colors.brown, + textStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Roboto', + ), + ), + ), + child: StreamMessageWidget(message: message), + ), + ), + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'message reaction theming', + fileName: 'message_reaction_theming', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + + final message = Message( + id: 'msg-3', + text: 'Check out these reactions!', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + reactionGroups: { + 'celebrate': ReactionGroup( + count: 3, + sumScores: 3, + firstReactionAt: DateTime(2024, 6, 1), + lastReactionAt: DateTime(2024, 6, 1), + ), + 'love': ReactionGroup( + count: 2, + sumScores: 2, + firstReactionAt: DateTime(2024, 6, 1), + lastReactionAt: DateTime(2024, 6, 1), + ), + }, + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + configData: StreamChatConfigurationData( + reactionIconResolver: const _CelebrationReactionResolver(), + ), + child: Center(child: StreamMessageWidget(message: message)), + ); + }, + ); + + goldenTest( + 'message with rounded avatar', + fileName: 'message_rounded_avatar', + constraints: const BoxConstraints.tightFor(width: 375, height: 120), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + + final message = Message( + id: 'msg-4', + text: 'Message with user avatar shown.', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + child: Center(child: StreamMessageWidget(message: message)), + ); + }, + ); + + goldenTest( + 'message styles', + fileName: 'message_styles', + constraints: const BoxConstraints.tightFor(width: 375, height: 300), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + core.StreamMessageItemTheme( + data: core.StreamMessageItemThemeData( + text: core.StreamMessageTextStyle.from( + textColor: Colors.deepPurple, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + fontFamily: 'Roboto', + ), + ), + ), + child: StreamMessageWidget( + message: Message( + id: 'msg-from-other', + text: 'This is a message from Bob.', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + ), + ), + ), + core.StreamMessageLayout( + data: const core.StreamMessageLayoutData( + alignment: core.StreamMessageAlignment.end, + ), + child: core.StreamMessageItemTheme( + data: core.StreamMessageItemThemeData( + text: core.StreamMessageTextStyle.from( + textColor: Colors.indigo, + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + fontFamily: 'Roboto', + ), + ), + ), + child: StreamMessageWidget( + message: Message( + id: 'msg-from-me', + text: 'And this is my reply!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 1), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); +} + +// Placeholder action types used to populate the context menu in golden tests. +class _ReplyAction { + const _ReplyAction(); +} + +class _ThreadReplyAction { + const _ThreadReplyAction(); +} + +class _EditAction { + const _EditAction(); +} + +class _CopyAction { + const _CopyAction(); +} + +class _PinAction { + const _PinAction(); +} + +class _DeleteAction { + const _DeleteAction(); +} diff --git a/packages/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png b/packages/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png new file mode 100644 index 000000000..931678b5e Binary files /dev/null and b/packages/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png differ diff --git a/packages/docs_screenshots/test/message_search/message_search_list_view_test.dart b/packages/docs_screenshots/test/message_search/message_search_list_view_test.dart new file mode 100644 index 000000000..4a5cfba0e --- /dev/null +++ b/packages/docs_screenshots/test/message_search/message_search_list_view_test.dart @@ -0,0 +1,95 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +GetMessageResponse _makeSearchResult({ + required String messageId, + required String text, + required String userName, + required String channelName, +}) { + final response = GetMessageResponse(); + response.message = Message( + id: messageId, + text: text, + user: User(id: 'user-$messageId', name: userName), + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + response.channel = ChannelModel( + id: 'ch-$messageId', + type: 'messaging', + extraData: {'name': channelName}, + ); + return response; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'message search list view', + fileName: 'message_search_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final results = [ + _makeSearchResult( + messageId: '1', + text: 'Flutter is an amazing UI toolkit!', + userName: 'Alice', + channelName: 'General', + ), + _makeSearchResult( + messageId: '2', + text: 'Flutter 3.0 has great performance improvements.', + userName: 'Bob', + channelName: 'Engineering', + ), + _makeSearchResult( + messageId: '3', + text: 'I love how Flutter handles animations.', + userName: 'Carol', + channelName: 'Design', + ), + _makeSearchResult( + messageId: '4', + text: 'Flutter Web support has come a long way.', + userName: 'Dave', + channelName: 'Random', + ), + ]; + + final controller = StreamMessageSearchListController.fromValue( + PagedValue(items: results), + client: client, + filter: Filter.equal('type', 'messaging'), + searchQuery: 'flutter', + ); + + stubSearchMessagesForGoldens(client, results); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamMessageSearchListView( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/polls/goldens/macos/poll_creator.png b/packages/docs_screenshots/test/polls/goldens/macos/poll_creator.png new file mode 100644 index 000000000..1b887b787 Binary files /dev/null and b/packages/docs_screenshots/test/polls/goldens/macos/poll_creator.png differ diff --git a/packages/docs_screenshots/test/polls/goldens/macos/poll_interactor.png b/packages/docs_screenshots/test/polls/goldens/macos/poll_interactor.png new file mode 100644 index 000000000..d12e9a7d3 Binary files /dev/null and b/packages/docs_screenshots/test/polls/goldens/macos/poll_interactor.png differ diff --git a/packages/docs_screenshots/test/polls/goldens/macos/polls_composer.png b/packages/docs_screenshots/test/polls/goldens/macos/polls_composer.png new file mode 100644 index 000000000..1e9fbb1e5 Binary files /dev/null and b/packages/docs_screenshots/test/polls/goldens/macos/polls_composer.png differ diff --git a/packages/docs_screenshots/test/polls/poll_test.dart b/packages/docs_screenshots/test/polls/poll_test.dart new file mode 100644 index 000000000..270fc38d6 --- /dev/null +++ b/packages/docs_screenshots/test/polls/poll_test.dart @@ -0,0 +1,189 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _sender = User(id: 'user-2', name: 'Bob'); + +Widget _buildMessageScaffold({ + required MockClient client, + required MockChannel channel, + required Widget child, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold(body: child), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'poll creator widget', + fileName: 'poll_creator', + constraints: const BoxConstraints.tightFor(width: 375, height: 650), + builder: () { + final client = MockClient(); + + final controller = StreamPollController( + poll: Poll( + id: 'poll-1', + name: 'What is your favorite programming language?', + options: [ + const PollOption(id: 'opt-1', text: 'Dart'), + const PollOption(id: 'opt-2', text: 'Swift'), + const PollOption(id: 'opt-3', text: 'Kotlin'), + ], + ), + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: null, + ), + title: const Text('Create Poll'), + actions: [ + IconButton( + icon: const Icon(Icons.send), + onPressed: null, + ), + ], + ), + body: StreamPollCreatorWidget( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ); + }, + ); + + goldenTest( + 'poll interactor widget', + fileName: 'poll_interactor', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + final poll = Poll( + id: 'poll-2', + name: 'Which feature would you like to see next?', + options: [ + const PollOption(id: 'opt-a', text: 'Offline mode'), + const PollOption(id: 'opt-b', text: 'Message scheduling'), + const PollOption(id: 'opt-c', text: 'Voice messages'), + const PollOption(id: 'opt-d', text: 'Reactions 2.0'), + ], + voteCountsByOption: { + 'opt-a': 8, + 'opt-b': 5, + 'opt-c': 12, + 'opt-d': 3, + }, + voteCount: 28, + ); + + final pollMessage = Message( + id: 'poll-msg', + user: _sender, + poll: poll, + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + child: Center( + child: StreamMessageWidget(message: pollMessage), + ), + ); + }, + ); + + goldenTest( + 'polls composer attachment picker', + fileName: 'polls_composer', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + final pollController = StreamPollController( + poll: Poll( + id: 'poll-3', + name: 'Pizza or Tacos for the team lunch?', + options: const [ + PollOption(id: 'p1', text: 'Pizza'), + PollOption(id: 'p2', text: 'Tacos'), + PollOption(id: 'p3', text: 'Both!'), + ], + ), + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: StreamPollCreatorWidget( + controller: pollController, + shrinkWrap: true, + ), + ), + ), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/src/fakes.dart b/packages/docs_screenshots/test/src/fakes.dart new file mode 100644 index 000000000..ea2d18f6b --- /dev/null +++ b/packages/docs_screenshots/test/src/fakes.dart @@ -0,0 +1,40 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:record/record.dart'; + +class FakeRecordPlatform extends Fake with MockPlatformInterfaceMixin implements RecordPlatform { + @override + Future create(String recorderId) async {} + + @override + Future hasPermission(String recorderId, {bool request = true}) async { + return true; + } + + @override + Future isPaused(String recorderId) async { + return false; + } + + @override + Future isRecording(String recorderId) async { + return false; + } + + @override + Future pause(String recorderId) async {} + + @override + Future resume(String recorderId) async {} + + @override + Future stop(String recorderId) async { + return 'path'; + } + + @override + Future cancel(String recorderId) async {} + + @override + Future dispose(String recorderId) async {} +} diff --git a/packages/docs_screenshots/test/src/golden_client_stubs.dart b/packages/docs_screenshots/test/src/golden_client_stubs.dart new file mode 100644 index 000000000..641c394c5 --- /dev/null +++ b/packages/docs_screenshots/test/src/golden_client_stubs.dart @@ -0,0 +1,108 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +bool _registeredFallbacks = false; + +void _ensureGoldenMocktailFallbacks() { + if (_registeredFallbacks) return; + registerFallbackValue(const Filter.empty()); + registerFallbackValue(const ThreadOptions()); + registerFallbackValue(const PaginationParams()); + registerFallbackValue(>[]); + registerFallbackValue(>[]); + registerFallbackValue(>[]); + registerFallbackValue(>[]); + _registeredFallbacks = true; +} + +/// Subscriptions after a successful paged load call [StreamChatClient.on]. Mocks must return a stream. +void stubStreamClientEventStream(MockClient client) { + when(() => client.on()).thenAnswer((_) => const Stream.empty()); +} + +/// Stubs channel queries for [StreamChannelListController] goldens using [StreamChannelListController.fromValue]. +void stubQueryChannelsForGoldens(MockClient client, List channels) { + _ensureGoldenMocktailFallbacks(); + stubStreamClientEventStream(client); + when( + () => client.queryChannels( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + waitForConnect: any(named: 'waitForConnect'), + ), + ).thenAnswer((_) => Stream.value(channels)); +} + +/// Stubs thread queries for [StreamThreadListController] goldens using [StreamThreadListController.fromValue]. +void stubQueryThreadsForGoldens(MockClient client, List threads) { + _ensureGoldenMocktailFallbacks(); + stubStreamClientEventStream(client); + when( + () => client.queryThreads( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + options: any(named: 'options'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryThreadsResponse() + ..threads = threads + ..next = null, + ); +} + +/// Stubs user queries for [StreamUserListController] goldens using [StreamUserListController.fromValue]. +void stubQueryUsersForGoldens(MockClient client, List users) { + _ensureGoldenMocktailFallbacks(); + when( + () => client.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryUsersResponse()..users = users); +} + +/// Stubs draft queries for [StreamDraftListController] goldens using [StreamDraftListController.fromValue]. +void stubQueryDraftsForGoldens(MockClient client, List drafts) { + _ensureGoldenMocktailFallbacks(); + stubStreamClientEventStream(client); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryDraftsResponse() + ..drafts = drafts + ..next = null, + ); +} + +/// Stubs search for [StreamMessageSearchListController] goldens using [StreamMessageSearchListController.fromValue]. +void stubSearchMessagesForGoldens(MockClient client, List results) { + _ensureGoldenMocktailFallbacks(); + when( + () => client.search( + any(), + query: any(named: 'query'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + messageFilters: any(named: 'messageFilters'), + ), + ).thenAnswer( + (_) async => SearchMessagesResponse() + ..results = results + ..next = null, + ); +} diff --git a/packages/docs_screenshots/test/src/golden_theme.dart b/packages/docs_screenshots/test/src/golden_theme.dart new file mode 100644 index 000000000..451390906 --- /dev/null +++ b/packages/docs_screenshots/test/src/golden_theme.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +// --------------------------------------------------------------------------- +// StreamTheme (stream_core_flutter) — drives new message text rendering +// --------------------------------------------------------------------------- +// +// core.DefaultStreamMessageText reads its text style from +// StreamTheme.of(context).textTheme.bodyDefault. Those styles carry no +// fontFamily by default; when MarkdownBody passes them as the `p` style to +// RichText, RichText does NOT inherit DefaultTextStyle, so Flutter falls back +// to the Ahem test font (black rectangles). +// +// Fix: build a StreamTheme whose textTheme has fontFamily: 'Roboto' applied. +// +// --------------------------------------------------------------------------- +// StreamChatThemeData (stream_chat_flutter) — drives legacy message rendering +// --------------------------------------------------------------------------- +// +// StreamChatThemeData text styles (body, footnote, …) also carry no fontFamily. +// Same Ahem problem for any remaining legacy widgets that go through +// StreamMarkdownMessage → MarkdownBody → RichText. +// +// Fix: merge fontFamily: 'Roboto' into every StreamTextTheme style. + +ThemeData docsScreenshotsTheme() { + final streamTextTheme = core.StreamTextTheme().apply( + color: core.StreamColorScheme.light().systemText, + fontFamily: 'Roboto', + ); + + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + extensions: [ + StreamTheme(brightness: Brightness.light, textTheme: streamTextTheme), + ], + ); +} + +StreamChatThemeData docsStreamChatThemeData() { + const roboto = TextStyle(fontFamily: 'Roboto'); + final base = StreamChatThemeData.light(); + final textTheme = base.textTheme.merge( + StreamTextTheme.light( + body: roboto, + bodyBold: roboto, + title: roboto, + headline: roboto, + headlineBold: roboto, + footnote: roboto, + footnoteBold: roboto, + captionBold: roboto, + ), + ); + return StreamChatThemeData.fromColorAndTextTheme(base.colorTheme, textTheme); +} diff --git a/packages/docs_screenshots/test/src/mocks.dart b/packages/docs_screenshots/test/src/mocks.dart new file mode 100644 index 000000000..87932e5a7 --- /dev/null +++ b/packages/docs_screenshots/test/src/mocks.dart @@ -0,0 +1,169 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// [MockClient] cannot use `when(() => client.state.currentUser)` — mocktail +/// will treat [ClientState.currentUser] as the value of [StreamChatClient.state]. +/// Use this helper instead. +void stubMockClientCurrentUser(MockClient client, OwnUser user) { + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); +} + +class MockClient extends Mock implements StreamChatClient { + MockClient() { + when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); + when(() => wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + final mockState = MockClientState(); + when(() => state).thenReturn(mockState); + } +} + +class MockClientState extends Mock implements ClientState { + MockClientState() { + when(() => currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => currentUser).thenReturn(OwnUser(id: 'user-id')); + } +} + +class MockChannel extends Mock implements Channel { + MockChannel({ + this.type = 'test-channel-type', + this.id = 'test-channel-id', + this.ownCapabilities = const [ + ChannelCapability.sendMessage, + ChannelCapability.uploadFile, + ], + }); + + @override + final String type; + + @override + final String? id; + + @override + String? get cid { + if (id != null) return '$type:$id'; + return null; + } + + @override + final List ownCapabilities; + + @override + Stream> get ownCapabilitiesStream { + return Stream.value(ownCapabilities); + } + + @override + Future get initialized async => true; + + @override + Future watch({ + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) { + return Future.value(const ChannelState()); + } + + @override + // ignore: prefer_expression_function_bodies + Future keyStroke([String? parentId]) async { + return; + } + + @override + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) => + const Stream.empty(); +} + +class MockChannelState extends Mock implements ChannelClientState { + MockChannelState() { + when(() => typingEvents).thenReturn({}); + when(() => typingEventsStream).thenAnswer((_) => Stream.value({})); + when(() => unreadCount).thenReturn(0); + when(() => unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => isUpToDate).thenReturn(true); + when(() => isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => read).thenReturn([]); + when(() => readStream).thenAnswer((_) => Stream.value([])); + when(() => currentUserReadStream).thenAnswer((_) => Stream.value(null)); + when(() => draft).thenReturn(null); + when(() => draftStream).thenAnswer((_) => Stream.value(null)); + when(() => pinnedMessages).thenReturn([]); + when(() => pinnedMessagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState).thenReturn(const ChannelState()); + when(() => channelStateStream).thenAnswer((_) => Stream.value(const ChannelState())); + } +} + +/// Sets up a [MockChannel] with all stubs required by [StreamMessageInput]. +void setupMockChannel({ + required MockClient client, + required MockClientState clientState, + required MockChannel channel, + required MockChannelState channelState, + String channelName = 'test', + List messages = const [], + List members = const [], +}) { + final allMembers = members.isNotEmpty + ? members + : [Member(userId: 'user-id', user: User(id: 'user-id'))]; + + when(() => client.state).thenReturn(clientState); + when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); + when(() => channel.lastMessageAtStream).thenAnswer((_) => Stream.value(DateTime.parse('2020-06-22 12:00:00'))); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.isDistinct).thenReturn(false); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': channelName})); + when(() => channel.extraData).thenReturn({'name': channelName}); + when(() => channel.name).thenReturn(channelName); + when(() => channel.nameStream).thenAnswer((_) => Stream.value(channelName)); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value(allMembers)); + when(() => channelState.members).thenReturn(allMembers); + when(() => channelState.messages).thenReturn(messages); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value(messages)); +} + +/// Creates a [MockChannel] pre-configured with fake data for list views. +MockChannel fakeChannel({ + required MockClient client, + String id = 'test-channel-id', + String name = 'General', + List messages = const [], + int unreadCount = 0, +}) { + final channel = MockChannel(type: 'messaging', id: id); + final channelState = MockChannelState(); + final clientState = MockClientState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: name, + messages: messages, + ); + + when(() => channelState.unreadCount).thenReturn(unreadCount); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(unreadCount)); + + return channel; +} diff --git a/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png new file mode 100644 index 000000000..4452a88a4 Binary files /dev/null and b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png differ diff --git a/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png new file mode 100644 index 000000000..19898a169 Binary files /dev/null and b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png differ diff --git a/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png new file mode 100644 index 000000000..1f1ce2a51 Binary files /dev/null and b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png differ diff --git a/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png new file mode 100644 index 000000000..c27e887a9 Binary files /dev/null and b/packages/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png differ diff --git a/packages/docs_screenshots/test/thread_list/thread_list_view_test.dart b/packages/docs_screenshots/test/thread_list/thread_list_view_test.dart new file mode 100644 index 000000000..96f8c4ff5 --- /dev/null +++ b/packages/docs_screenshots/test/thread_list/thread_list_view_test.dart @@ -0,0 +1,288 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _user1 = User(id: 'user-1', name: 'Alice'); +final _user2 = User(id: 'user-2', name: 'Bob'); + +Thread _makeThread({ + required String id, + required String channelName, + required String parentText, + required String latestReplyText, + int unreadCount = 0, +}) { + final parentMessage = Message( + id: 'parent-$id', + text: parentText, + user: _user1, + createdAt: DateTime(2024, 6, 1, 9, 0), + ); + final latestReply = Message( + id: 'reply-$id', + text: latestReplyText, + user: _user2, + parentId: 'parent-$id', + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return Thread( + channelCid: 'messaging:$id', + parentMessageId: 'parent-$id', + createdByUserId: 'user-1', + replyCount: 3, + participantCount: 2, + channel: ChannelModel( + id: id, + type: 'messaging', + extraData: {'name': channelName}, + ), + parentMessage: parentMessage, + latestReplies: [latestReply], + read: unreadCount > 0 + ? [ + Read( + user: _user1, + lastRead: DateTime(2024, 6, 1, 8, 0), + unreadMessages: unreadCount, + ), + ] + : [], + ); +} + +Widget _buildFullAppThreadScaffold({ + required MockClient client, + required StreamThreadListController controller, + Widget Function(BuildContext)? emptyBuilder, + Widget Function(BuildContext, Thread)? customItemBuilder, + Widget? banner, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + title: const Text('Stream Chat'), + actions: [ + IconButton(icon: const Icon(Icons.edit_outlined), onPressed: null), + ], + ), + body: Column( + children: [ + if (banner != null) banner, + Expanded( + child: StreamThreadListView( + controller: controller, + emptyBuilder: emptyBuilder, + itemBuilder: customItemBuilder != null + ? (context, threads, index, defaultWidget) => customItemBuilder(context, threads[index]) + : null, + ), + ), + ], + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: 2, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.chat_bubble_outline), + label: 'Chats', + ), + BottomNavigationBarItem( + icon: Icon(Icons.alternate_email), + label: 'Mentions', + ), + BottomNavigationBarItem( + icon: Icon(Icons.comment_outlined), + label: 'Threads', + ), + ], + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'thread list view with threads', + fileName: 'thread_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 700), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final threads = [ + _makeThread( + id: 'general', + channelName: 'General', + parentText: 'Has anyone tried the new Flutter version?', + latestReplyText: 'Yes! The performance improvements are amazing.', + unreadCount: 2, + ), + _makeThread( + id: 'design', + channelName: 'Design', + parentText: 'The new color palette looks great!', + latestReplyText: 'Agreed, especially the dark mode colors.', + ), + _makeThread( + id: 'engineering', + channelName: 'Engineering', + parentText: 'We should refactor the auth module', + latestReplyText: 'I can take that on next sprint.', + ), + ]; + + final controller = StreamThreadListController.fromValue( + PagedValue(items: threads), + client: client, + ); + + stubQueryThreadsForGoldens(client, threads); + + return _buildFullAppThreadScaffold(client: client, controller: controller); + }, + ); + + goldenTest( + 'thread list view empty state', + fileName: 'thread_list_view_empty', + constraints: const BoxConstraints.tightFor(width: 375, height: 700), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final controller = StreamThreadListController.fromValue( + const PagedValue(items: []), + client: client, + ); + + stubQueryThreadsForGoldens(client, const []); + + return _buildFullAppThreadScaffold( + client: client, + controller: controller, + emptyBuilder: (context) => const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.forum_outlined, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'No threads yet', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + Text( + 'Threads will appear here once\nyou reply to a message.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + }, + ); + + goldenTest( + 'thread list tile custom', + fileName: 'thread_list_tile_custom', + constraints: const BoxConstraints.tightFor(width: 375, height: 120), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final thread = _makeThread( + id: 'general', + channelName: 'General', + parentText: 'Has anyone tried the new Flutter version?', + latestReplyText: 'Yes! The performance improvements are amazing.', + unreadCount: 3, + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: Colors.blue.shade700, width: 4), + ), + ), + child: StreamThreadListTile( + thread: thread, + currentUser: _user1, + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'thread list unread banner', + fileName: 'thread_list_unread_banner', + constraints: const BoxConstraints.tightFor(width: 375, height: 700), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final threads = [ + _makeThread( + id: 'general', + channelName: 'General', + parentText: 'Has anyone tried the new Flutter version?', + latestReplyText: 'Yes! The performance improvements are amazing.', + unreadCount: 2, + ), + _makeThread( + id: 'design', + channelName: 'Design', + parentText: 'The new color palette looks great!', + latestReplyText: 'Agreed, especially the dark mode colors.', + ), + _makeThread( + id: 'engineering', + channelName: 'Engineering', + parentText: 'We should refactor the auth module', + latestReplyText: 'I can take that on next sprint.', + ), + ]; + + final controller = StreamThreadListController.fromValue( + PagedValue(items: threads), + client: client, + ); + + stubQueryThreadsForGoldens(client, threads); + + return _buildFullAppThreadScaffold( + client: client, + controller: controller, + banner: StreamUnreadThreadsBanner( + unreadThreads: const {'thread-1', 'thread-2', 'thread-3'}, + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/user_list/goldens/macos/user_list_view.png b/packages/docs_screenshots/test/user_list/goldens/macos/user_list_view.png new file mode 100644 index 000000000..60806d8c0 Binary files /dev/null and b/packages/docs_screenshots/test/user_list/goldens/macos/user_list_view.png differ diff --git a/packages/docs_screenshots/test/user_list/user_list_view_test.dart b/packages/docs_screenshots/test/user_list/user_list_view_test.dart new file mode 100644 index 000000000..bb6cfcc07 --- /dev/null +++ b/packages/docs_screenshots/test/user_list/user_list_view_test.dart @@ -0,0 +1,53 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'user list view', + fileName: 'user_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final users = [ + User(id: 'user-2', name: 'Alice Johnson', online: true), + User(id: 'user-3', name: 'Bob Smith', online: false), + User(id: 'user-4', name: 'Carol White', online: true), + User(id: 'user-5', name: 'David Brown', online: false), + User(id: 'user-6', name: 'Eve Davis', online: true), + ]; + + final controller = StreamUserListController.fromValue( + PagedValue(items: users), + client: client, + ); + + stubQueryUsersForGoldens(client, users); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamUserListView( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ); + }, + ); +} diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png new file mode 100644 index 000000000..a7840f6cc Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png differ diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png new file mode 100644 index 000000000..a7840f6cc Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png differ diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png new file mode 100644 index 000000000..dbd8686b0 Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png differ diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png new file mode 100644 index 000000000..16d3fa681 Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png differ diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png new file mode 100644 index 000000000..36eb42460 Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png differ diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png new file mode 100644 index 000000000..dbd8686b0 Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png differ diff --git a/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png new file mode 100644 index 000000000..6f00a8e5b Binary files /dev/null and b/packages/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png differ diff --git a/packages/docs_screenshots/test/voice_recording/voice_recording_test.dart b/packages/docs_screenshots/test/voice_recording/voice_recording_test.dart new file mode 100644 index 000000000..3b2bcda36 --- /dev/null +++ b/packages/docs_screenshots/test/voice_recording/voice_recording_test.dart @@ -0,0 +1,335 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_recording_locked.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_recording_ongoing.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/fakes.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +class _MockAudioRecorder extends Mock implements AudioRecorder {} + +StreamAudioRecorderController _makeRecorderController(AudioRecorderState initialState) { + final mockRecorder = _MockAudioRecorder(); + when(() => mockRecorder.onAmplitudeChanged(any())).thenAnswer((_) => const Stream.empty()); + when(() => mockRecorder.dispose()).thenAnswer((_) async {}); + return StreamAudioRecorderController.raw( + config: const RecordConfig(numChannels: 1), + recorder: mockRecorder, + initialState: initialState, + ); +} + +Widget _buildVoiceRecordingMessageInputScaffold({ + required MockClient client, + required MockChannel channel, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded(child: Container()), + const StreamMessageInput(enableVoiceRecording: true), + ], + ), + ), + ), + ), + ); +} + +Widget _buildVoiceRecorderScaffold({ + required MockClient client, + required Widget child, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: Padding( + padding: const EdgeInsets.all(8), + child: Center(child: child), + ), + ), + ), + ); +} + +/// Scaffold that shows a message bubble + the voice widget + an input bar, +/// giving context to how voice recording looks in a real conversation. +Widget _buildVoiceRecordingContextScaffold({ + required MockClient client, + required MockChannel channel, + required Widget voiceWidget, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded( + child: ListView( + reverse: true, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: voiceWidget, + ), + StreamMessageWidget( + message: Message( + id: 'ctx-msg', + text: 'Hey, listen to this!', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 0), + ), + ), + ], + ), + ), + const StreamMessageInput(enableVoiceRecording: true), + ], + ), + ), + ), + ), + ); +} + +void _setupChannel(MockClient client, MockClientState clientState, MockChannel channel, MockChannelState channelState) { + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(Duration.zero); + }); + + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + goldenTest( + 'voice recording idle state', + fileName: 'voice_recording_idle', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + return _buildVoiceRecordingMessageInputScaffold( + client: client, + channel: channel, + ); + }, + ); + + goldenTest( + 'voice recording enabled (mic button visible)', + fileName: 'voice_recording_enabled', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + return _buildVoiceRecordingMessageInputScaffold( + client: client, + channel: channel, + ); + }, + ); + + goldenTest( + 'voice recording hold recording state', + fileName: 'voice_recording_hold_recording', + constraints: const BoxConstraints.tightFor(width: 375, height: 56), + builder: () { + final client = MockClient(); + + final holdState = RecordStateRecordingHold( + duration: const Duration(seconds: 5), + waveform: List.generate(20, (i) => (i % 5) / 5.0), + ); + + return _buildVoiceRecorderScaffold( + client: client, + child: StreamMessageComposerRecordingOngoing( + audioRecorderController: _makeRecorderController(holdState), + ), + ); + }, + ); + + goldenTest( + 'voice recording locked recording state', + fileName: 'voice_recording_locked_recording', + constraints: const BoxConstraints.tightFor(width: 375, height: 120), + builder: () { + final client = MockClient(); + + final lockedState = RecordStateRecordingLocked( + duration: const Duration(seconds: 12), + waveform: List.generate(20, (i) => (i % 5) / 5.0), + ); + + return _buildVoiceRecorderScaffold( + client: client, + child: MessageComposerRecordingLocked( + audioRecorderController: _makeRecorderController(lockedState), + feedback: const AudioRecorderFeedback(), + messageInputController: StreamMessageInputController(), + sendMessageCallback: null, + state: lockedState, + ), + ); + }, + ); + + goldenTest( + 'voice recording finished state', + fileName: 'voice_recording_finished', + constraints: const BoxConstraints.tightFor(width: 375, height: 120), + builder: () { + final client = MockClient(); + + final stoppedState = RecordStateStopped( + audioRecording: Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [0.1, 0.5, 0.9, 0.4, 0.2], + }, + ), + ); + + return _buildVoiceRecorderScaffold( + client: client, + child: MessageComposerRecordingStopped( + audioRecorderController: _makeRecorderController(stoppedState), + feedback: const AudioRecorderFeedback(), + messageInputController: StreamMessageInputController(), + sendMessageCallback: null, + recordingState: stoppedState, + ), + ); + }, + ); + + goldenTest( + 'voice recording attachment idle', + fileName: 'voice_recording_attachment', + constraints: const BoxConstraints.tightFor(width: 375, height: 400), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final voiceMessage = Message( + id: 'voice-msg', + user: User(id: 'user-2', name: 'Bob'), + attachments: [ + Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [0.1, 0.3, 0.5, 0.7, 0.9, 0.7, 0.5, 0.3, 0.1, + 0.2, 0.4, 0.6, 0.8, 0.6, 0.4, 0.2, 0.5, 0.8, 0.6, 0.3, + 0.1, 0.4, 0.7, 0.9, 0.6, 0.3, 0.1, 0.4, 0.7, 0.5, 0.2, + 0.6, 0.8, 0.4, 0.2], + }, + ), + ], + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildVoiceRecordingContextScaffold( + client: client, + channel: channel, + voiceWidget: StreamMessageWidget(message: voiceMessage), + ); + }, + ); + + goldenTest( + 'voice recording attachment playing', + fileName: 'voice_recording_attachment_playing', + constraints: const BoxConstraints.tightFor(width: 375, height: 400), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final voiceMessage = Message( + id: 'voice-msg-playing', + user: User(id: 'user-2', name: 'Bob'), + attachments: [ + Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [0.1, 0.3, 0.5, 0.7, 0.9, 0.7, 0.5, 0.3, 0.1, + 0.2, 0.4, 0.6, 0.8, 0.6, 0.4, 0.2, 0.5, 0.8, 0.6, 0.3, + 0.1, 0.4, 0.7, 0.9, 0.6, 0.3, 0.1, 0.4, 0.7, 0.5, 0.2, + 0.6, 0.8, 0.4, 0.2], + }, + ), + ], + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildVoiceRecordingContextScaffold( + client: client, + channel: channel, + voiceWidget: StreamMessageWidget(message: voiceMessage), + ); + }, + ); +} diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 8c69556af..45c3a1f14 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -72,7 +72,7 @@ dependencies: video_player: ^2.8.7 dev_dependencies: - alchemist: ^0.13.0 + alchemist: ^0.14.0 build_runner: ^2.4.9 connectivity_plus_platform_interface: ^2.0.0 faker_dart: ^0.2.1