diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 8046f2deee..741c1f8a3e 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: c012cfe01900fcc9cc87cafbcb2d46eada243343 + ref: 639f99401891f171e9cc2264eea822ef3ede3f99 path: packages/stream_core_flutter dev_dependencies: diff --git a/docs/docs_screenshots/test/channel/channel_header_test.dart b/docs/docs_screenshots/test/channel/channel_header_test.dart index a7d5afe73d..36aed46604 100644 --- a/docs/docs_screenshots/test/channel/channel_header_test.dart +++ b/docs/docs_screenshots/test/channel/channel_header_test.dart @@ -21,9 +21,7 @@ Widget _buildChannelHeaderScaffold({ child: StreamChannel( showLoading: false, channel: channel, - child: Scaffold( - appBar: header ?? const StreamChannelHeader(showBackButton: false), - ), + child: Scaffold(appBar: header), ), ), ); @@ -35,7 +33,7 @@ void main() { goldenTest( 'channel header default', fileName: 'channel_header', - constraints: const BoxConstraints.tightFor(width: 375, height: 56), + constraints: const BoxConstraints.tightFor(width: 375, height: 72), builder: () { final client = MockClient(); final clientState = MockClientState(); @@ -50,14 +48,20 @@ void main() { channelName: 'General', ); - return _buildChannelHeaderScaffold(client: client, channel: channel); + return _buildChannelHeaderScaffold( + client: client, + channel: channel, + header: const StreamChannelHeader( + automaticallyImplyLeading: false, + ), + ); }, ); goldenTest( 'channel header with custom title', fileName: 'channel_header_custom_title', - constraints: const BoxConstraints.tightFor(width: 375, height: 56), + constraints: const BoxConstraints.tightFor(width: 375, height: 72), builder: () { final client = MockClient(); final clientState = MockClientState(); @@ -76,8 +80,8 @@ void main() { client: client, channel: channel, header: const StreamChannelHeader( - showBackButton: false, title: Text('My Custom Title'), + automaticallyImplyLeading: false, ), ); }, diff --git a/docs/docs_screenshots/test/channel/channel_list_header_test.dart b/docs/docs_screenshots/test/channel/channel_list_header_test.dart index 74c20d2dfd..b4d31cb404 100644 --- a/docs/docs_screenshots/test/channel/channel_list_header_test.dart +++ b/docs/docs_screenshots/test/channel/channel_list_header_test.dart @@ -18,9 +18,7 @@ Widget _buildListHeaderScaffold({ client: client, streamChatThemeData: docsStreamChatThemeData(), connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: Scaffold( - appBar: header ?? const StreamChannelListHeader(), - ), + child: Scaffold(appBar: header), ), ); } @@ -31,21 +29,24 @@ void main() { goldenTest( 'channel list header default', fileName: 'channel_list_header', - constraints: const BoxConstraints.tightFor(width: 375, height: 56), + constraints: const BoxConstraints.tightFor(width: 375, height: 72), 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); + return _buildListHeaderScaffold( + client: client, + header: const StreamChannelListHeader(), + ); }, ); goldenTest( 'channel list header with custom subtitle', fileName: 'channel_list_header_custom_subtitle', - constraints: const BoxConstraints.tightFor(width: 375, height: 56), + constraints: const BoxConstraints.tightFor(width: 375, height: 72), builder: () { final client = MockClient(); final clientState = MockClientState(); diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png index 95fc1ff072..4d18213300 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png and b/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png index 997904ce29..78c61e9870 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png and b/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png index 1471fdab15..287165df47 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png and b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png index 7ca57652a6..caebc7dee6 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png and b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png index b8c803ef04..d2dedb3646 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png and b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png index 35e72864be..28856ac26f 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png and b/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png b/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png index 2e88270019..52e79ff025 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png and b/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png b/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png index d3b2fdfcc1..640187f5a6 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png and b/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png index 1219003fb6..72f5f57da8 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png and b/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png index 423dce7b4e..9f21dd5e9d 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png and b/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png index 51201d3a24..33d9a674fd 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png and b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png index 60a9dba966..967a4fef66 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png and b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png index 17da3063af..4955a1cf0f 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png and b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png index 2092af1550..41b5016759 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png and b/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png b/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png index 8c148aec02..7b576cb79e 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png and b/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png b/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png index 1b2df5b5b5..6e1da412b8 100644 Binary files a/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png and b/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png b/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png index 24a91a01e9..bf0efba09e 100644 Binary files a/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png and b/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png b/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png index e0b7af8ac7..5e463115dd 100644 Binary files a/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png and b/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png b/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png index 736bef3654..b8262fa04a 100644 Binary files a/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png and b/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png index 15cd1ebb2c..e656fc6837 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png index e1b2d28ffa..bb409d9118 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png index 64f0a52dd1..ba328df7b6 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png index 3a14aa89e6..fb230595da 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png index 15cd1ebb2c..e656fc6837 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png and b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png index 4100f39978..8cb395a19b 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png index 0b28d7e3d5..9cca5a0eb8 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png index 6081990377..48ef4e2af9 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png index a12eeb1fe9..0b0979a944 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png index b61dedaca2..acdcb103d5 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png index fbe081b749..879707b8ed 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png index ca2123dc03..e0621a0219 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png index 6ba953d228..5d16603aae 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png differ diff --git a/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png b/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png index e866c5928c..4035a4990f 100644 Binary files a/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png and b/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png b/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png index ce8cf23227..76b8461cb0 100644 Binary files a/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png and b/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png b/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png index 64285e9df1..8f23a4c305 100644 Binary files a/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png and b/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png b/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png index 99059644af..b584057767 100644 Binary files a/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png and b/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png differ diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart index 397120f1b2..359ac4e604 100644 --- a/docs/docs_screenshots/test/src/mocks.dart +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -133,6 +133,8 @@ void setupMockChannel({ when(() => channel.isDistinct).thenReturn(false); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.isPinned).thenReturn(false); + when(() => channel.isPinnedStream).thenAnswer((_) => Stream.value(false)); when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': channelName})); when(() => channel.extraData).thenReturn({'name': channelName}); when(() => channel.name).thenReturn(channelName); diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png index 6800c954ad..909cf785cd 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png index 828159897d..777bc6e2b6 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png index ee197046bf..2d3021f2db 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png index 0cf3f42900..2b1ce833d6 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png index 3ca81f4bb2..07839c663f 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png index 25e081d596..440917ece9 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png index 3f80819a82..4c853e6958 100644 Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png differ diff --git a/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png b/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png index e50faead54..c9948533b6 100644 Binary files a/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png and b/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png index 90b4d52e26..d9742b5dbf 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png index b6b1125d3f..81a81b61e0 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png index 90b4d52e26..d9742b5dbf 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png index d1c5a838a8..c412178ff3 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png index c283512cfa..bd6a72fc00 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png index dd484b0b4e..f09eca04bd 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png index d1c5a838a8..c412178ff3 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png index 369a1611c2..29cba0a34f 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png index e6d13e4e20..ed110c3bfc 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png index 32488f1a92..49d2a05921 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png differ diff --git a/melos.yaml b/melos.yaml index 94eed485db..cdc24fb372 100644 --- a/melos.yaml +++ b/melos.yaml @@ -19,6 +19,9 @@ categories: command: bootstrap: + # Run `pub get` sequentially to avoid races on the shared git-dep cache. + runPubGetInParallel: false + # Dart and Flutter environment used in the project. environment: sdk: ^3.10.0 @@ -98,7 +101,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: c012cfe01900fcc9cc87cafbcb2d46eada243343 + ref: 639f99401891f171e9cc2264eea822ef3ede3f99 path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 diff --git a/migrations/redesign/headers_and_icons.md b/migrations/redesign/headers_and_icons.md index 4356f61eb1..053a9f36c9 100644 --- a/migrations/redesign/headers_and_icons.md +++ b/migrations/redesign/headers_and_icons.md @@ -113,34 +113,178 @@ The following icons have been **removed with no equivalent** in the new set: ## Header Widgets -`StreamChannelHeader`, `StreamChannelListHeader`, and `StreamThreadHeader` all received the same set of default-value changes. +All four chat headers — `StreamChannelHeader`, `StreamChannelListHeader`, +`StreamThreadHeader`, and `StreamGalleryHeader` — have been rebuilt on top +of the new design system's `StreamAppBar`. They now share a single slot +model (`leading` / `title` / `subtitle` / `trailing`) and a single theme +type (`StreamAppBarThemeData`), replacing the legacy +`AppBar`-style API. + +### What changed across all headers + +* **New layout primitive.** The headers render a [`StreamAppBar`] with a + fixed 72-px height (`kStreamHeaderHeight`) instead of Material's + `kToolbarHeight` (56 px). Pass the header directly to `Scaffold.appBar` + as before — it implements `PreferredSizeWidget`. +* **Slot model.** All four headers now expose `leading`, `title`, + `subtitle`, and `trailing` as plain `Widget?` slots. Anything you used + to compose with `actions: [...]` should move into `trailing:` (single + widget) or be wrapped into a `Row` you build yourself. +* **Auto-implied leading.** Headers that previously had `showBackButton` + / `onBackPressed` now use `automaticallyImplyLeading` (default `true`) + to insert a default back button. To override, pass `leading:` directly; + to suppress, pass `automaticallyImplyLeading: false`. +* **Theme.** `StreamChannelHeaderThemeData`, `StreamChannelListHeaderThemeData`, + and `StreamGalleryHeaderThemeData` are deleted. The corresponding + accessors on `StreamChatThemeData` (`channelHeaderTheme`, + `channelListHeaderTheme`, `threadHeaderTheme`, `galleryHeaderTheme`) + now return [`StreamAppBarThemeData`]. +* **Per-instance overrides.** A new `style: StreamAppBarStyle?` parameter + lets callers override colours / padding / typography for one instance — + it merges over the ambient `StreamAppBarTheme`. +* **Removed parameters.** `centerTitle`, `elevation`, `bottomOpacity`, + `bottom`, and `backgroundColor` are gone — the new bar always centres + the title, draws a hairline `borderSubtle` divider instead of an + elevation shadow, and reads its background from the theme / `style`. + +### `StreamChannelHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `showBackButton: false` | `automaticallyImplyLeading: false` | +| `onBackPressed: cb` | `leading: StreamBackButton(onPressed: cb)` | +| `onTitleTap: cb` | `title: GestureDetector(onTap: cb, child: ...)` | +| `onImageTap: cb` | `onChannelAvatarPressed: (channel) => cb()` (or replace `trailing:`) | +| `showTypingIndicator: false` | `subtitle: Text(channel.name)` (or any custom widget) | +| `actions: [a, b]` | `trailing: Row(children: [a, b])` | +| `centerTitle`, `elevation`, `bottom`, `bottomOpacity`, `backgroundColor` | Use `style: StreamAppBarStyle(backgroundColor: ...)` for the background; the rest are gone | -### Breaking Changes +**Before:** -| Parameter | Old default | New default | Notes | -|-----------|-------------|-------------|-------| -| `centerTitle` | `bool?` (`null` → platform-adaptive) | `bool` (`true`) | Was `null` by default, which let Flutter centre on iOS and left-align on Android. Now always `true` — explicitly pass `centerTitle: false` to restore left-aligned titles. | -| `elevation` | `1` | `0` | Removes the drop shadow by default. Pass `elevation: 1` to restore the old appearance. | -| `scrolledUnderElevation` | — | `0` (new param) | Controls the elevation when content is scrolled under the header. | +```dart +StreamChannelHeader( + showBackButton: true, + onBackPressed: () => GoRouter.of(context).pop(), + onImageTap: () => openChannelInfo(channel), + showTypingIndicator: true, + elevation: 1, +) +``` -### Migration +**After:** + +```dart +StreamChannelHeader( + leading: StreamBackButton(onPressed: () => GoRouter.of(context).pop()), + onChannelAvatarPressed: (channel) => openChannelInfo(channel), +) +``` + +The default leading is now [`StreamBackButton`] with a channel-aware +unread badge; the default trailing is the channel avatar wrapped in a +48×48 tap target wired to `onChannelAvatarPressed`. + +### `StreamChannelListHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `titleBuilder: (context, user) => ...` | `title: ...` (a `Widget`) | +| `onUserAvatarTap: cb` | `onUserAvatarPressed: cb` (renamed) | +| `onNewChatButtonTap: cb` | `trailing: StreamButton.icon(icon: Icon(context.streamIcons.plus), onPressed: cb)` | +| `preNavigationCallback`, `leading`, `actions`, `centerTitle`, `elevation`, `backgroundColor` | Removed — see notes below | + +The leading slot is no longer caller-overridable: the SDK always renders +the signed-in user's avatar. When `onUserAvatarPressed` is null the +avatar mirrors Material `AppBar`'s auto-implied leading and opens the +enclosing `Scaffold`'s drawer if one exists, so the previous +`onUserAvatarTap: (_) => Scaffold.of(context).openDrawer()` callsites +can drop the callback entirely. + +The trailing slot is empty by default — the SDK no longer ships a +"new chat" button. Pass your own widget if you want one. + +**Before:** ```dart -// Before: title was platform-adaptive, header had a shadow -StreamChannelHeader() -StreamChannelListHeader() -StreamThreadHeader(parent: parentMessage) - -// After: title always centred, no shadow -// If you relied on left-aligned titles on Android, pass centerTitle: false: -StreamChannelHeader(centerTitle: false) -StreamChannelListHeader(centerTitle: false) -StreamThreadHeader(parent: parentMessage, centerTitle: false) - -// If you relied on the elevation shadow, restore it: -StreamChannelHeader(elevation: 1) +StreamChannelListHeader( + titleBuilder: (context, user) => Text(user?.name ?? 'Stream Chat'), + onUserAvatarTap: (_) => Scaffold.of(context).openDrawer(), + onNewChatButtonTap: () => GoRouter.of(context).pushNamed('new-chat'), + elevation: 1, +) ``` +**After:** + +```dart +StreamChannelListHeader( + title: Text('Chats', style: context.streamTextTheme.headingSm), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + onPressed: () => GoRouter.of(context).pushNamed('new-chat'), + ), +) +``` + +### `StreamThreadHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `showBackButton: false` | `automaticallyImplyLeading: false` | +| `onBackPressed: cb` | `leading: StreamBackButton(onPressed: cb)` | +| `onTitleTap: cb` | `title: GestureDetector(onTap: cb, child: ...)` | +| `showTypingIndicator: false` | `subtitle: Text(...)` (or any custom widget) | +| `actions: [a, b]` | `trailing: Row(children: [a, b])` | +| `centerTitle`, `elevation`, `backgroundColor` | Use `style:`; the rest are gone | + +The default subtitle is a [`StreamTypingIndicator`] that falls back to +the thread's reply count when nobody is typing. + +### `StreamGalleryHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `showBackButton: false` | `automaticallyImplyLeading: false` | +| `onBackPressed: cb` | `leading: StreamBackButton(onPressed: cb)` | +| `onTitleTap: cb` | `title: GestureDetector(onTap: cb, child: ...)` | +| `onImageTap: cb` | `trailing: GestureDetector(onTap: cb, child: ...)` | +| `elevation`, `backgroundColor` | Use `style:`; the rest are gone | + +`onShowMessage`, `onReplyMessage`, and `attachmentActionsModalBuilder` +are unchanged. The default trailing is still an icon button that opens +the attachment actions modal. + +### Theming + +The theme accessors on `StreamChatThemeData` keep their names but their +type changes to [`StreamAppBarThemeData`]: + +```dart +// Before +StreamChannelHeaderThemeData( + color: theme.colorTheme.barsBg, + titleStyle: theme.textTheme.headlineBold, +) + +// After +StreamAppBarThemeData( + style: StreamAppBarStyle( + backgroundColor: colorScheme.backgroundElevation1, + titleTextStyle: textTheme.headingSm, + ), +) +``` + +Use `StreamAppBarTheme(data: ..., child: ...)` to override the theme for +a subtree, or pass `style:` directly on a single header instance. + +### Header height + +`kStreamHeaderHeight` (72 px) is now the canonical header height — +exposed from `package:stream_chat_flutter/stream_chat_flutter.dart`. If +you read `kToolbarHeight` (56 px) to size custom chrome that sits next +to a Stream header, switch to `kStreamHeaderHeight` to stay aligned. + --- ## StreamChat.componentBuilders @@ -225,7 +369,15 @@ StreamChat( ## Migration Checklist - [ ] Replace all `StreamSvgIcon(icon: StreamSvgIcons.*)` with `Icon(context.streamIcons.*)` using the mapping table above -- [ ] If you relied on platform-adaptive `centerTitle` behaviour on `StreamChannelHeader` / `StreamChannelListHeader` / `StreamThreadHeader`, pass `centerTitle: false` explicitly for Android-style left-aligned titles -- [ ] If you relied on the `elevation: 1` shadow on headers, pass `elevation: 1` explicitly +- [ ] Replace `showBackButton: false` with `automaticallyImplyLeading: false` on every header that used it +- [ ] Move `onBackPressed: cb` to `leading: StreamBackButton(onPressed: cb)` +- [ ] Move `onTitleTap` / `onImageTap` callbacks into `GestureDetector` wrappers around the new `title:` / `trailing:` slots (or use `onChannelAvatarPressed` on `StreamChannelHeader`) +- [ ] Rename `StreamChannelListHeader.onUserAvatarTap` to `onUserAvatarPressed`; drop manual `Scaffold.of(context).openDrawer()` callbacks if you only want the default drawer behaviour +- [ ] Replace `StreamChannelListHeader.onNewChatButtonTap` with a `trailing: StreamButton.icon(...)` widget — the SDK no longer ships the default button +- [ ] Replace `titleBuilder: (context, user) => ...` with `title: ...` (a `Widget`) +- [ ] Replace `actions: [a, b]` with `trailing: Row(children: [a, b])` — only one trailing slot is exposed +- [ ] Drop `centerTitle`, `elevation`, `bottomOpacity`, `bottom`, and `backgroundColor` from header callsites; use `style: StreamAppBarStyle(backgroundColor: ...)` if you need to override the background +- [ ] Update theme overrides: `StreamChannelHeaderThemeData` / `StreamChannelListHeaderThemeData` / `StreamGalleryHeaderThemeData` are deleted — switch to `StreamAppBarThemeData` +- [ ] If you sized custom chrome to `kToolbarHeight` next to a Stream header, switch to `kStreamHeaderHeight` (72 px) - [ ] Optionally move `StreamComponentFactory` wrapping into the `componentBuilders` parameter on `StreamChat` - [ ] Use the new `attachmentBuilders`, `reactionType`, and `reactionPosition` fields on `StreamChatConfigurationData` if you need custom attachment rendering or global reaction style control diff --git a/migrations/redesign/localizations.md b/migrations/redesign/localizations.md index c7df78e249..ab090565ef 100644 --- a/migrations/redesign/localizations.md +++ b/migrations/redesign/localizations.md @@ -97,6 +97,10 @@ String get loadingReactionsError => 'Error loading reactions'; @override String get tapToRemoveReactionLabel => 'Tap to remove'; +@override +String reactionsCountText(int count) => + count == 1 ? '1 Reaction' : '$count Reactions'; + // Confirmation dialogs @override String get confirmLabel => 'CONFIRM'; diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 2e6d2ddc46..3b4c7d334b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -4,11 +4,17 @@ - Added `StreamChatClient.recoverStateOnReconnect` (defaults to `true`); when `false`, the client no longer auto-re-queries active channels on connection recovery — useful for consumers driving their own refresh from the `connectionRecovered` event. - Added `Message.updateWith(Message? other)` — merges a server-side update onto the local message while preserving locally-known `poll`, `sharedLocation`, `ownReactions`, and nested `quotedMessage` enrichment when the server omits them. +- Added `Channel.isOneToOne` — true when the channel is `isDistinct` and has exactly two members. For the looser count-only check, inline `channel.memberCount == 2`. ⚠️ Deprecated - Deprecated `Message.syncWith` in favor of `Message.updateWith`. Note the arguments are flipped: `local.updateWith(remote)` replaces `remote.syncWith(local)`. +🔄 Changed + +- Tightened `Channel.isGroup` from `memberCount != 2` to `memberCount > 2 || !isDistinct`. Two-member non-distinct channels now correctly report as groups, and 1-member distinct channels no longer do. Migrate via `!channel.isOneToOne` or `channel.memberCount != 2`. +- Tightened `Channel.isDistinct` to require the `!members-` prefix (with trailing dash), matching the backend's `DistinctChannelPrefix` constant. Real server-generated ids always include the dash; only malformed/test ids that previously matched the looser `!members` check are affected. + 🐞 Fixed - Fixed reactions, polls, and quoted-message enrichment briefly flickering after the app returned from the background. The reconnect path now refreshes channels and advances `lastSyncAt` to the current time instead of replaying every event since `lastSyncAt` through `handleEvent`. `client.sync()` remains available for consumers that need event-level replay. diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 9fcbd181af..8654c1599a 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -143,19 +143,57 @@ class Channel { _extraData.addAll(extraData); } + /// Whether this channel is identified by its member set rather than an + /// explicit id. + /// + /// Stream auto-generates ids of the form `!members-` for channels + /// created with members but no id, so the same set of users always + /// references the same channel. + /// + /// Distinct channels can lose members but can't gain them after creation. + /// + /// See [isOneToOne] for the typical 1-to-1 predicate built on this. + bool get isDistinct => id?.startsWith('!members') == true; + + /// Whether this is a group channel. + /// + /// True when the channel has more than two members, or isn't [isDistinct]. + /// Custom-id channels are treated as groups regardless of current member + /// count because they aren't bounded — they can grow back into + /// multi-person conversations. + /// + /// Near-inverse of [isOneToOne]. + bool get isGroup => (memberCount ?? 0) > 2 || !isDistinct; + + /// Whether this is a 1-to-1 conversation. + /// + /// True when the channel is [isDistinct] and has exactly two members. + /// Distinct channels can't gain members, so a 2-member distinct channel + /// is permanently bounded to two participants — including channels that + /// shrunk down from a larger group DM. + /// + /// This is a structural predicate without a current-user check. Combine + /// with capability / permission checks at the call site if you need + /// perspective gating. + /// + /// Near-inverse of [isGroup]. + bool get isOneToOne => isDistinct && memberCount == 2; + /// Returns true if the channel is muted. - bool get isMuted => _client.state.currentUser?.channelMutes.any((element) => element.channel.cid == cid) == true; + bool get isMuted { + final channelMutes = _client.state.currentUser?.channelMutes; + if (channelMutes == null) return false; - /// Returns true if the channel is muted, as a stream. - Stream get isMutedStream => _client.state.currentUserStream - .map((event) => event?.channelMutes.any((element) => element.channel.cid == cid) == true) - .distinct(); + return channelMutes.any((it) => it.channel.cid == cid); + } - /// True if the channel is a group. - bool get isGroup => memberCount != 2; + /// Returns true if the channel is muted, as a stream. + Stream get isMutedStream => _client.state.currentUserStream.map((user) { + final channelMutes = user?.channelMutes; + if (channelMutes == null) return false; - /// True if the channel is distinct. - bool get isDistinct => id?.startsWith('!members') == true; + return channelMutes.any((it) => it.channel.cid == cid); + }).distinct(); /// Channel configuration. ChannelConfig? get config { diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c353af2010..6702419904 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -23,6 +23,8 @@ - `StreamMessageActionConfirmationModal.cancelActionTitle` / `confirmActionTitle` are now nullable and fall back to `Translations.cancelLabel` / `confirmLabel`. - Renamed `Translations.attachmentsUploadProgressText` parameter `remaining` → `completed`. - Updated several `Translations` default strings and added new abstract members — see [`migrations/redesign/localizations.md`](../../migrations/redesign/localizations.md). +- Renamed `MuteIconPosition` → `AttributePosition` (values `title` → `inlineTitle`, `subtitle` → `trailingBottom`) and `StreamChannelListItemThemeData.muteIconPosition` → `attributePosition`. Now controls both mute and pin icons in `StreamChannelListTile`. +- Removed `AttachmentModalSheet`, `ErrorAlertSheet` and `StreamChannelInfoBottomSheet`. ✅ Added @@ -39,6 +41,8 @@ - Re-exported `StreamMessageAttachment` and `StreamMessageAttachmentStyle` from `stream_core_flutter`. - Added a `BoxFit? fit` parameter to `ThumbnailSizeCalculator.calculate` (null defaults to `BoxFit.scaleDown`, matching `paintImage`) so callers using `cover` / `fill` get a bitmap large enough to render without upscale blur. - Added `Translations.totalVoteCountLabel({int? count})`, `viewAllLabel`, `pollVotesLabel`, `endVoteConfirmationMessage` and `questionLabel({bool isPlural = false})`. +- Added `Translations.reactionsCountText(int count)` for the reaction-detail sheet header. +- Added `StreamChannelListTile.isPinned` — renders a pin icon alongside the existing mute icon for pinned channels. 🔄 Changed diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index a9719055b3..b8aa4c81d4 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -227,25 +227,37 @@ class _ChannelPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: StreamChannelHeader( - onBackPressed: widget.onBackPressed != null - ? () { - widget.onBackPressed!(context); - } - : null, - onImageTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: StreamChannel.of(context).channel, - child: const DebugChannelPage(), - ); - }, - ), - ); + leading: switch ((widget.showBackButton, widget.onBackPressed)) { + (true, final cb?) => StreamBackButton( + channelId: StreamChannel.of(context).channel.cid, + onPressed: () => cb(context), + showUnreadCount: true, + ), + (true, null) => StreamBackButton( + channelId: StreamChannel.of(context).channel.cid, + showUnreadCount: true, + ), + _ => const SizedBox(), }, - showBackButton: widget.showBackButton, + trailing: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: StreamChannel.of(context).channel, + child: const DebugChannelPage(), + ); + }, + ), + ); + }, + child: StreamChannelAvatar( + size: .lg, + channel: StreamChannel.of(context).channel, + ), + ), ), body: Column( children: [ diff --git a/packages/stream_chat_flutter/example/lib/split_view.dart b/packages/stream_chat_flutter/example/lib/split_view.dart index a873f92265..220c7cdf24 100644 --- a/packages/stream_chat_flutter/example/lib/split_view.dart +++ b/packages/stream_chat_flutter/example/lib/split_view.dart @@ -130,7 +130,7 @@ class ChannelPage extends StatelessWidget { onGenerateRoute: (settings) => MaterialPageRoute( builder: (context) => const Scaffold( appBar: StreamChannelHeader( - showBackButton: false, + automaticallyImplyLeading: false, ), body: Column( children: [ diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart index 7b101e3f77..f935edc970 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart @@ -113,7 +113,7 @@ class AttachmentActionsModal extends StatelessWidget { context.translations.replyLabel, Icon( context.streamIcons.reply, - size: 24, + size: 20, color: theme.colorTheme.textLowEmphasis, ), onReply, @@ -123,9 +123,9 @@ class AttachmentActionsModal extends StatelessWidget { context, context.translations.showInChatLabel, Icon( - context.streamIcons.eyeFill, - size: 24, - color: theme.colorTheme.textHighEmphasis, + context.streamIcons.messageBubble, + size: 20, + color: theme.colorTheme.textLowEmphasis, ), onShowMessage, ), @@ -136,8 +136,8 @@ class AttachmentActionsModal extends StatelessWidget { ? context.translations.saveVideoLabel : context.translations.saveImageLabel, Icon( - context.streamIcons.save, - size: 24, + context.streamIcons.arrowDownCircle, + size: 20, color: theme.colorTheme.textLowEmphasis, ), () { @@ -197,7 +197,7 @@ class AttachmentActionsModal extends StatelessWidget { context.translations.deleteLabel, Icon( context.streamIcons.delete, - size: 24, + size: 20, color: theme.colorTheme.accentError, ), () { diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/attachment_modal_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/attachment_modal_sheet.dart deleted file mode 100644 index 7367e0201c..0000000000 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/attachment_modal_sheet.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template attachmentModalSheet} -/// The modalBottomSheet that appears when a mobile user attempts to add -/// attachments to a chat. -/// -/// Should not be used on desktop or web. -/// {@endtemplate} -class AttachmentModalSheet extends StatelessWidget { - /// {@macro attachmentModalSheet} - const AttachmentModalSheet({ - super.key, - required this.onFileTap, - required this.onPhotoTap, - required this.onVideoTap, - }); - - /// The action to perform when the "file" button is tapped. - final VoidCallback onFileTap; - - /// The action to perform when the "photo" button is tapped. - final VoidCallback onPhotoTap; - - /// The action to perform when the "video" button is tapped. - final VoidCallback onVideoTap; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - context.translations.addAFileLabel, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ListTile( - leading: const Icon(Icons.image), - title: Text(context.translations.uploadAPhotoLabel), - onTap: () { - onPhotoTap.call(); - Navigator.of(context).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.video_library), - title: Text(context.translations.uploadAVideoLabel), - onTap: () { - onVideoTap.call(); - Navigator.of(context).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.insert_drive_file), - title: Text(context.translations.uploadAFileLabel), - onTap: () { - onFileTap.call(); - Navigator.of(context).pop(); - }, - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/stream_channel_info_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/stream_channel_info_bottom_sheet.dart deleted file mode 100644 index 0ad3d609c9..0000000000 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/stream_channel_info_bottom_sheet.dart +++ /dev/null @@ -1,352 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A [BottomSheet] that shows information about a [Channel]. -class StreamChannelInfoBottomSheet extends StatelessWidget { - /// Creates a new instance [StreamChannelInfoBottomSheet] widget. - StreamChannelInfoBottomSheet({ - super.key, - required this.channel, - this.onMemberTap, - this.onViewInfoTap, - this.onLeaveChannelTap, - this.onDeleteConversationTap, - this.onCancelTap, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The [Channel] to show information about. - final Channel channel; - - /// A callback that is called when a member is tapped. - final void Function(Member)? onMemberTap; - - /// A callback that is called when the "View Info" button is tapped. - final VoidCallback? onViewInfoTap; - - /// A callback that is called when the "Leave Channel" button is tapped. - /// - /// Only shown when the channel is a group channel. - final VoidCallback? onLeaveChannelTap; - - /// A callback that is called when the "Delete Conversation" button is tapped. - /// - /// Only shown when you are the `owner` of the channel. - final VoidCallback? onDeleteConversationTap; - - /// A callback that is called when the "Cancel" button is tapped. - final VoidCallback? onCancelTap; - - @override - Widget build(BuildContext context) { - final themeData = StreamChatTheme.of(context); - final colorTheme = themeData.colorTheme; - - final currentUser = channel.client.state.currentUser; - final isOneToOneChannel = channel.isDistinct && channel.memberCount == 2; - - final members = channel.state?.members ?? []; - - // remove current user in case it's 1-1 conversation - if (isOneToOneChannel) { - members.removeWhere((it) => it.user?.id == currentUser?.id); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 24), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamChannelName( - channel: channel, - textStyle: themeData.textTheme.headlineBold, - ), - ), - ), - const SizedBox(height: 5), - Center( - // TODO: Refactor ChannelInfo - child: StreamChannelInfo( - showTypingIndicator: false, - channel: channel, - textStyle: context.streamTextTheme.captionDefault, - ), - ), - const SizedBox(height: 17), - Container( - height: 94, - alignment: Alignment.center, - child: ListView.separated( - shrinkWrap: true, - itemCount: members.length, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - separatorBuilder: (context, index) => const SizedBox(width: 16), - itemBuilder: (context, index) { - final member = members[index]; - final user = member.user!; - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: switch (onMemberTap) { - final onTap? => () => onTap(member), - _ => null, - }, - child: StreamUserAvatar( - size: .xl, - user: user, - ), - ), - const SizedBox(height: 6), - Text( - user.name, - style: themeData.textTheme.footnoteBold, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - }, - ), - ), - const SizedBox(height: 24), - StreamOptionListTile( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - context.streamIcons.user, - color: colorTheme.textLowEmphasis, - ), - ), - title: context.translations.viewInfoLabel, - onTap: onViewInfoTap, - ), - if (!isOneToOneChannel) - StreamOptionListTile( - title: context.translations.leaveGroupLabel, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - context.streamIcons.userRemove, - color: colorTheme.textLowEmphasis, - ), - ), - onTap: onLeaveChannelTap, - ), - if (channel.canDeleteChannel) - StreamOptionListTile( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - context.streamIcons.delete, - color: colorTheme.accentError, - ), - ), - title: context.translations.deleteConversationLabel, - titleColor: colorTheme.accentError, - onTap: onDeleteConversationTap, - ), - StreamOptionListTile( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - context.streamIcons.xmark, - color: colorTheme.textLowEmphasis, - ), - ), - title: context.translations.cancelLabel, - onTap: onCancelTap ?? Navigator.of(context).pop, - ), - ], - ); - } -} - -const _kDefaultChannelInfoBottomSheetShape = RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), -); - -/// Shows a modal material design bottom sheet. -/// -/// A modal bottom sheet is an alternative to a menu or a dialog and prevents -/// the user from interacting with the rest of the app. -/// -/// A closely related widget is a persistent bottom sheet, which shows -/// information that supplements the primary content of the app without -/// preventing the use from interacting with the app. Persistent bottom sheets -/// can be created and displayed with the [showBottomSheet] function or the -/// [ScaffoldState.showBottomSheet] method. -/// -/// The `context` argument is used to look up the [Navigator] and [Theme] for -/// the bottom sheet. It is only used when the method is called. Its -/// corresponding widget can be safely removed from the tree before the bottom -/// sheet is closed. -/// -/// The `isScrollControlled` parameter specifies whether this is a route for -/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish -/// to have a bottom sheet that has a scrollable child such as a [ListView] or -/// a [GridView] and have the bottom sheet be draggable, you should set this -/// parameter to true. -/// -/// The `useRootNavigator` parameter ensures that the root navigator is used to -/// display the [BottomSheet] when set to `true`. This is useful in the case -/// that a modal [BottomSheet] needs to be displayed above all other content -/// but the caller is inside another [Navigator]. -/// -/// The [isDismissible] parameter specifies whether the bottom sheet will be -/// dismissed when user taps on the scrim. -/// -/// The [enableDrag] parameter specifies whether the bottom sheet can be -/// dragged up and down and dismissed by swiping downwards. -/// -/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], -/// [constraints] and [transitionAnimationController] -/// parameters can be passed in to customize the appearance and behavior of -/// modal bottom sheets (see the documentation for these on [BottomSheet] -/// for more details). -/// -/// The [transitionAnimationController] controls the bottom sheet's entrance and -/// exit animations if provided. -/// -/// The optional `routeSettings` parameter sets the [RouteSettings] -/// of the modal bottom sheet sheet. -/// This is particularly useful in the case that a user wants to observe -/// [PopupRoute]s within a [NavigatorObserver]. -/// -/// Returns a `Future` that resolves to the value (if any) that was passed to -/// [Navigator.pop] when the modal bottom sheet was closed. -/// -/// See also: -/// -/// * [BottomSheet], which becomes the parent of the widget returned by the -/// function passed as the `builder` argument to [showModalBottomSheet]. -/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing -/// non-modal bottom sheets. -/// * [DraggableScrollableSheet], which allows you to create a bottom sheet -/// that grows and then becomes scrollable once it reaches its maximum size. -/// * -Future showChannelInfoModalBottomSheet({ - required BuildContext context, - required Channel channel, - Color? backgroundColor, - double? elevation, - BoxConstraints? constraints, - Color? barrierColor, - bool isScrollControlled = true, - bool useRootNavigator = false, - bool isDismissible = true, - bool enableDrag = true, - RouteSettings? routeSettings, - AnimationController? transitionAnimationController, - Clip? clipBehavior = Clip.hardEdge, - ShapeBorder? shape = _kDefaultChannelInfoBottomSheetShape, - void Function(Member)? onMemberTap, - VoidCallback? onViewInfoTap, - VoidCallback? onLeaveChannelTap, - VoidCallback? onDeleteConversationTap, - VoidCallback? onCancelTap, -}) => showModalBottomSheet( - context: context, - backgroundColor: backgroundColor, - elevation: elevation, - shape: shape, - clipBehavior: clipBehavior, - constraints: constraints, - barrierColor: barrierColor, - isScrollControlled: isScrollControlled, - useRootNavigator: useRootNavigator, - isDismissible: isDismissible, - enableDrag: enableDrag, - routeSettings: routeSettings, - transitionAnimationController: transitionAnimationController, - builder: (BuildContext context) => StreamChannelInfoBottomSheet( - channel: channel, - onMemberTap: onMemberTap, - onViewInfoTap: onViewInfoTap, - onLeaveChannelTap: onLeaveChannelTap, - onDeleteConversationTap: onDeleteConversationTap, - onCancelTap: onCancelTap, - ), -); - -/// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If -/// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet]. -/// -/// Returns a controller that can be used to close and otherwise manipulate the -/// bottom sheet. -/// -/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], -/// [constraints] and [transitionAnimationController] -/// parameters can be passed in to customize the appearance and behavior of -/// persistent bottom sheets (see the documentation for these on [BottomSheet] -/// for more details). -/// -/// To rebuild the bottom sheet (e.g. if it is stateful), call -/// [PersistentBottomSheetController.setState] on the controller returned by -/// this method. -/// -/// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing -/// [ModalRoute] and a back button is added to the app bar of the [Scaffold] -/// that closes the bottom sheet. -/// -/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and -/// does not add a back button to the enclosing Scaffold's app bar, use the -/// [Scaffold.bottomSheet] constructor parameter. -/// -/// A closely related widget is a modal bottom sheet, which is an alternative -/// to a menu or a dialog and prevents the user from interacting with the rest -/// of the app. Modal bottom sheets can be created and displayed with the -/// [showModalBottomSheet] function. -/// -/// The `context` argument is used to look up the [Scaffold] for the bottom -/// sheet. It is only used when the method is called. Its corresponding widget -/// can be safely removed from the tree before the bottom sheet is closed. -/// -/// See also: -/// -/// * [BottomSheet], which becomes the parent of the widget returned by the -/// `builder`. -/// * [showModalBottomSheet], which can be used to display a modal bottom -/// sheet. -/// * [Scaffold.of], for information about how to obtain the [BuildContext]. -/// * -PersistentBottomSheetController showChannelInfoBottomSheet({ - required BuildContext context, - required Channel channel, - Color? backgroundColor, - double? elevation, - BoxConstraints? constraints, - AnimationController? transitionAnimationController, - Clip? clipBehavior = Clip.hardEdge, - ShapeBorder? shape = _kDefaultChannelInfoBottomSheetShape, - void Function(Member)? onMemberTap, - VoidCallback? onViewInfoTap, - VoidCallback? onLeaveChannelTap, - VoidCallback? onDeleteConversationTap, - VoidCallback? onCancelTap, -}) => showBottomSheet( - context: context, - backgroundColor: backgroundColor, - elevation: elevation, - shape: shape, - clipBehavior: clipBehavior, - constraints: constraints, - transitionAnimationController: transitionAnimationController, - builder: (BuildContext context) => StreamChannelInfoBottomSheet( - channel: channel, - onMemberTap: onMemberTap, - onViewInfoTap: onViewInfoTap, - onLeaveChannelTap: onLeaveChannelTap, - onDeleteConversationTap: onDeleteConversationTap, - onCancelTap: onCancelTap, - ), -); diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart index a3f173d8e2..67648e1291 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -1,158 +1,155 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamChannelHeader} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header_paint.png) +/// A top-of-screen header for a single channel. /// -/// Shows information about the current [Channel]. +/// [StreamChannelHeader] renders a [StreamAppBar] whose default title is +/// the channel's name (via [StreamChannelName]) and whose default subtitle +/// is the channel's typing / member status (via [StreamChannelInfo]). /// -/// ```dart -/// class MyApp extends StatelessWidget { -/// final StreamChatClient client; -/// final Channel channel; +/// The default leading is a [StreamBackButton] that pops the route when +/// tapped, gated by [automaticallyImplyLeading] — set it to `false` to +/// suppress the default, or pass [leading] to replace it entirely. +/// +/// The default trailing is the channel avatar (via [StreamChannelAvatar]). +/// Tap behaviour is wired through [onChannelAvatarPressed]; when the +/// callback is null the avatar is rendered non-interactive. Pass [trailing] +/// to replace the avatar with a custom action — the callback is then +/// ignored. +/// +/// A [StreamChannel] ancestor is required so the title and subtitle can +/// observe the channel's stream of updates. When [showConnectionStateTile] +/// is true, a [StreamInfoTile] banner is rendered above the bar while the +/// client is reconnecting or offline. /// -/// MyApp(this.client, this.channel); +/// [StreamChannelHeader] implements [PreferredSizeWidget] so it can be +/// passed directly to [Scaffold.appBar]. /// -/// @override -/// Widget build(BuildContext context) { -/// return MaterialApp( -/// home: StreamChat( -/// client: client, -/// child: StreamChannel( -/// channel: channel, -/// child: Scaffold( -/// appBar: ChannelHeader(), -/// ), -/// ), -/// ), -/// ); -/// } -/// } +/// {@tool snippet} +/// +/// Basic usage as a [Scaffold.appBar] — the back button and channel +/// avatar are auto-populated from the enclosing [StreamChannel]: +/// +/// ```dart +/// Scaffold( +/// appBar: const StreamChannelHeader(), +/// body: const StreamMessageListView(), +/// ) /// ``` +/// {@end-tool} /// -/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. -/// However, you can also use it as a normal widget. +/// {@tool snippet} /// -/// Make sure to have a [StreamChannel] ancestor in order to provide the -/// information about the channel. +/// With a tap handler that opens a channel-detail screen: /// -/// Every part of the widget uses a [StreamBuilder] to render the channel -/// information as soon as it updates. +/// ```dart +/// StreamChannelHeader( +/// onChannelAvatarPressed: (channel) => GoRouter.of(context).pushNamed( +/// 'channel-detail', +/// extra: channel, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming /// -/// By default the widget shows a backButton that calls [Navigator.pop]. -/// You can disable this button using the [showBackButton] property. -/// Alternatively, you can override this behaviour via the [onBackPressed] -/// callback. +/// [StreamChannelHeader] reads its chrome (background, padding, typography, +/// divider) from [StreamChatThemeData.channelHeaderTheme], which is a +/// [StreamAppBarThemeData]. Per-instance overrides go on [style]. /// -/// The UI is rendered based on the first ancestor of type [StreamChatTheme] -/// and the [StreamChatThemeData.channelHeaderTheme] property. Modify it to -/// change the widget's appearance. +/// See also: +/// +/// * [StreamAppBar], the underlying app bar component. +/// * [StreamAppBarThemeData], for customizing appearance globally. +/// * [StreamChannelListHeader], the equivalent header for the channel +/// list. +/// * [StreamThreadHeader], the equivalent header for a thread. /// {@endtemplate} class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamChannelHeader} const StreamChannelHeader({ super.key, - this.showBackButton = true, - this.onBackPressed, - this.onTitleTap, - this.showTypingIndicator = true, - this.onImageTap, + this.onChannelAvatarPressed, this.showConnectionStateTile = false, + this.leading, + this.automaticallyImplyLeading = true, this.title, this.subtitle, - this.centerTitle = true, - this.leading, - this.actions, - this.bottom, - this.backgroundColor, - this.elevation = 0, - this.scrolledUnderElevation = 0, - this.bottomOpacity = 1, + this.trailing, + this.primary = true, + this.style, }); - /// Whether to show the leading back button - /// - /// Defaults to `true` - final bool showBackButton; - - /// The action to perform when the back button is pressed. + /// Called when the default channel-avatar trailing is pressed. /// - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; + /// Ignored when [trailing] is provided. When null, the avatar is rendered + /// non-interactive. + final void Function(Channel channel)? onChannelAvatarPressed; - /// The action to perform when the header is tapped. - final VoidCallback? onTitleTap; - - /// The action to perform when the image is tapped. - final VoidCallback? onImageTap; + /// Whether to show the connection-state banner above the bar. + final bool showConnectionStateTile; - /// Whether to show the typing indicator + /// {@macro StreamAppBar.leading} /// - /// Defaults to `true` - final bool showTypingIndicator; + /// Defaults to a [StreamBackButton] when [automaticallyImplyLeading] is + /// `true`. + final Widget? leading; - /// Whether to show the connection state tile - final bool showConnectionStateTile; + /// Whether to render a default [StreamBackButton] as the leading when + /// [leading] is null. + /// + /// Defaults to `true`. Set to `false` to suppress the back button. + final bool automaticallyImplyLeading; - /// Title widget + /// {@macro StreamAppBar.title} + /// + /// Defaults to a [StreamChannelName] for the enclosing channel. final Widget? title; - /// Subtitle widget + /// {@macro StreamAppBar.subtitle} + /// + /// Defaults to a [StreamChannelInfo] showing typing / member status. final Widget? subtitle; - /// Whether the title should be centered - final bool centerTitle; - - /// Leading widget - final Widget? leading; - - /// The bottom widget - final PreferredSizeWidget? bottom; - - /// {@macro flutter.material.appbar.actions} + /// {@macro StreamAppBar.trailing} /// - /// The [StreamChannelAvatar] is shown by default - final List? actions; + /// Defaults to a [StreamChannelAvatar] for the enclosing channel wired to + /// [onChannelAvatarPressed]. + final Widget? trailing; - /// The background color for this [StreamChannelHeader]. - final Color? backgroundColor; + /// {@macro StreamAppBar.primary} + final bool primary; - /// The elevation for this [StreamChannelHeader]. - final double elevation; - - /// The scrolled under elevation for this [StreamChannelHeader]. - final double scrolledUnderElevation; - - /// The opacity of the bottom widget. - final double bottomOpacity; + /// {@macro StreamAppBar.style} + /// + /// Per-instance override; merges over + /// [StreamChatThemeData.channelHeaderTheme]. + final StreamAppBarStyle? style; @override - Size get preferredSize { - final bottomHeight = bottom?.preferredSize.height ?? 0; - return Size.fromHeight(kToolbarHeight + bottomHeight); - } + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); @override Widget build(BuildContext context) { - final effectiveCenterTitle = getEffectiveCenterTitle( - Theme.of(context), - actions: actions, - centerTitle: centerTitle, - ); final channel = StreamChannel.of(context).channel; - final channelHeaderTheme = StreamChannelHeaderTheme.of(context); + final headerTheme = StreamChatTheme.of(context).channelHeaderTheme; + + var leading = this.leading; + if (leading == null && automaticallyImplyLeading) { + leading = const StreamBackButton(showUnreadCount: true); + } + + var title = this.title; + title ??= StreamChannelName(channel: channel); + + var subtitle = this.subtitle; + subtitle ??= StreamChannelInfo(channel: channel); - final leadingWidget = - leading ?? - (showBackButton - ? StreamBackButton( - onPressed: onBackPressed, - showUnreadCount: true, - ) - : const SizedBox()); + var trailing = this.trailing; + trailing ??= _DefaultChannelAvatar(channel: channel, onPressed: onChannelAvatarPressed); return Portal( child: StreamConnectionStatusBuilder( @@ -176,85 +173,20 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget return StreamInfoTile( showMessage: showConnectionStateTile && showStatus, message: statusString, - child: StreamAppBar( - titleTextStyle: Theme.of(context).textTheme.titleLarge, - elevation: elevation, - scrolledUnderElevation: scrolledUnderElevation, - leading: Padding( - padding: .directional(start: context.streamSpacing.sm), - child: Center(child: leadingWidget), - ), - titleSpacing: context.streamSpacing.sm, - bottom: bottom, - bottomOpacity: bottomOpacity, - backgroundColor: backgroundColor ?? channelHeaderTheme.color, - actions: - actions ?? - [ - if (effectiveCenterTitle) - Padding( - padding: .directional(end: context.streamSpacing.sm), - child: Center( - child: GestureDetector( - onTap: onImageTap, - child: StreamChannelAvatar( - size: .lg, - channel: channel, - ), - ), - ), - ), - ], - centerTitle: centerTitle, - title: Row( - spacing: context.streamSpacing.sm, - children: [ - if (!effectiveCenterTitle) ...[ - GestureDetector( - onTap: onImageTap, - child: StreamChannelAvatar( - size: .lg, - channel: channel, - ), - ), - ], - Expanded( - child: InkWell( - onTap: onTitleTap, - child: SizedBox( - height: preferredSize.height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: effectiveCenterTitle - ? CrossAxisAlignment.center - : CrossAxisAlignment.stretch, - children: [ - title ?? - StreamChannelName( - channel: channel, - textStyle: - channelHeaderTheme.titleStyle ?? - context.streamTextTheme.headingSm.copyWith( - color: context.streamColorScheme.textPrimary, - ), - ), - const SizedBox(height: 2), - subtitle ?? - StreamChannelInfo( - showTypingIndicator: showTypingIndicator, - channel: channel, - textStyle: - channelHeaderTheme.subtitleStyle ?? - context.streamTextTheme.captionDefault.copyWith( - color: context.streamColorScheme.textSecondary, - ), - ), - ], - ), - ), - ), - ), - ], + // Wrap the bar in a [StreamAppBarTheme] so the per-header chat + // theme drives all default styling (background, padding, + // typography, divider) — the bar internally merges in any + // [style] override the caller passed. + child: StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + automaticallyImplyLeading: false, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, ), ), ); @@ -263,3 +195,35 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget ); } } + +class _DefaultChannelAvatar extends StatelessWidget { + const _DefaultChannelAvatar({required this.channel, this.onPressed}); + + final Channel channel; + final void Function(Channel channel)? onPressed; + + @override + Widget build(BuildContext context) { + final effectiveOnTap = switch (onPressed) { + final cb? => () => cb(channel), + _ => null, + }; + + // Match the 48×48 tap target StreamAppBar's auto-implied leading uses + // (StreamButton.icon medium = 40 visible + Material padded tap target), + // so the avatar slot sizes and hit-tests consistently with other bars. + return SizedBox.square( + dimension: 48, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: effectiveOnTap, + child: Center( + child: StreamChannelAvatar( + size: .lg, + channel: channel, + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart index 61e2a52e0d..cd09b1f835 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart @@ -1,114 +1,132 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamChannelListHeader} -/// Shows the current [StreamChatClient] status. +/// A top-of-screen header for the channel list, surfacing the current +/// [StreamChatClient] connection status. +/// +/// [StreamChannelListHeader] renders a [StreamAppBar] whose default title +/// reflects the connection state — _Stream Chat_ when connected, a +/// loading spinner + _Searching for network…_ when connecting, and an +/// _Offline_ label with a _try again_ affordance when disconnected. +/// +/// The leading slot is always the signed-in user's avatar. Tap behaviour +/// is wired through [onUserAvatarPressed]; when the callback is null the +/// avatar mirrors Material [AppBar]'s auto-implied leading by opening the +/// enclosing [Scaffold]'s drawer if one exists, and is otherwise rendered +/// non-interactive. +/// +/// The trailing slot is empty by default — pass [trailing] to wire up an +/// action such as a _new chat_ button. +/// +/// When [showConnectionStateTile] is true, a [StreamInfoTile] banner is +/// rendered above the bar while the client is reconnecting or offline. +/// +/// [StreamChannelListHeader] implements [PreferredSizeWidget] so it can +/// be passed directly to [Scaffold.appBar]. +/// +/// {@tool snippet} +/// +/// Basic usage as a [Scaffold.appBar] — the avatar opens the [Scaffold]'s +/// drawer automatically when one is provided: /// /// ```dart -/// class MyApp extends StatelessWidget { -/// final StreamChatClient client; +/// Scaffold( +/// appBar: const StreamChannelListHeader(), +/// drawer: MyDrawer(user: currentUser), +/// body: const StreamChannelListView(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} /// -/// MyApp(this.client); +/// With a custom avatar tap and a trailing _new chat_ button: /// -/// @override -/// Widget build(BuildContext context) { -/// return MaterialApp( -/// home: StreamChat( -/// client: client, -/// child: Scaffold( -/// appBar: ChannelListHeader(), -/// ), -/// ), -/// ); -/// } -/// } +/// ```dart +/// StreamChannelListHeader( +/// onUserAvatarPressed: (user) => showProfile(context, user), +/// trailing: StreamButton.icon( +/// icon: Icon(context.streamIcons.plus), +/// onPressed: () => GoRouter.of(context).pushNamed('new-chat'), +/// ), +/// ) /// ``` +/// {@end-tool} +/// +/// ## Theming /// -/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. -/// However, you can also use it as a normal widget. +/// [StreamChannelListHeader] reads its chrome (background, padding, +/// typography, divider) from [StreamChatThemeData.channelListHeaderTheme], +/// which is a [StreamAppBarThemeData]. Per-instance overrides go on +/// [style]. /// -/// Uses the inherited [StreamChatClient], by default, to fetch information -/// about the status of the [client]. You can also pass your own -/// [StreamChatClient] if you don't have it in the widget tree. +/// See also: /// -/// Renders the UI based on the first ancestor of type [StreamChatTheme] and -/// the [StreamChannelListHeaderThemeData] property. Modify it to change the -/// widget's appearance. +/// * [StreamAppBar], the underlying app bar component. +/// * [StreamAppBarThemeData], for customizing appearance globally. +/// * [StreamChannelHeader], the equivalent header for a single channel. /// {@endtemplate} class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamChannelListHeader} const StreamChannelListHeader({ super.key, this.client, - this.titleBuilder, - this.onUserAvatarTap, - this.onNewChatButtonTap, + this.onUserAvatarPressed, this.showConnectionStateTile = false, - this.preNavigationCallback, + this.title, this.subtitle, - this.centerTitle = true, - this.leading, - this.actions, - this.backgroundColor, - this.elevation = 0, - this.scrolledUnderElevation = 0, + this.trailing, + this.primary = true, + this.style, }); /// Use this if you don't have a [StreamChatClient] in your widget tree. final StreamChatClient? client; - /// {@macro channelListHeaderTitleBuilder} - final ChannelListHeaderTitleBuilder? titleBuilder; - - /// The action to perform when pressing the user avatar button. + /// Called when the user-avatar leading is pressed. /// - /// By default it calls `Scaffold.of(context).openDrawer()`. - final Function(User)? onUserAvatarTap; + /// When null, the avatar opens the enclosing [Scaffold]'s drawer if one + /// exists (matching Material [AppBar]); otherwise it's rendered + /// non-interactive. + final void Function(User user)? onUserAvatarPressed; - /// The action to perform when pressing the "new chat" button. - final VoidCallback? onNewChatButtonTap; - - /// Whether to show the connection state tile + /// Whether to show the connection-state banner above the bar. final bool showConnectionStateTile; - /// The function to execute before navigation is performed - final VoidCallback? preNavigationCallback; + /// {@macro StreamAppBar.title} + /// + /// Defaults to a connection-state-aware title — see the class docs. + final Widget? title; - /// Subtitle widget + /// {@macro StreamAppBar.subtitle} final Widget? subtitle; - /// Whether the title should be centered - final bool centerTitle; - - /// Leading widget + /// {@macro StreamAppBar.trailing} /// - /// By default it shows the logged in user's avatar - final Widget? leading; - - /// {@macro flutter.material.appbar.actions} - /// - /// The "new chat" button is shown by default. - final List? actions; - - /// The background color for this [StreamChannelListHeader]. - final Color? backgroundColor; + /// No default — pass a widget to wire up an action. + final Widget? trailing; - /// The elevation for this [StreamChannelListHeader]. - final double elevation; + /// {@macro StreamAppBar.primary} + final bool primary; - /// The scrolled under elevation for this [StreamChannelListHeader]. - final double scrolledUnderElevation; + /// {@macro StreamAppBar.style} + /// + /// Per-instance override; merges over + /// [StreamChatThemeData.channelListHeaderTheme]. + final StreamAppBarStyle? style; @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); @override Widget build(BuildContext context) { final _client = client ?? StreamChat.of(context).client; - final user = _client.state.currentUser; + final headerTheme = StreamChatTheme.of(context).channelListHeaderTheme; + + final leading = _DefaultUserAvatar(client: _client, onPressed: onUserAvatarPressed); + return Portal( child: StreamConnectionStatusBuilder( statusBuilder: (context, status) { @@ -128,77 +146,29 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi break; } - final channelListHeaderThemeData = StreamChannelListHeaderTheme.of(context); + final title = + this.title ?? + switch (status) { + ConnectionStatus.connected => _ConnectedTitleState(), + ConnectionStatus.connecting => _ConnectingTitleState(), + ConnectionStatus.disconnected => _DisconnectedTitleState(client: _client), + }; return StreamInfoTile( showMessage: showConnectionStateTile && showStatus, message: statusString, - child: StreamAppBar( - elevation: elevation, - scrolledUnderElevation: scrolledUnderElevation, - backgroundColor: backgroundColor ?? channelListHeaderThemeData.color, - centerTitle: centerTitle, - leading: switch ((leading, user)) { - (final leading?, _) => leading, - (_, final user?) => Padding( - padding: .directional(start: context.streamSpacing.sm), - child: Center( - child: GestureDetector( - onTap: switch (onUserAvatarTap) { - final onTap? => () => onTap(user), - _ => () { - preNavigationCallback?.call(); - Scaffold.of(context).openDrawer(); - }, - }, - child: StreamUserAvatar( - size: .lg, - user: user, - showOnlineIndicator: false, - ), - ), - ), - ), - _ => const Empty(), - }, - actionsPadding: .directional(end: context.streamSpacing.sm), - actions: - actions ?? - [ - StreamConnectionStatusBuilder( - statusBuilder: (context, status) { - final callback = switch (status) { - ConnectionStatus.connected => onNewChatButtonTap, - ConnectionStatus.connecting => null, - ConnectionStatus.disconnected => null, - }; - - return StreamButton.icon( - icon: Icon(context.streamIcons.plus), - onPressed: callback, - ); - }, - ), - ], - title: Column( - children: [ - Builder( - builder: (context) { - if (titleBuilder != null) { - return titleBuilder!(context, status, _client); - } - switch (status) { - case ConnectionStatus.connected: - return _ConnectedTitleState(); - case ConnectionStatus.connecting: - return _ConnectingTitleState(); - case ConnectionStatus.disconnected: - return _DisconnectedTitleState(client: _client); - } - }, - ), - subtitle ?? const Empty(), - ], + // Wrap the bar in a [StreamAppBarTheme] so the per-header chat + // theme drives all default styling — the bar internally merges + // in any [style] override the caller passed. + child: StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, ), ), ); @@ -208,13 +178,52 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi } } +class _DefaultUserAvatar extends StatelessWidget { + const _DefaultUserAvatar({required this.client, this.onPressed}); + + final StreamChatClient client; + final void Function(User user)? onPressed; + + @override + Widget build(BuildContext context) { + final user = client.state.currentUser; + if (user == null) return const SizedBox.shrink(); + + // Caller-provided handler wins; otherwise mirror Material AppBar and + // open the enclosing Scaffold's drawer if one exists. With no callback + // and no drawer, the avatar is non-interactive. + final scaffold = Scaffold.maybeOf(context); + final effectiveOnTap = switch (onPressed) { + final cb? => () => cb(user), + _ => scaffold?.openDrawer, + }; + + // Match the 48×48 tap target StreamAppBar's auto-implied leading uses + // (StreamButton.icon medium = 40 visible + Material padded tap target), + // so the avatar slot sizes and hit-tests consistently with other bars. + return SizedBox.square( + dimension: 48, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: effectiveOnTap, + child: Center( + child: StreamUserAvatar( + size: .lg, + user: user, + showOnlineIndicator: false, + ), + ), + ), + ); + } +} + class _ConnectedTitleState extends StatelessWidget { @override Widget build(BuildContext context) { - final textTheme = context.streamTextTheme; return Text( context.translations.streamChatLabel, - style: textTheme.headingSm, + style: context.streamTextTheme.headingSm, ); } } @@ -222,23 +231,17 @@ class _ConnectedTitleState extends StatelessWidget { class _ConnectingTitleState extends StatelessWidget { @override Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; return Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( - height: 16, - width: 16, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ), + StreamLoadingSpinner(size: .sm), const SizedBox(width: 10), Text( context.translations.searchingForNetworkText, - style: StreamChannelListHeaderTheme.of(context).titleStyle?.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), ), ], ); @@ -246,36 +249,28 @@ class _ConnectingTitleState extends StatelessWidget { } class _DisconnectedTitleState extends StatelessWidget { - const _DisconnectedTitleState({ - required this.client, - }); + const _DisconnectedTitleState({required this.client}); final StreamChatClient client; @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - final channelListHeaderTheme = StreamChannelListHeaderTheme.of(context); + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; return Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Text( context.translations.offlineLabel, - style: channelListHeaderTheme.titleStyle?.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), ), - TextButton( + StreamButton( + type: .ghost, + style: .primary, + size: .small, onPressed: client.maybeReconnect, - child: Text( - context.translations.tryAgainLabel, - style: channelListHeaderTheme.titleStyle?.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - color: chatThemeData.colorTheme.accentPrimary, - ), - ), + child: Text(context.translations.tryAgainLabel), ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart index b9c72ceb45..266e9086b5 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart @@ -128,17 +128,16 @@ class _FullScreenMediaState extends State { return AnimatedPositionedDirectional( duration: kThemeAnimationDuration, curve: Curves.easeInOut, - top: isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight), + top: isDisplayingDetail ? 0 : -(topPadding + kStreamHeaderHeight), start: 0, end: 0, - height: topPadding + kToolbarHeight, + height: topPadding + kStreamHeaderHeight, child: StreamGalleryHeader( userName: widget.userName, sentAt: context.translations.sentAtText( date: _currentAttachmentPackage.message.createdAt, time: _currentAttachmentPackage.message.createdAt, ), - onBackPressed: Navigator.of(context).pop, message: _currentMessage, attachment: _currentAttachment, onShowMessage: widget.onShowMessage != null @@ -174,10 +173,10 @@ class _FullScreenMediaState extends State { return AnimatedPositionedDirectional( duration: kThemeAnimationDuration, curve: Curves.easeInOut, - bottom: isDisplayingDetail ? 0 : -(bottomPadding + kToolbarHeight), + bottom: isDisplayingDetail ? 0 : -(bottomPadding + kStreamHeaderHeight), start: 0, end: 0, - height: bottomPadding + kToolbarHeight, + height: bottomPadding + kStreamHeaderHeight, child: StreamGalleryFooter( currentPage: currentPage, totalPages: widget.mediaAttachmentPackages.length, @@ -276,12 +275,12 @@ class _FullScreenMediaState extends State { return AnimatedContainer( duration: kThemeChangeDuration, color: switch (isDisplayingDetail) { - true => StreamChannelHeaderTheme.of(context).color, - false => Colors.black, + true => context.streamColorScheme.backgroundApp, + false => StreamColors.black, }, padding: EdgeInsetsDirectional.only( - top: padding.top + kToolbarHeight, - bottom: padding.bottom + kToolbarHeight, + top: padding.top + kStreamHeaderHeight, + bottom: padding.bottom + kStreamHeaderHeight, ), child: child, ); diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart index 5092d1d14e..2cf3b811e2 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart @@ -177,17 +177,16 @@ class _FullScreenMediaDesktopState extends State { return AnimatedPositionedDirectional( duration: kThemeAnimationDuration, curve: Curves.easeInOut, - top: isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight), + top: isDisplayingDetail ? 0 : -(topPadding + kStreamHeaderHeight), start: 0, end: 0, - height: topPadding + kToolbarHeight, + height: topPadding + kStreamHeaderHeight, child: StreamGalleryHeader( userName: widget.userName, sentAt: context.translations.sentAtText( date: _currentAttachmentPackage.message.createdAt, time: _currentAttachmentPackage.message.createdAt, ), - onBackPressed: Navigator.of(context).pop, message: _currentMessage, attachment: _currentAttachment, onShowMessage: () { @@ -210,10 +209,10 @@ class _FullScreenMediaDesktopState extends State { return AnimatedPositionedDirectional( duration: kThemeAnimationDuration, curve: Curves.easeInOut, - bottom: isDisplayingDetail ? 0 : -(bottomPadding + kToolbarHeight), + bottom: isDisplayingDetail ? 0 : -(bottomPadding + kStreamHeaderHeight), start: 0, end: 0, - height: bottomPadding + kToolbarHeight, + height: bottomPadding + kStreamHeaderHeight, child: StreamGalleryFooter( currentPage: currentPage, totalPages: widget.mediaAttachmentPackages.length, @@ -313,12 +312,12 @@ class _FullScreenMediaDesktopState extends State { return AnimatedContainer( duration: kThemeChangeDuration, color: switch (isDisplayingDetail) { - true => StreamChannelHeaderTheme.of(context).color, - false => Colors.black, + true => context.streamColorScheme.backgroundApp, + false => StreamColors.black, }, padding: EdgeInsetsDirectional.only( - top: padding.top + kToolbarHeight, - bottom: padding.bottom + kToolbarHeight, + top: padding.top + kStreamHeaderHeight, + bottom: padding.bottom + kStreamHeaderHeight, ), child: child, ); diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart index 7d3a06a7d7..77dc4212b3 100644 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart +++ b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart @@ -21,7 +21,7 @@ class StreamGalleryFooter extends StatefulWidget implements PreferredSizeWidget required this.mediaAttachmentPackages, this.mediaSelectedCallBack, this.backgroundColor, - }) : preferredSize = const Size.fromHeight(kToolbarHeight); + }); /// Callback to call when pressing the back button. /// By default it calls [Navigator.pop] @@ -52,7 +52,7 @@ class StreamGalleryFooter extends StatefulWidget implements PreferredSizeWidget _StreamGalleryFooterState createState() => _StreamGalleryFooterState(); @override - final Size preferredSize; + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); } class _StreamGalleryFooterState extends State { diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart index ec87a14b9a..ff2fa5c712 100644 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart +++ b/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment_actions_modal/attachment_actions_modal.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/theme/themes.dart'; -import 'package:stream_chat_flutter/src/utils/utils.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamGalleryHeader} -/// Header/AppBar widget for media display screen +/// Header bar for the gallery / media display screen. +/// +/// Renders a [StreamAppBar] whose default title is the sender's [userName] +/// and whose default subtitle is the [sentAt] timestamp. The default +/// trailing action opens an [AttachmentActionsModal] for the focused +/// [attachment]. +/// +/// The bar's chrome (background, padding, typography, divider) is themed via +/// `StreamChatThemeData.galleryHeaderTheme`. Per-instance overrides go on +/// [style]. /// {@endtemplate} class StreamGalleryHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamGalleryHeader} @@ -16,27 +19,19 @@ class StreamGalleryHeader extends StatelessWidget implements PreferredSizeWidget super.key, required this.message, required this.attachment, - this.showBackButton = true, - this.onBackPressed, this.onShowMessage, this.onReplyMessage, - this.onTitleTap, - this.onImageTap, this.userName = '', this.sentAt = '', - this.backgroundColor, this.attachmentActionsModalBuilder, - this.elevation = 1.0, - }) : preferredSize = const Size.fromHeight(kToolbarHeight); - - /// Whether to show the leading back button. - /// - /// Defaults to `true`. - final bool showBackButton; - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.subtitle, + this.trailing, + this.primary = true, + this.style, + }); /// Callback to call when pressing the show message button. final VoidCallback? onShowMessage; @@ -44,100 +39,97 @@ class StreamGalleryHeader extends StatelessWidget implements PreferredSizeWidget /// Callback to call when pressing the reply message button. final VoidCallback? onReplyMessage; - /// Callback to call when the header is tapped. - final VoidCallback? onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback? onImageTap; - - /// Message which attachments are attached to + /// Message which attachments are attached to. final Message message; - /// The attachment that's currently in focus + /// The attachment that's currently in focus. final Attachment attachment; - /// Username of sender + /// Username of sender. final String userName; - /// Text which connotes the time the message was sent + /// Text which connotes the time the message was sent. final String sentAt; - /// The background color of this [StreamGalleryHeader]. - final Color? backgroundColor; - /// {@macro attachmentActionsBuilder} final AttachmentActionsBuilder? attachmentActionsModalBuilder; - /// The elevation of this [StreamGalleryHeader]. + /// {@macro StreamAppBar.leading} + final Widget? leading; + + /// {@macro StreamAppBar.automaticallyImplyLeading} + final bool automaticallyImplyLeading; + + /// {@macro StreamAppBar.title} + /// + /// Defaults to a [Text] showing [userName]. + final Widget? title; + + /// {@macro StreamAppBar.subtitle} + /// + /// Defaults to a [Text] showing [sentAt]. + final Widget? subtitle; + + /// {@macro StreamAppBar.trailing} + /// + /// Defaults to an icon button that opens the attachment actions modal. + final Widget? trailing; + + /// {@macro StreamAppBar.primary} + final bool primary; + + /// {@macro StreamAppBar.style} /// - /// Defaults to `1.0`. When used for desktop & web platforms, it should - /// be set to `0.0`. - final double elevation; + /// Per-instance override; merges over + /// `StreamChatThemeData.galleryHeaderTheme`. + final StreamAppBarStyle? style; + + @override + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); @override Widget build(BuildContext context) { - final galleryHeaderThemeData = StreamGalleryHeaderTheme.of(context); - final textTheme = context.streamTextTheme; - - return StreamAppBar( - elevation: elevation, - leading: showBackButton - ? IconButton( - icon: Icon( - context.streamIcons.arrowLeft, - color: galleryHeaderThemeData.closeButtonColor, - size: 20, - ), - onPressed: onBackPressed, - ) - : const Empty(), - surfaceTintColor: backgroundColor ?? galleryHeaderThemeData.backgroundColor, - backgroundColor: backgroundColor ?? galleryHeaderThemeData.backgroundColor, - actions: [ - if (!message.isEphemeral) - IconButton( - icon: Icon( - context.streamIcons.more, - color: galleryHeaderThemeData.iconMenuPointColor, - ), - onPressed: () => _showMessageActionModalBottomSheet(context), - ), - ], - centerTitle: true, - title: !message.isEphemeral - ? InkWell( - onTap: onTitleTap, - child: SizedBox( - height: preferredSize.height, - width: preferredSize.width, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - userName, - style: galleryHeaderThemeData.titleTextStyle ?? textTheme.headingSm, - ), - Text( - sentAt, - style: - galleryHeaderThemeData.subtitleTextStyle ?? - textTheme.captionDefault.copyWith(color: context.streamColorScheme.textSecondary), - ), - ], - ), - ), - ) - : const Empty(), + final headerTheme = StreamChatTheme.of(context).galleryHeaderTheme; + + // Ephemeral messages (e.g. previews of unsent attachments) don't have + // sender / timestamp metadata to surface, so the title / subtitle / + // overflow-action defaults are suppressed — caller-provided slots + // still flow through. + var title = this.title; + if (title == null && !message.isEphemeral) { + title = Text(userName); + } + + var subtitle = this.subtitle; + if (subtitle == null && !message.isEphemeral) { + subtitle = Text(sentAt); + } + + var trailing = this.trailing; + if (trailing == null && !message.isEphemeral) { + trailing = IconButton( + icon: Icon(context.streamIcons.more), + onPressed: () => _showMessageActionModalBottomSheet(context), + ); + } + + return StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, + ), ); } - @override - final Size preferredSize; - Future _showMessageActionModalBottomSheet(BuildContext context) async { final channel = StreamChannel.of(context).channel; - final galleryHeaderThemeData = StreamChatTheme.of(context).galleryHeaderTheme; + final colorTheme = StreamChatTheme.of(context).colorTheme; final defaultModal = AttachmentActionsModal( attachment: attachment, @@ -157,7 +149,7 @@ class StreamGalleryHeader extends StatelessWidget implements PreferredSizeWidget final result = await showDialog( useRootNavigator: false, context: context, - barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor, + barrierColor: colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, child: effectiveModal, diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index bde5e382f3..e531537c4f 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -701,6 +701,10 @@ abstract class Translations { /// The label hint shown next to the viewer's own reaction indicating the /// reaction can be tapped to remove it. String get tapToRemoveReactionLabel; + + /// The header text for the reaction detail sheet showing the count of + /// visible reactions (e.g. "1 Reaction" / "5 Reactions"). + String reactionsCountText(int count); } /// Default implementation of Translation strings for the stream chat widgets @@ -1508,4 +1512,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get tapToRemoveReactionLabel => 'Tap to remove'; + + @override + String reactionsCountText(int count) => count == 1 ? '1 Reaction' : '$count Reactions'; } diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/error_alert_sheet.dart b/packages/stream_chat_flutter/lib/src/message_input/error_alert_sheet.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/bottom_sheets/error_alert_sheet.dart rename to packages/stream_chat_flutter/lib/src/message_input/error_alert_sheet.dart diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index 2ee55e31f8..6eb976abcd 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/src/message_input/error_alert_sheet.dart'; import 'package:stream_chat_flutter/src/message_input/tld.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; diff --git a/packages/stream_chat_flutter/lib/src/misc/back_button.dart b/packages/stream_chat_flutter/lib/src/misc/back_button.dart index fd854b617b..70ca5453bd 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -24,7 +24,12 @@ class StreamBackButton extends StatelessWidget { @override Widget build(BuildContext context) { - Widget icon = Icon(context.streamIcons.arrowLeft); + final iconData = switch (Theme.of(context).platform) { + .iOS || .macOS => context.streamIcons.chevronLeft, + _ => context.streamIcons.arrowLeft, + }; + + Widget icon = Icon(iconData); if (showUnreadCount) { icon = switch (channelId) { final cid? => StreamUnreadIndicator.channels(cid: cid, child: icon), diff --git a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart index 73badc2f16..085bb664cf 100644 --- a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart @@ -1,206 +1,105 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamThreadHeader} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/thread_header.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/thread_header_paint.png) -/// /// Shows information about the current message thread. /// -/// ```dart -/// class ThreadPage extends StatelessWidget { -/// final Message parent; -/// -/// ThreadPage({ -/// Key key, -/// this.parent, -/// }) : super(key: key); -/// -/// @override -/// Widget build(BuildContext context) { -/// return Scaffold( -/// appBar: ThreadHeader( -/// parent: parent, -/// ), -/// body: Column( -/// children: [ -/// Expanded( -/// child: MessageListView( -/// parentMessage: parent, -/// ), -/// ), -/// MessageInput( -/// parentMessage: parent, -/// ), -/// ], -/// ), -/// ); -/// } -/// } -/// ``` -/// -/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. -/// However you can also use it as a normal widget. -/// -/// A [StreamChannel] ancestor is required in order to provide the -/// information about the channel. -/// -/// Every part of the widget uses a [StreamBuilder] to render the channel -/// information as soon as it updates. +/// Renders a [StreamAppBar] with the thread's reply label as the title and a +/// live typing indicator (or reply count) as the subtitle. Inherits the +/// [StreamAppBar] auto-implied back button — pass [leading] to override. /// -/// By default the widget shows a backButton that calls [Navigator.pop]. -/// You can disable this button using the [showBackButton] property. -/// Alternatively, you can override the behavior with [onBackPressed]. +/// The bar's chrome (background, padding, typography, divider) is themed via +/// [StreamChatThemeData.threadHeaderTheme]. Per-instance overrides go on +/// [style]. /// -/// The UI is rendered based on the first ancestor of type [StreamChatTheme] -/// and the [ChannelTheme.channelHeaderTheme] property. Modify it to change -/// the widget's appearance. +/// A [StreamChannel] ancestor is required so that the typing indicator can +/// observe the channel's typing events. /// {@endtemplate} class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamThreadHeader} const StreamThreadHeader({ super.key, required this.parent, - this.showBackButton = true, - this.onBackPressed, + this.leading, + this.automaticallyImplyLeading = true, this.title, this.subtitle, - this.centerTitle = true, - this.leading, - this.actions, - this.onTitleTap, - this.showTypingIndicator = true, - this.backgroundColor, - this.elevation = 0, - this.scrolledUnderElevation = 0, - }) : preferredSize = const Size.fromHeight(kToolbarHeight); - - /// Whether to show the leading back button. - /// - /// Defaults to `true`. - final bool showBackButton; + this.trailing, + this.primary = true, + this.style, + }); - /// The action to perform when pressing the back button. - /// - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; + /// The message parent of this thread. + final Message parent; - /// The action to perform when the title is tapped. - final VoidCallback? onTitleTap; + /// {@macro StreamAppBar.leading} + final Widget? leading; - /// The message parent of this thread - final Message parent; + /// {@macro StreamAppBar.automaticallyImplyLeading} + final bool automaticallyImplyLeading; - /// Title widget + /// {@macro StreamAppBar.title} + /// + /// Defaults to the localized "Thread reply" label. final Widget? title; - /// Subtitle widget + /// {@macro StreamAppBar.subtitle} + /// + /// Defaults to a live [StreamTypingIndicator] that falls back to the + /// thread's reply count when nobody is typing. final Widget? subtitle; - /// Whether the title should be centered - final bool centerTitle; - - /// Leading widget - final Widget? leading; + /// {@macro StreamAppBar.trailing} + final Widget? trailing; - /// {@macro flutter.material.appbar.actions} - final List? actions; + /// {@macro StreamAppBar.primary} + final bool primary; - /// Whether to show the typing indicator if users are currently typing. + /// {@macro StreamAppBar.style} /// - /// Defaults to `true`. - final bool showTypingIndicator; + /// Per-instance override; merges over + /// [StreamChatThemeData.threadHeaderTheme]. + final StreamAppBarStyle? style; - /// The background color of this [StreamThreadHeader]. - final Color? backgroundColor; - - /// The elevation for this [StreamThreadHeader]. - final double elevation; - - /// The scrolled under elevation for this [StreamThreadHeader]. - final double scrolledUnderElevation; + @override + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); @override Widget build(BuildContext context) { - final effectiveCenterTitle = getEffectiveCenterTitle( - Theme.of(context), - actions: actions, - centerTitle: centerTitle, + final channel = StreamChannel.maybeOf(context)?.channel; + final headerTheme = StreamChatTheme.of(context).threadHeaderTheme; + + var leading = this.leading; + if (leading == null && automaticallyImplyLeading) { + leading = StreamBackButton(channelId: channel?.cid, showUnreadCount: true); + } + + Widget? fallbackSubtitle; + if (parent.replyCount case final count? when count > 0) { + fallbackSubtitle = Text(context.translations.threadReplyCountText(count)); + } + + var title = this.title; + title ??= Text(context.translations.threadReplyLabel); + + var subtitle = this.subtitle; + subtitle ??= StreamTypingIndicator( + channel: channel, + parentId: parent.id, + alternativeWidget: fallbackSubtitle, ); - final channelHeaderTheme = StreamChannelHeaderTheme.of(context); - final textTheme = context.streamTextTheme; - final colorScheme = context.streamColorScheme; - - final replyCount = parent.replyCount; - - final defaultSubtitle = - subtitle ?? - (replyCount != null - ? Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - context.translations.threadReplyCountText(replyCount), - style: - channelHeaderTheme.subtitleStyle ?? - textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), - ), - ], - ) - : const SizedBox.shrink()); - - return StreamAppBar( - automaticallyImplyLeading: false, - elevation: elevation, - scrolledUnderElevation: scrolledUnderElevation, - leading: - leading ?? - (showBackButton - ? StreamBackButton( - channelId: StreamChannel.of(context).channel.cid, - onPressed: onBackPressed, - showUnreadCount: true, - ) - : const SizedBox()), - backgroundColor: backgroundColor ?? channelHeaderTheme.color, - centerTitle: centerTitle, - actions: actions, - title: InkWell( - onTap: onTitleTap, - child: SizedBox( - height: preferredSize.height, - width: 250, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: effectiveCenterTitle ? CrossAxisAlignment.center : CrossAxisAlignment.stretch, - children: [ - title ?? - Text( - context.translations.threadReplyLabel, - style: channelHeaderTheme.titleStyle ?? textTheme.headingSm, - ), - const SizedBox(height: 2), - if (showTypingIndicator) - StreamTypingIndicator( - channel: StreamChannel.of(context).channel, - style: - channelHeaderTheme.subtitleStyle ?? - textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), - parentId: parent.id, - alternativeWidget: defaultSubtitle, - ) - else - defaultSubtitle, - ], - ), - ), + return StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, ), ); } - - @override - final Size preferredSize; } diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart index 43d008d76a..94ee20f892 100644 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart @@ -123,7 +123,6 @@ class _StreamPollOptionVotesSheetState extends State final optionNumber = optionIndex >= 0 ? optionIndex + 1 : 1; return Column( - mainAxisSize: .min, children: [ StreamSheetHeader( style: effectiveTheme.sheetHeaderStyle, diff --git a/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart b/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart index 03e8f72ae3..2b3e855678 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart @@ -158,10 +158,7 @@ class _ReactionDetailSheetState extends State { Padding( padding: .symmetric(horizontal: spacing.sm), child: Text( - switch (visibleCount) { - 1 => '1 Reaction', - _ => '$visibleCount Reactions', - }, + context.translations.reactionsCountText(visibleCount), textAlign: .center, style: textTheme.headingSm, ), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index 82dc2396cd..12c5dab9d3 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -141,15 +141,9 @@ class _DefaultStreamChannelListItem extends StatelessWidget { @override Widget build(BuildContext context) { final channelState = props.channel.state!; - final textTheme = context.streamTextTheme; - final avatar = props.leading ?? StreamChannelAvatar(channel: props.channel, size: StreamAvatarGroupSize.xl); - final titleWidget = - props.title ?? - StreamChannelName( - channel: props.channel, - textStyle: textTheme.headingSm.copyWith(height: 1), - ); + final avatar = props.leading ?? StreamChannelAvatar(channel: props.channel, size: .xl); + final titleWidget = props.title ?? StreamChannelName(channel: props.channel); final subtitleWidget = props.subtitle ?? ChannelListTileSubtitle( @@ -158,25 +152,29 @@ class _DefaultStreamChannelListItem extends StatelessWidget { ); final timestampWidget = props.trailing ?? ChannelLastMessageDate(channel: props.channel); - return BetterStreamBuilder( - stream: props.channel.isMutedStream, - initialData: props.channel.isMuted, - builder: (context, isMuted) => BetterStreamBuilder( - stream: channelState.unreadCountStream, - initialData: channelState.unreadCount, - builder: (context, unreadCount) { - return StreamChannelListTile( - avatar: avatar, - title: titleWidget, - subtitle: subtitleWidget, - timestamp: timestampWidget, - unreadCount: unreadCount, - isMuted: isMuted, - onTap: props.onTap, - onLongPress: props.onLongPress, - selected: props.selected, - ); - }, + return BetterStreamBuilder( + initialData: ( + isMuted: props.channel.isMuted, + isPinned: props.channel.isPinned, + unreadCount: channelState.unreadCount, + ), + stream: Rx.combineLatest3( + props.channel.isMutedStream, + props.channel.isPinnedStream, + channelState.unreadCountStream, + (isMuted, isPinned, unreadCount) => (isMuted: isMuted, isPinned: isPinned, unreadCount: unreadCount), + ), + builder: (context, state) => StreamChannelListTile( + avatar: avatar, + title: titleWidget, + subtitle: subtitleWidget, + timestamp: timestampWidget, + unreadCount: state.unreadCount, + isMuted: state.isMuted, + isPinned: state.isPinned, + onTap: props.onTap, + onLongPress: props.onLongPress, + selected: props.selected, ), ); } @@ -195,6 +193,7 @@ class StreamChannelListTile extends StatelessWidget { this.timestamp, this.unreadCount = 0, this.isMuted = false, + this.isPinned = false, this.onTap, this.onLongPress, this.selected = false, @@ -234,6 +233,11 @@ class StreamChannelListTile extends StatelessWidget { /// When true, a mute icon is displayed in the title or subtitle. final bool isMuted; + /// Whether the channel is pinned by the current user. + /// + /// When true, a pin icon is displayed alongside the mute icon. + final bool isPinned; + /// Called when the list item is tapped. final VoidCallback? onTap; @@ -245,72 +249,74 @@ class StreamChannelListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final icons = context.streamIcons; final spacing = context.streamSpacing; + final channelListItemTheme = StreamChannelListItemTheme.of(context); final defaults = _StreamChannelListItemThemeDefaults(context); final effectiveTitleStyle = channelListItemTheme.titleStyle ?? defaults.titleStyle; final effectiveSubtitleStyle = channelListItemTheme.subtitleStyle ?? defaults.subtitleStyle; final effectiveTimestampStyle = channelListItemTheme.timestampStyle ?? defaults.timestampStyle; - final effectiveMuteIconPosition = channelListItemTheme.muteIconPosition ?? defaults.muteIconPosition; - - final muteIcon = isMuted - ? Icon( - context.streamIcons.mute, - size: 20, - color: context.streamColorScheme.textTertiary, - ) - : null; - - final hasMuteIconInSubtitle = effectiveMuteIconPosition == MuteIconPosition.subtitle && isMuted; - - return StreamListTileTheme( - data: context.streamListTileTheme.copyWith( - contentPadding: EdgeInsets.all(spacing.md - 4), - backgroundColor: channelListItemTheme.backgroundColor, - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: Material( - type: MaterialType.transparency, - child: StreamListTileContainer( - enabled: true, - selected: selected, - onTap: onTap, - onLongPress: onLongPress, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: spacing.md, - children: [ - avatar, - Expanded( - child: Padding( - padding: EdgeInsets.symmetric(vertical: spacing.xxxs), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: spacing.xxs, - children: [ - _TitleRow( - title: title, - titleTrailing: effectiveMuteIconPosition == MuteIconPosition.title ? muteIcon : null, - timestamp: timestamp, - unreadCount: unreadCount, - titleStyle: effectiveTitleStyle, - timestampStyle: effectiveTimestampStyle, - spacing: spacing, - ), - if (subtitle != null || hasMuteIconInSubtitle) - _SubtitleRow( - subtitle: subtitle, - subtitleTrailing: effectiveMuteIconPosition == MuteIconPosition.subtitle ? muteIcon : null, - subtitleStyle: effectiveSubtitleStyle, - ), - ], + final effectiveAttributePosition = channelListItemTheme.attributePosition ?? defaults.attributePosition; + + final channelAttributes = [ + if (isMuted) Icon(icons.mute), + if (isPinned) Icon(icons.pin), + ]; + + Widget? attributesRow; + if (channelAttributes.isNotEmpty) { + attributesRow = Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: channelAttributes, + ); + } + + final titleTrailing = effectiveAttributePosition == .inlineTitle ? attributesRow : null; + final subtitleTrailing = effectiveAttributePosition == .trailingBottom ? attributesRow : null; + + return Padding( + padding: EdgeInsets.all(spacing.xxs), + child: StreamListTileTheme( + data: StreamListTileThemeData( + contentPadding: EdgeInsets.all(spacing.sm), + backgroundColor: channelListItemTheme.backgroundColor, + ), + child: StreamListTileContainer( + onTap: onTap, + onLongPress: onLongPress, + selected: selected, + child: Row( + mainAxisSize: .min, + spacing: spacing.md, + children: [ + avatar, + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxs, + crossAxisAlignment: .center, + children: [ + _TitleRow( + title: title, + titleTrailing: titleTrailing, + timestamp: timestamp, + unreadCount: unreadCount, + titleStyle: effectiveTitleStyle, + timestampStyle: effectiveTimestampStyle, ), - ), + if (subtitle != null || subtitleTrailing != null) + _SubtitleRow( + subtitle: subtitle, + subtitleTrailing: subtitleTrailing, + subtitleStyle: effectiveSubtitleStyle, + ), + ], ), - ], - ), + ), + ], ), ), ), @@ -326,7 +332,6 @@ class _TitleRow extends StatelessWidget { required this.unreadCount, required this.titleStyle, required this.timestampStyle, - required this.spacing, }); final Widget title; @@ -335,46 +340,43 @@ class _TitleRow extends StatelessWidget { final int unreadCount; final TextStyle titleStyle; final TextStyle timestampStyle; - final StreamSpacing spacing; @override Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + return Row( + mainAxisSize: .min, spacing: spacing.md, children: [ Expanded( child: Row( - spacing: spacing.xxs, + mainAxisSize: .min, + spacing: spacing.xs, children: [ Flexible( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: StreamBadgeNotificationSize.sm.value), - child: Align( - alignment: AlignmentDirectional.centerStart, - widthFactor: 1, - child: DefaultTextStyle.merge( - style: titleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: title, - ), - ), + child: DefaultTextStyle.merge( + style: titleStyle, + maxLines: 1, + overflow: .ellipsis, + child: title, ), ), - ?titleTrailing, + if (titleTrailing case final trailing?) + IconTheme.merge( + data: .new(size: 20, color: colorScheme.textTertiary), + child: trailing, + ), ], ), ), if (timestamp != null || unreadCount > 0) Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, spacing: spacing.xs, children: [ - if (timestamp case final timestamp?) - DefaultTextStyle.merge( - style: timestampStyle, - child: timestamp, - ), + if (timestamp case final timestamp?) DefaultTextStyle.merge(style: timestampStyle, child: timestamp), if (unreadCount > 0) StreamBadgeNotification(label: '$unreadCount'), ], ), @@ -396,16 +398,27 @@ class _SubtitleRow extends StatelessWidget { @override Widget build(BuildContext context) { - return DefaultTextStyle( - style: subtitleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: Row( - children: [ - Expanded(child: subtitle ?? const SizedBox.shrink()), - ?subtitleTrailing, - ], - ), + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return Row( + mainAxisSize: .min, + spacing: spacing.md, + children: [ + Flexible( + child: DefaultTextStyle.merge( + style: subtitleStyle, + maxLines: 1, + overflow: .ellipsis, + child: subtitle ?? const Empty(), + ), + ), + if (subtitleTrailing case final trailing?) + IconTheme.merge( + data: .new(size: 20, color: colorScheme.textTertiary), + child: trailing, + ), + ], ); } } @@ -418,6 +431,9 @@ class _StreamChannelListItemThemeDefaults extends StreamChannelListItemThemeData late final _colorScheme = _context.streamColorScheme; late final _textTheme = _context.streamTextTheme; + @override + AttributePosition get attributePosition => .inlineTitle; + @override TextStyle get titleStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); @@ -429,9 +445,6 @@ class _StreamChannelListItemThemeDefaults extends StreamChannelListItemThemeData @override Color get borderColor => _colorScheme.borderSubtle; - - @override - MuteIconPosition get muteIconPosition => MuteIconPosition.title; } /// Shows the delivery status icon + "You:" prefix for outgoing messages in diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart index 078a4ade8c..d2e14fd7f6 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart @@ -1,10 +1,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamChannelListView]. Widget defaultChannelListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart index 1400679a7e..152e83d2da 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamDraftListView]. Widget defaultDraftListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart index c0378cefb0..b418fbdd35 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default grid delegate for [StreamMemberGridView]. const defaultMemberGridViewDelegate = SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart index 6bc5d04503..6a82a328c9 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamMemberListView]. Widget defaultMemberListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart index efdc6704f8..1d876833d3 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamMessageSearchListView]. Widget defaultMessageSearchListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart index 87d85ce9de..2c0bab739b 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart @@ -2,11 +2,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:photo_manager/photo_manager.dart' show AssetEntity, ThumbnailFormat, ThumbnailSize; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default grid delegate for [StreamPhotoGallery]. const defaultStreamPhotoGalleryDelegate = SliverGridDelegateWithFixedCrossAxisCount( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart index d43390f049..0456629274 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamReactionListView]. Widget defaultReactionListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index e746fc9273..7ca48b7382 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamThreadListTile} /// A widget that displays a thread in a list. @@ -76,6 +75,8 @@ class _DefaultStreamThreadListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final theme = StreamThreadListTileTheme.of(context); final defaults = _StreamThreadListTileThemeDefaults(context); @@ -106,77 +107,70 @@ class _DefaultStreamThreadListTile extends StatelessWidget { channel?.formatName(currentUser: currentUser) ?? avatarUser?.name ?? context.translations.noTitleText; final participantUsers = thread.threadParticipants.map((it) => it.user).nonNulls.toList(growable: false); - return StreamListTileTheme( - data: StreamListTileThemeData( - contentPadding: effectivePadding, - backgroundColor: WidgetStatePropertyAll(effectiveBackgroundColor), - ), - child: StreamListTileContainer( - enabled: true, - selected: false, - onTap: props.onTap, - onLongPress: props.onLongPress, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (avatarUser case final user?) - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: StreamUserAvatar( + return Padding( + padding: EdgeInsets.all(spacing.xxs), + child: StreamListTileTheme( + data: StreamListTileThemeData( + contentPadding: effectivePadding, + backgroundColor: .all(effectiveBackgroundColor), + ), + child: StreamListTileContainer( + onTap: props.onTap, + onLongPress: props.onLongPress, + child: Row( + spacing: spacing.sm, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (avatarUser case final user?) + StreamUserAvatar( user: user, size: StreamAvatarSize.xl, ), - ) - else - const Padding( - padding: EdgeInsetsDirectional.only(end: 12), - child: SizedBox.square(dimension: 40), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ThreadTitle( - channelName: channelName, - style: effectiveChannelNameStyle, - ), - ), - if (unreadMessageCount case final count? when count > 0) ...[ - const SizedBox(width: 8), - ThreadUnreadCount( - unreadCount: count, - style: effectiveUnreadCountStyle, - backgroundColor: effectiveUnreadCountBackgroundColor, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: spacing.sm, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ThreadTitle( + channelName: channelName, + style: effectiveChannelNameStyle, + ), ), + if (unreadMessageCount case final count? when count > 0) + ThreadUnreadCount( + unreadCount: count, + style: effectiveUnreadCountStyle, + backgroundColor: effectiveUnreadCountBackgroundColor, + ), ], - ], - ), - const SizedBox(height: 2), - ThreadRootMessagePreview( - parentMessage: parentMessage, - channel: channel, - language: language, - style: effectiveReplyToMessageStyle, - emptyStyle: effectiveLatestReplyMessageStyle, - ), - const SizedBox(height: 8), - ThreadFooter( - participantUsers: participantUsers, - replyCount: thread.replyCount, - latestActivityAt: latestActivityAt, - replyCountStyle: effectiveReplyCountStyle, - timestampStyle: effectiveTimestampStyle, - timestampFormatter: effectiveTimestampFormatter, - ), - ], + ), + SizedBox(height: spacing.xxs), + ThreadRootMessagePreview( + parentMessage: parentMessage, + channel: channel, + language: language, + style: effectiveReplyToMessageStyle, + emptyStyle: effectiveLatestReplyMessageStyle, + ), + SizedBox(height: spacing.xs), + ThreadFooter( + participantUsers: participantUsers, + replyCount: thread.replyCount, + latestActivityAt: latestActivityAt, + replyCountStyle: effectiveReplyCountStyle, + timestampStyle: effectiveTimestampStyle, + timestampFormatter: effectiveTimestampFormatter, + ), + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -340,22 +334,21 @@ class ThreadFooter extends StatelessWidget { @override Widget build(BuildContext context) { + final spacing = context.streamSpacing; + return Row( + spacing: spacing.xs, children: [ if (participantUsers.isNotEmpty) - Padding( - padding: const EdgeInsetsDirectional.only(end: 6), - child: StreamUserAvatarStack( - users: participantUsers, - size: StreamAvatarStackSize.sm, - max: 3, - ), + StreamUserAvatarStack( + users: participantUsers, + size: StreamAvatarStackSize.sm, + max: 3, ), Text( context.translations.threadReplyCountText(replyCount), style: replyCountStyle, ), - const SizedBox(width: 8), StreamTimestamp( date: latestActivityAt.toLocal(), style: timestampStyle, @@ -371,11 +364,12 @@ class _StreamThreadListTileThemeDefaults extends StreamThreadListTileThemeData { final BuildContext _context; + late final _spacing = _context.streamSpacing; late final _colorScheme = _context.streamColorScheme; late final _textTheme = _context.streamTextTheme; @override - EdgeInsetsGeometry get padding => const EdgeInsets.symmetric(vertical: 14, horizontal: 8); + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.sm); @override Color get backgroundColor => _colorScheme.backgroundApp; diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart index 5bb9fb778e..85fe965399 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart @@ -1,10 +1,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; import 'package:stream_chat_flutter/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamThreadListView]. Widget defaultThreadListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart index 354d44d857..419735bc05 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default grid delegate for [StreamUserGridView]. const defaultUserGridViewDelegate = SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart index 154972f60c..95f6a170f9 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart @@ -1,10 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamUserListView]. Widget defaultUserListViewSeparatorBuilder( diff --git a/packages/stream_chat_flutter/lib/src/theme/channel_header_theme.dart b/packages/stream_chat_flutter/lib/src/theme/channel_header_theme.dart deleted file mode 100644 index d9ea4b009e..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/channel_header_theme.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/avatar_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/theme/themes.dart'; - -/// {@template channel_header_theme} -/// Overrides the default style of [ChannelHeader] descendants. -/// -/// See also: -/// -/// * [StreamChannelHeaderThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamChannelHeaderTheme extends InheritedTheme { - /// Creates a [StreamChannelHeaderTheme]. - /// - /// The [data] parameter must not be null. - const StreamChannelHeaderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamChannelHeaderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamChannelHeaderTheme] widget, then - /// [StreamChatThemeData.channelTheme.channelHeaderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// final theme = ChannelHeaderTheme.of(context); - /// ``` - static StreamChannelHeaderThemeData of(BuildContext context) { - final channelHeaderTheme = context.dependOnInheritedWidgetOfExactType(); - return channelHeaderTheme?.data ?? StreamChatTheme.of(context).channelHeaderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => StreamChannelHeaderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamChannelHeaderTheme oldWidget) => data != oldWidget.data; -} - -/// {@template channel_header_theme_data} -/// A style that overrides the default appearance of [ChannelHeader]s when used -/// with [StreamChannelHeaderTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.channelHeaderTheme]. -/// -/// See also: -/// -/// * [StreamChannelHeaderTheme], the theme which is configured with this class. -/// * [StreamChatThemeData.channelHeaderTheme], which can be used to override -/// the default style for [ChannelHeader]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamChannelHeaderThemeData with Diagnosticable { - /// Creates a [StreamChannelHeaderThemeData] - const StreamChannelHeaderThemeData({ - this.titleStyle, - this.subtitleStyle, - this.avatarTheme, - this.color, - }); - - /// Theme for title - final TextStyle? titleStyle; - - /// Theme for subtitle - final TextStyle? subtitleStyle; - - /// Theme for avatar - final StreamAvatarThemeData? avatarTheme; - - /// Color for [StreamChannelHeaderThemeData] - final Color? color; - - /// Copy with theme - StreamChannelHeaderThemeData copyWith({ - TextStyle? titleStyle, - TextStyle? subtitleStyle, - StreamAvatarThemeData? avatarTheme, - Color? color, - }) { - return StreamChannelHeaderThemeData( - titleStyle: titleStyle ?? this.titleStyle, - subtitleStyle: subtitleStyle ?? this.subtitleStyle, - avatarTheme: avatarTheme ?? this.avatarTheme, - color: color ?? this.color, - ); - } - - /// Linearly interpolate between two [StreamChannelHeaderThemeData]. - /// - /// All the properties must be non-null. - StreamChannelHeaderThemeData lerp( - StreamChannelHeaderThemeData a, - StreamChannelHeaderThemeData b, - double t, - ) { - return StreamChannelHeaderThemeData( - titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), - subtitleStyle: TextStyle.lerp(a.subtitleStyle, b.subtitleStyle, t), - avatarTheme: const StreamAvatarThemeData().lerp(a.avatarTheme!, b.avatarTheme!, t), - color: Color.lerp(a.color, b.color, t), - ); - } - - /// Merge with other [StreamChannelHeaderThemeData] - StreamChannelHeaderThemeData merge(StreamChannelHeaderThemeData? other) { - if (other == null) return this; - return copyWith( - titleStyle: titleStyle?.merge(other.titleStyle) ?? other.titleStyle, - subtitleStyle: subtitleStyle?.merge(other.subtitleStyle) ?? other.subtitleStyle, - avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, - color: other.color, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamChannelHeaderThemeData && - runtimeType == other.runtimeType && - titleStyle == other.titleStyle && - subtitleStyle == other.subtitleStyle && - avatarTheme == other.avatarTheme && - color == other.color; - - @override - int get hashCode => titleStyle.hashCode ^ subtitleStyle.hashCode ^ avatarTheme.hashCode ^ color.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('title', titleStyle)) - ..add(DiagnosticsProperty('subtitle', subtitleStyle)) - ..add(DiagnosticsProperty('avatarTheme', avatarTheme)) - ..add(ColorProperty('color', color)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/channel_list_header_theme.dart b/packages/stream_chat_flutter/lib/src/theme/channel_list_header_theme.dart deleted file mode 100644 index 6335400790..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/channel_list_header_theme.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/avatar_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template channelListHeaderTheme} -/// Overrides the default style of [ChannelListHeader] descendants. -/// -/// See also: -/// -/// * [StreamChannelListHeaderThemeData], which is used -/// to configure this theme. -/// {@endtemplate} -class StreamChannelListHeaderTheme extends InheritedTheme { - /// Creates a [StreamChannelListHeaderTheme]. - /// - /// The [data] parameter must not be null. - const StreamChannelListHeaderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamChannelListHeaderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamChannelListHeaderTheme] widget, then - /// [StreamChatThemeData.channelListHeaderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// final theme = ChannelListHeaderTheme.of(context); - /// ``` - static StreamChannelListHeaderThemeData of(BuildContext context) { - final channelListHeaderTheme = context.dependOnInheritedWidgetOfExactType(); - return channelListHeaderTheme?.data ?? StreamChatTheme.of(context).channelListHeaderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => StreamChannelListHeaderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamChannelListHeaderTheme oldWidget) => data != oldWidget.data; -} - -/// {@template channel_list_header_theme_data} -/// Theme dedicated to the [ChannelListHeader] -/// {@endtemplate} -class StreamChannelListHeaderThemeData with Diagnosticable { - /// Returns a new [StreamChannelListHeaderThemeData] - const StreamChannelListHeaderThemeData({ - this.titleStyle, - this.avatarTheme, - this.color, - }); - - /// Style of the title text - final TextStyle? titleStyle; - - /// Theme dedicated to the userAvatar - final StreamAvatarThemeData? avatarTheme; - - /// Background color of the appbar - final Color? color; - - /// Returns a new [StreamChannelListHeaderThemeData] replacing some of its - /// properties - StreamChannelListHeaderThemeData copyWith({ - TextStyle? titleStyle, - StreamAvatarThemeData? avatarTheme, - Color? color, - }) { - return StreamChannelListHeaderThemeData( - titleStyle: titleStyle ?? this.titleStyle, - avatarTheme: avatarTheme ?? this.avatarTheme, - color: color ?? this.color, - ); - } - - /// Linearly interpolate from one [StreamChannelListHeaderThemeData] - /// to another. - StreamChannelListHeaderThemeData lerp( - StreamChannelListHeaderThemeData a, - StreamChannelListHeaderThemeData b, - double t, - ) { - return StreamChannelListHeaderThemeData( - avatarTheme: const StreamAvatarThemeData().lerp(a.avatarTheme!, b.avatarTheme!, t), - color: Color.lerp(a.color, b.color, t), - titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), - ); - } - - /// Merges [this] [StreamChannelListHeaderThemeData] with the [other] - StreamChannelListHeaderThemeData merge( - StreamChannelListHeaderThemeData? other, - ) { - if (other == null) return this; - return copyWith( - titleStyle: titleStyle?.merge(other.titleStyle) ?? other.titleStyle, - avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, - color: other.color, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamChannelListHeaderThemeData && - runtimeType == other.runtimeType && - titleStyle == other.titleStyle && - avatarTheme == other.avatarTheme && - color == other.color; - - @override - int get hashCode => titleStyle.hashCode ^ avatarTheme.hashCode ^ color.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('titleStyle', titleStyle)) - ..add(DiagnosticsProperty('avatarTheme', avatarTheme)) - ..add(ColorProperty('color', color)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/gallery_header_theme.dart b/packages/stream_chat_flutter/lib/src/theme/gallery_header_theme.dart deleted file mode 100644 index 8add8e4775..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/gallery_header_theme.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template galleryHeaderTheme} -/// Overrides the default style of [GalleryHeader] descendants. -/// -/// See also: -/// -/// * [StreamGalleryHeaderThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamGalleryHeaderTheme extends InheritedTheme { - /// Creates a [StreamGalleryHeaderTheme]. - /// - /// The [data] parameter must not be null. - const StreamGalleryHeaderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamGalleryHeaderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamGalleryHeaderTheme] widget, then - /// [StreamChatThemeData.galleryHeaderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// ImageHeaderTheme theme = ImageHeaderTheme.of(context); - /// ``` - static StreamGalleryHeaderThemeData of(BuildContext context) { - final galleryHeaderTheme = context.dependOnInheritedWidgetOfExactType(); - return galleryHeaderTheme?.data ?? StreamChatTheme.of(context).galleryHeaderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => StreamGalleryHeaderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamGalleryHeaderTheme oldWidget) => data != oldWidget.data; -} - -/// {@template galleryHeaderThemeData} -/// A style that overrides the default appearance of [GalleryHeader]s when used -/// with [StreamGalleryHeaderTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.galleryHeaderTheme]. -/// -/// See also: -/// -/// * [StreamGalleryHeaderTheme], the theme which is configured with this class. -/// * [StreamChatThemeData.galleryHeaderTheme], which can be used to override -/// the default style for [GalleryHeader]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamGalleryHeaderThemeData with Diagnosticable { - /// Creates an [StreamGalleryHeaderThemeData]. - const StreamGalleryHeaderThemeData({ - this.closeButtonColor, - this.backgroundColor, - this.iconMenuPointColor, - this.titleTextStyle, - this.subtitleTextStyle, - this.bottomSheetBarrierColor, - }); - - /// The color of the "close" button. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? closeButtonColor; - - /// The background color of the [GalleryHeader] widget. - /// - /// Defaults to [ChannelHeaderTheme.color]. - final Color? backgroundColor; - - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? iconMenuPointColor; - - /// The [TextStyle] to use for the [GalleryHeader] title text. - /// - /// Defaults to [TextTheme.headlineBold]. - final TextStyle? titleTextStyle; - - /// The [TextStyle] to use for the [GalleryHeader] subtitle text. - /// - /// Defaults to [ChannelPreviewTheme.subtitleStyle]. - final TextStyle? subtitleTextStyle; - - /// - final Color? bottomSheetBarrierColor; - - /// Copies this [StreamGalleryHeaderThemeData] to another. - StreamGalleryHeaderThemeData copyWith({ - Color? closeButtonColor, - Color? backgroundColor, - Color? iconMenuPointColor, - TextStyle? titleTextStyle, - TextStyle? subtitleTextStyle, - Color? bottomSheetBarrierColor, - }) { - return StreamGalleryHeaderThemeData( - closeButtonColor: closeButtonColor ?? this.closeButtonColor, - backgroundColor: backgroundColor ?? this.backgroundColor, - iconMenuPointColor: iconMenuPointColor ?? this.iconMenuPointColor, - titleTextStyle: titleTextStyle ?? this.titleTextStyle, - subtitleTextStyle: subtitleTextStyle ?? this.subtitleTextStyle, - bottomSheetBarrierColor: bottomSheetBarrierColor ?? this.bottomSheetBarrierColor, - ); - } - - /// Linearly interpolate between two [GalleryHeader] themes. - /// - /// All the properties must be non-null. - StreamGalleryHeaderThemeData lerp( - StreamGalleryHeaderThemeData a, - StreamGalleryHeaderThemeData b, - double t, - ) { - return StreamGalleryHeaderThemeData( - closeButtonColor: Color.lerp(a.closeButtonColor, b.closeButtonColor, t), - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - iconMenuPointColor: Color.lerp(a.iconMenuPointColor, b.iconMenuPointColor, t), - titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), - subtitleTextStyle: TextStyle.lerp(a.subtitleTextStyle, b.subtitleTextStyle, t), - bottomSheetBarrierColor: Color.lerp(a.bottomSheetBarrierColor, b.bottomSheetBarrierColor, t), - ); - } - - /// Merges one [StreamGalleryHeaderThemeData] with the another - StreamGalleryHeaderThemeData merge(StreamGalleryHeaderThemeData? other) { - if (other == null) return this; - return copyWith( - closeButtonColor: other.closeButtonColor, - backgroundColor: other.backgroundColor, - iconMenuPointColor: other.iconMenuPointColor, - titleTextStyle: other.titleTextStyle, - subtitleTextStyle: other.subtitleTextStyle, - bottomSheetBarrierColor: other.bottomSheetBarrierColor, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamGalleryHeaderThemeData && - runtimeType == other.runtimeType && - closeButtonColor == other.closeButtonColor && - backgroundColor == other.backgroundColor && - iconMenuPointColor == other.iconMenuPointColor && - titleTextStyle == other.titleTextStyle && - subtitleTextStyle == other.subtitleTextStyle && - bottomSheetBarrierColor == other.bottomSheetBarrierColor; - - @override - int get hashCode => - closeButtonColor.hashCode ^ - backgroundColor.hashCode ^ - iconMenuPointColor.hashCode ^ - titleTextStyle.hashCode ^ - subtitleTextStyle.hashCode ^ - bottomSheetBarrierColor.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('closeButtonColor', closeButtonColor)) - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(ColorProperty('iconMenuPointColor', iconMenuPointColor)) - ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) - ..add(DiagnosticsProperty('subtitleTextStyle', subtitleTextStyle)) - ..add(ColorProperty('bottomSheetBarrierColor', bottomSheetBarrierColor)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart index d56174f744..8bd98fcf6c 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart @@ -4,6 +4,24 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann part 'stream_channel_list_item_theme.g.theme.dart'; +/// Predefined attribute positions for [StreamChannelListTile]. +/// +/// Each position controls where the channel attribute icons are rendered +/// within the tile. +/// +/// See also: +/// +/// * [StreamChannelListTile], which uses these position variants. +/// * [StreamChannelListItemThemeData.attributePosition], for setting a +/// global default position. +enum AttributePosition { + /// Inline with the channel name in the title row. + inlineTitle, + + /// At the trailing end of the subtitle row. + trailingBottom, +} + /// Applies a channel list item theme to descendant /// [StreamChannelListItem] widgets. /// @@ -91,7 +109,7 @@ class StreamChannelListItemThemeData with _$StreamChannelListItemThemeData { this.timestampStyle, this.backgroundColor, this.borderColor, - this.muteIconPosition, + this.attributePosition, }); /// The text style for the channel title. @@ -119,10 +137,10 @@ class StreamChannelListItemThemeData with _$StreamChannelListItemThemeData { /// Falls back to [StreamColorScheme.borderSubtle]. final Color? borderColor; - /// The position of the mute icon. + /// Position of channel attribute icons. /// - /// Falls back to [MuteIconPosition.title]. - final MuteIconPosition? muteIconPosition; + /// Defaults to [AttributePosition.inlineTitle]. + final AttributePosition? attributePosition; /// Linearly interpolate between two [StreamChannelListItemThemeData] objects. static StreamChannelListItemThemeData? lerp( @@ -131,14 +149,3 @@ class StreamChannelListItemThemeData with _$StreamChannelListItemThemeData { double t, ) => _$StreamChannelListItemThemeData.lerp(a, b, t); } - -/// The position of the mute icon. -/// By default the mute icon will be shown directly next to the title. -/// When choosing for subtitle, the mute icon will be shown at the end of the list item. -enum MuteIconPosition { - /// Top row of the list item, next to the title. - title, - - /// Bottom row, at the end of the list item. - subtitle, -} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart index bb1baafe13..a2543cbe8a 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart @@ -40,7 +40,7 @@ mixin _$StreamChannelListItemThemeData { Color.lerp, ), borderColor: Color.lerp(a.borderColor, b.borderColor, t), - muteIconPosition: t < 0.5 ? a.muteIconPosition : b.muteIconPosition, + attributePosition: t < 0.5 ? a.attributePosition : b.attributePosition, ); } @@ -50,7 +50,7 @@ mixin _$StreamChannelListItemThemeData { TextStyle? timestampStyle, WidgetStateProperty? backgroundColor, Color? borderColor, - MuteIconPosition? muteIconPosition, + AttributePosition? attributePosition, }) { final _this = (this as StreamChannelListItemThemeData); @@ -60,7 +60,7 @@ mixin _$StreamChannelListItemThemeData { timestampStyle: timestampStyle ?? _this.timestampStyle, backgroundColor: backgroundColor ?? _this.backgroundColor, borderColor: borderColor ?? _this.borderColor, - muteIconPosition: muteIconPosition ?? _this.muteIconPosition, + attributePosition: attributePosition ?? _this.attributePosition, ); } @@ -85,7 +85,7 @@ mixin _$StreamChannelListItemThemeData { other.timestampStyle, backgroundColor: other.backgroundColor, borderColor: other.borderColor, - muteIconPosition: other.muteIconPosition, + attributePosition: other.attributePosition, ); } @@ -107,7 +107,7 @@ mixin _$StreamChannelListItemThemeData { _other.timestampStyle == _this.timestampStyle && _other.backgroundColor == _this.backgroundColor && _other.borderColor == _this.borderColor && - _other.muteIconPosition == _this.muteIconPosition; + _other.attributePosition == _this.attributePosition; } @override @@ -121,7 +121,7 @@ mixin _$StreamChannelListItemThemeData { _this.timestampStyle, _this.backgroundColor, _this.borderColor, - _this.muteIconPosition, + _this.attributePosition, ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 148f9a4ebb..62e4a5605a 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -42,12 +42,13 @@ class StreamChatThemeData { Brightness? brightness, StreamTextTheme? textTheme, StreamColorTheme? colorTheme, - StreamChannelListHeaderThemeData? channelListHeaderTheme, - StreamChannelHeaderThemeData? channelHeaderTheme, + StreamAppBarThemeData? channelHeaderTheme, + StreamAppBarThemeData? channelListHeaderTheme, + StreamAppBarThemeData? threadHeaderTheme, + StreamAppBarThemeData? galleryHeaderTheme, Widget Function(BuildContext, User)? defaultUserImage, PlaceholderUserImage? placeholderUserImage, IconThemeData? primaryIconTheme, - StreamGalleryHeaderThemeData? imageHeaderTheme, StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, @@ -71,12 +72,13 @@ class StreamChatThemeData { ); final customizedData = defaultData.copyWith( - channelListHeaderTheme: channelListHeaderTheme, channelHeaderTheme: channelHeaderTheme, + channelListHeaderTheme: channelListHeaderTheme, + threadHeaderTheme: threadHeaderTheme, + galleryHeaderTheme: galleryHeaderTheme, defaultUserImage: defaultUserImage, placeholderUserImage: placeholderUserImage, primaryIconTheme: primaryIconTheme, - galleryHeaderTheme: imageHeaderTheme, galleryFooterTheme: imageFooterTheme, messageListViewTheme: messageListViewTheme, pollCreatorTheme: pollCreatorTheme, @@ -104,10 +106,11 @@ class StreamChatThemeData { const StreamChatThemeData.raw({ required this.textTheme, required this.colorTheme, - required this.channelListHeaderTheme, required this.channelHeaderTheme, - required this.primaryIconTheme, + required this.channelListHeaderTheme, + required this.threadHeaderTheme, required this.galleryHeaderTheme, + required this.primaryIconTheme, required this.galleryFooterTheme, required this.messageListViewTheme, required this.pollCreatorTheme, @@ -140,40 +143,19 @@ class StreamChatThemeData { StreamTextTheme textTheme, ) { final iconTheme = IconThemeData(color: colorTheme.textLowEmphasis); - final channelHeaderTheme = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: colorTheme.barsBg, - ); return StreamChatThemeData.raw( textTheme: textTheme, colorTheme: colorTheme, primaryIconTheme: iconTheme, - channelListHeaderTheme: StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: colorTheme.barsBg, - titleStyle: textTheme.headlineBold, - ), - channelHeaderTheme: channelHeaderTheme, - galleryHeaderTheme: StreamGalleryHeaderThemeData( - closeButtonColor: colorTheme.textHighEmphasis, - backgroundColor: channelHeaderTheme.color, - iconMenuPointColor: colorTheme.textHighEmphasis, - titleTextStyle: textTheme.headlineBold, - bottomSheetBarrierColor: colorTheme.overlay, - ), + // Header chrome flows through per-header [StreamAppBarThemeData] + // entries — defaults are resolved by the design system (background, + // divider, padding, typography). Override individual fields per + // header type to customise globally. + channelHeaderTheme: const StreamAppBarThemeData(), + channelListHeaderTheme: const StreamAppBarThemeData(), + threadHeaderTheme: const StreamAppBarThemeData(), + galleryHeaderTheme: const StreamAppBarThemeData(), galleryFooterTheme: StreamGalleryFooterThemeData( backgroundColor: colorTheme.barsBg, shareIconColor: colorTheme.textHighEmphasis, @@ -218,15 +200,17 @@ class StreamChatThemeData { /// The color themes used in the widgets final StreamColorTheme colorTheme; - /// Theme of the [StreamChannelListHeader] - final StreamChannelListHeaderThemeData channelListHeaderTheme; + /// The default [StreamAppBar] style applied to [StreamChannelHeader]. + final StreamAppBarThemeData channelHeaderTheme; - /// Theme of the chat widgets dedicated to a channel header - final StreamChannelHeaderThemeData channelHeaderTheme; + /// The default [StreamAppBar] style applied to [StreamChannelListHeader]. + final StreamAppBarThemeData channelListHeaderTheme; - /// The default style for [StreamGalleryHeader]s below the overall - /// [StreamChatTheme]. - final StreamGalleryHeaderThemeData galleryHeaderTheme; + /// The default [StreamAppBar] style applied to [StreamThreadHeader]. + final StreamAppBarThemeData threadHeaderTheme; + + /// The default [StreamAppBar] style applied to [StreamGalleryHeader]. + final StreamAppBarThemeData galleryHeaderTheme; /// The default style for [StreamGalleryFooter]s below the overall /// [StreamChatTheme]. @@ -273,12 +257,13 @@ class StreamChatThemeData { StreamChatThemeData copyWith({ StreamTextTheme? textTheme, StreamColorTheme? colorTheme, - StreamChannelHeaderThemeData? channelHeaderTheme, + StreamAppBarThemeData? channelHeaderTheme, + StreamAppBarThemeData? channelListHeaderTheme, + StreamAppBarThemeData? threadHeaderTheme, + StreamAppBarThemeData? galleryHeaderTheme, Widget Function(BuildContext, User)? defaultUserImage, PlaceholderUserImage? placeholderUserImage, IconThemeData? primaryIconTheme, - StreamChannelListHeaderThemeData? channelListHeaderTheme, - StreamGalleryHeaderThemeData? galleryHeaderTheme, StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, @@ -292,12 +277,13 @@ class StreamChatThemeData { StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, StreamChannelListItemThemeData? channelListItemTheme, }) => StreamChatThemeData.raw( - channelListHeaderTheme: this.channelListHeaderTheme.merge(channelListHeaderTheme), textTheme: this.textTheme.merge(textTheme), colorTheme: this.colorTheme.merge(colorTheme), - primaryIconTheme: this.primaryIconTheme.merge(primaryIconTheme), channelHeaderTheme: this.channelHeaderTheme.merge(channelHeaderTheme), - galleryHeaderTheme: galleryHeaderTheme ?? this.galleryHeaderTheme, + channelListHeaderTheme: this.channelListHeaderTheme.merge(channelListHeaderTheme), + threadHeaderTheme: this.threadHeaderTheme.merge(threadHeaderTheme), + galleryHeaderTheme: this.galleryHeaderTheme.merge(galleryHeaderTheme), + primaryIconTheme: this.primaryIconTheme.merge(primaryIconTheme), galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, pollCreatorTheme: pollCreatorTheme ?? this.pollCreatorTheme, @@ -316,12 +302,13 @@ class StreamChatThemeData { StreamChatThemeData merge(StreamChatThemeData? other) { if (other == null) return this; return copyWith( - channelListHeaderTheme: channelListHeaderTheme.merge(other.channelListHeaderTheme), textTheme: textTheme.merge(other.textTheme), colorTheme: colorTheme.merge(other.colorTheme), - primaryIconTheme: other.primaryIconTheme, channelHeaderTheme: channelHeaderTheme.merge(other.channelHeaderTheme), + channelListHeaderTheme: channelListHeaderTheme.merge(other.channelListHeaderTheme), + threadHeaderTheme: threadHeaderTheme.merge(other.threadHeaderTheme), galleryHeaderTheme: galleryHeaderTheme.merge(other.galleryHeaderTheme), + primaryIconTheme: other.primaryIconTheme, galleryFooterTheme: galleryFooterTheme.merge(other.galleryFooterTheme), messageListViewTheme: messageListViewTheme.merge(other.messageListViewTheme), pollCreatorTheme: pollCreatorTheme.merge(other.pollCreatorTheme), diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 74c9dad44c..8d809f1103 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,10 +1,7 @@ export 'avatar_theme.dart'; -export 'channel_header_theme.dart'; -export 'channel_list_header_theme.dart'; export 'color_theme.dart'; export 'draft_list_tile_theme.dart'; export 'gallery_footer_theme.dart'; -export 'gallery_header_theme.dart'; export 'message_list_view_theme.dart'; export 'poll_card_style.dart'; export 'poll_comments_sheet_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 030c7ceb9a..677a11dc7a 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -3,7 +3,13 @@ export 'package:photo_manager/photo_manager.dart' show ThumbnailSize, ThumbnailF export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'package:stream_core_flutter/stream_core_flutter.dart' show + StreamAppBar, + StreamAppBarProps, + StreamAppBarStyle, + StreamAppBarTheme, + StreamAppBarThemeData, StreamAudioWaveformThemeData, + StreamAvatar, StreamAvatarGroupSize, StreamAvatarSize, StreamAvatarStackSize, @@ -45,6 +51,7 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamMessageLayout, StreamMessageStackPosition, StreamMessageChannelKind, + StreamNetworkImage, StreamMessageListKind, StreamMessageContentKind, StreamMessageText, @@ -59,6 +66,10 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamReactionsType, StreamListTile, StreamListTileContainer, + StreamListTileTheme, + StreamListTileThemeData, + StreamLoadingSpinner, + StreamLoadingSpinnerSize, StreamSheet, StreamSheetDragHandle, StreamSheetHeader, @@ -93,6 +104,8 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamMessageLayoutProperty, StreamMessageLayoutVisibility, StreamVisibility, + StreamColors, + kStreamHeaderHeight, showStreamSheet, streamSupportedEmojis; @@ -116,9 +129,6 @@ export 'src/attachment/voice_recording_attachment_playlist.dart'; export 'src/attachment_actions_modal/attachment_actions_modal.dart'; export 'src/autocomplete/stream_autocomplete.dart'; export 'src/avatars/gradient_avatar.dart'; -export 'src/bottom_sheets/attachment_modal_sheet.dart'; -export 'src/bottom_sheets/error_alert_sheet.dart'; -export 'src/bottom_sheets/stream_channel_info_bottom_sheet.dart'; export 'src/channel/channel_header.dart'; export 'src/channel/channel_info.dart'; export 'src/channel/channel_list_header.dart'; @@ -211,7 +221,9 @@ export 'src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart'; export 'src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; export 'src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart'; export 'src/scroll_view/stream_scroll_view_empty_widget.dart'; +export 'src/scroll_view/stream_scroll_view_error_widget.dart'; export 'src/scroll_view/stream_scroll_view_indexed_widget_builder.dart'; +export 'src/scroll_view/stream_scroll_view_loading_widget.dart'; export 'src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart'; export 'src/scroll_view/thread_scroll_view/stream_thread_list_view.dart'; export 'src/scroll_view/thread_scroll_view/stream_unread_threads_banner.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 4aba522143..2cf647bdf3 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: c012cfe01900fcc9cc87cafbcb2d46eada243343 + ref: 639f99401891f171e9cc2264eea822ef3ede3f99 path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 diff --git a/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart index dac183ef17..cabd566306 100644 --- a/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart @@ -2,7 +2,6 @@ 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'; import '../mocks.dart'; diff --git a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart index d75da31da1..c288dee6cd 100644 --- a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart @@ -2,7 +2,6 @@ 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'; import '../mocks.dart'; diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/attachment_modal_sheet_test.dart b/packages/stream_chat_flutter/test/src/bottom_sheets/attachment_modal_sheet_test.dart deleted file mode 100644 index a1ba65dc58..0000000000 --- a/packages/stream_chat_flutter/test/src/bottom_sheets/attachment_modal_sheet_test.dart +++ /dev/null @@ -1,147 +0,0 @@ -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 '../material_app_wrapper.dart'; - -void main() { - group('AttachmentModalSheet tests', () { - testWidgets('Appears on tap', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: ElevatedButton( - child: const Text('Show Modal'), - onPressed: () => showModalBottomSheet( - context: context, - builder: (_) => AttachmentModalSheet( - onFileTap: () {}, - onPhotoTap: () {}, - onVideoTap: () {}, - ), - ), - ), - ); - }, - ), - ), - ), - ); - - final button = find.byType(ElevatedButton); - await tester.tap(button); - await tester.pumpAndSettle(); - expect(find.byType(AttachmentModalSheet), findsOneWidget); - expect(find.byType(ListTile), findsNWidgets(4)); - }); - - testWidgets('onPhotoTap works', (tester) async { - var called = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () => called = 1, - onFileTap: () {}, - onVideoTap: () {}, - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AttachmentModalSheet), findsOneWidget); - final photoTile = find.widgetWithIcon(ListTile, Icons.image); - expect(photoTile, findsOneWidget); - await tester.tap(photoTile); - await tester.pumpAndSettle(); - expect(called, 1); - }); - - testWidgets('onVideoTap works', (tester) async { - var called = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () {}, - onVideoTap: () => called = 1, - onFileTap: () {}, - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AttachmentModalSheet), findsOneWidget); - final videoTile = find.widgetWithIcon(ListTile, Icons.video_library); - expect(videoTile, findsOneWidget); - await tester.tap(videoTile); - await tester.pumpAndSettle(); - expect(called, 1); - }); - - testWidgets('onFileTap works', (tester) async { - var called = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () {}, - onVideoTap: () {}, - onFileTap: () => called = 1, - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AttachmentModalSheet), findsOneWidget); - final fileTile = find.widgetWithIcon(ListTile, Icons.insert_drive_file); - expect(fileTile, findsOneWidget); - await tester.tap(fileTile); - await tester.pumpAndSettle(); - expect(called, 1); - }); - - goldenTest( - 'golden test for AttachmentModalSheet', - fileName: 'attachment_modal_sheet_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () {}, - onVideoTap: () {}, - onFileTap: () {}, - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/attachment_modal_sheet_0.png b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/attachment_modal_sheet_0.png deleted file mode 100644 index d61f6d8fcc..0000000000 Binary files a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/attachment_modal_sheet_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png deleted file mode 100644 index 46819594a7..0000000000 Binary files a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart index 3f1653c098..5155f2505d 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart @@ -1,4 +1,3 @@ -import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -77,7 +76,6 @@ void main() { expect(find.text('test'), findsOneWidget); expect(find.byType(StreamChannelAvatar), findsOneWidget); - expect(find.byType(StreamBackButton), findsOneWidget); expect(find.byType(StreamChannelInfo), findsOneWidget); }, ); @@ -268,12 +266,9 @@ void main() { channel: channel, child: const Scaffold( body: StreamChannelHeader( - centerTitle: true, leading: Text('leading'), subtitle: Text('subtitle'), - actions: [ - Text('action'), - ], + trailing: Text('action'), title: Text('title'), ), ), @@ -346,8 +341,8 @@ void main() { channel: channel, child: const Scaffold( body: StreamChannelHeader( - showTypingIndicator: false, - showBackButton: false, + automaticallyImplyLeading: false, + leading: SizedBox(), ), ), ), @@ -359,10 +354,6 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(StreamBackButton), findsNothing); - expect( - tester.widget(find.byType(StreamChannelInfo)).showTypingIndicator, - false, - ); expect(tester.widget(find.byType(StreamInfoTile)).showMessage, false); }, ); @@ -421,9 +412,15 @@ void main() { channel: channel, child: Scaffold( body: StreamChannelHeader( - onBackPressed: () => backPressed = true, - onImageTap: () => imageTapped = true, - onTitleTap: () => titleTapped = true, + leading: StreamBackButton(onPressed: () => backPressed = true), + trailing: GestureDetector( + onTap: () => imageTapped = true, + child: StreamChannelAvatar(size: .lg, channel: channel), + ), + title: GestureDetector( + onTap: () => titleTapped = true, + child: StreamChannelName(channel: channel), + ), ), ), ), @@ -443,68 +440,4 @@ void main() { expect(titleTapped, true); }, ); - - goldenTest( - 'golden test for StreamChannelHeader with bottom widget', - fileName: 'channel_header_bottom_widget', - constraints: const BoxConstraints.tightFor(width: 300, height: 60), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final user = OwnUser(id: 'user-id'); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); - when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); - when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); - when(() => channelState.unreadCount).thenReturn(1); - when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); - when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); - when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); - when(() => channelState.membersStream).thenAnswer( - (i) => Stream.value([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]), - ); - when(() => channelState.members).thenReturn([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]); - - return MaterialAppWrapper( - home: StreamChat( - client: client, - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: StreamChannel( - channel: channel, - child: const Scaffold( - body: StreamChannelHeader( - bottom: PreferredSize( - preferredSize: Size.fromHeight(1), - child: Divider(height: 1, color: Colors.red), - ), - ), - ), - ), - ), - ); - }, - ); } diff --git a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart index a66eef0f64..c6593b5f9c 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart @@ -2,7 +2,6 @@ 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'; import '../mocks.dart'; diff --git a/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart index 7a036b6b00..e23e450846 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart @@ -2,7 +2,6 @@ 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'; import '../mocks.dart'; @@ -31,7 +30,6 @@ void main() { final userAvatar = tester.widget(find.byType(StreamUserAvatar)); expect(userAvatar.user, clientState.currentUser); - expect(find.byType(StreamButton), findsOneWidget); expect(find.text('Stream Chat'), findsOneWidget); }, ); @@ -108,12 +106,9 @@ void main() { client: client, child: Scaffold( body: StreamChannelListHeader( - titleBuilder: (context, status, client) => const Text('TITLE'), + title: const Text('TITLE'), subtitle: const Text('SUBTITLE'), - leading: const Text('LEADING'), - actions: const [ - Text('ACTION'), - ], + trailing: const Text('ACTION'), client: client, ), ), @@ -124,46 +119,12 @@ void main() { expect(find.text('TITLE'), findsOneWidget); expect(find.text('SUBTITLE'), findsOneWidget); - expect(find.text('LEADING'), findsOneWidget); expect(find.text('ACTION'), findsOneWidget); }, ); testWidgets( - 'it should apply prenavigationcallback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: Scaffold( - body: StreamChannelListHeader( - preNavigationCallback: () { - tapped = true; - }, - ), - ), - ), - ), - ); - await tester.pump(); - - await tester.tap(find.byType(StreamUserAvatar)); - expect(tapped, true); - }, - ); - - testWidgets( - 'it should apply passed callbacks', + 'trailing slot receives caller-provided widget', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -172,19 +133,17 @@ void main() { when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); - var tapped = 0; + var trailingTapped = 0; await tester.pumpWidget( MaterialApp( home: StreamChat( client: client, child: Scaffold( body: StreamChannelListHeader( - onUserAvatarTap: (u) { - tapped++; - }, - onNewChatButtonTap: () { - tapped++; - }, + trailing: GestureDetector( + onTap: () => trailingTapped++, + child: const Text('trailing-slot'), + ), ), ), ), @@ -192,9 +151,8 @@ void main() { ); await tester.pump(); - await tester.tap(find.byType(StreamUserAvatar)); - await tester.tap(find.byIcon(StreamIconData.plus)); - expect(tapped, 2); + await tester.tap(find.text('trailing-slot')); + expect(trailingTapped, 1); }, ); } diff --git a/packages/stream_chat_flutter/test/src/channel/goldens/ci/channel_header_bottom_widget.png b/packages/stream_chat_flutter/test/src/channel/goldens/ci/channel_header_bottom_widget.png deleted file mode 100644 index 0edb3f2ff3..0000000000 Binary files a/packages/stream_chat_flutter/test/src/channel/goldens/ci/channel_header_bottom_widget.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart b/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart index a9f0b6b5f0..eed83d1393 100644 --- a/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart +++ b/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart @@ -8,7 +8,7 @@ import '../mocks.dart'; void main() { testWidgets( - 'it should show channel typing', + 'renders the photo with header and footer icons', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -83,25 +83,40 @@ void main() { ); await tester.pumpWidget( MaterialApp( - home: StreamChat( + builder: (context, child) => StreamChat( client: client, child: StreamChannel( channel: channel, - child: StreamFullScreenMedia( - mediaAttachmentPackages: [ - StreamAttachmentPackage( - attachment: attachment, - message: message, + child: child!, + ), + ), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamFullScreenMedia( + mediaAttachmentPackages: [ + StreamAttachmentPackage( + attachment: attachment, + message: message, + ), + ], + ), + ), ), - ], + child: const Text('Open media'), + ), ), ), ), ), ); - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); expect(find.byType(PhotoView), findsOneWidget); expect(find.byType(Icon), findsNWidgets(4)); diff --git a/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart b/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart index d467151397..a5b6460b81 100644 --- a/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart +++ b/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart @@ -1,6 +1,5 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -13,7 +12,6 @@ void main() { late MockClientState clientState; late MockChannel channel; late MockChannelState channelState; - const methodChannel = MethodChannel('dev.fluttercommunity.plus/connectivity_status'); setUpAll(() { client = MockClient(); @@ -39,41 +37,34 @@ void main() { }); }); - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(methodChannel, ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'listen') { - try { - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - methodChannel.name, - methodChannel.codec.encodeSuccessEnvelope(['wifi']), - (_) {}, - ); - } catch (e) { - print(e); - } - } - return null; - }); - }); - testWidgets( - 'it should show channel typing', + 'renders auto-implied back button alongside the overflow action', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( - home: StreamChat( + builder: (context, child) => StreamChat( client: client, + connectivityStream: .value([.wifi]), child: StreamChannel( channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: Scaffold( - appBar: StreamGalleryHeader( - attachment: MockAttachment(), - message: Message(), + child: child!, + ), + ), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + appBar: StreamGalleryHeader( + attachment: MockAttachment(), + message: Message(), + ), + ), + ), ), + child: const Text('Open gallery'), ), ), ), @@ -81,7 +72,8 @@ void main() { ), ); - // wait for the initial state to be rendered. + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); expect(find.byType(Icon), findsNWidgets(2)); @@ -96,6 +88,7 @@ void main() { return MaterialAppWrapper( home: StreamChat( client: client, + connectivityStream: .value([.wifi]), child: StreamChannel( channel: channel, child: PopScope( @@ -114,8 +107,4 @@ void main() { ); }, ); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(methodChannel, null); - }); } diff --git a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png index 0e60c0f7d1..e42f55979c 100644 Binary files a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png and b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png differ diff --git a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png index 5e5160bcff..8be074be20 100644 Binary files a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png and b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png differ diff --git a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart index 75dbc341a7..6adfe4c114 100644 --- a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart +++ b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart @@ -44,6 +44,9 @@ void main() { expect(translations.messageDeletedText, isNotNull); expect(translations.messageDeletedLabel, isNotNull); expect(translations.messageReactionsLabel, isNotNull); + // singular vs. plural — both branches exercised + expect(translations.reactionsCountText(1), isNotNull); + expect(translations.reactionsCountText(5), isNotNull); expect(translations.emptyChatMessagesText, isNotNull); expect(translations.threadSeparatorText(3), isNotNull); expect(translations.connectedLabel, isNotNull); diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/error_alert_sheet_test.dart b/packages/stream_chat_flutter/test/src/message_input/error_alert_sheet_test.dart similarity index 97% rename from packages/stream_chat_flutter/test/src/bottom_sheets/error_alert_sheet_test.dart rename to packages/stream_chat_flutter/test/src/message_input/error_alert_sheet_test.dart index 4915e5f6bb..32b08862e3 100644 --- a/packages/stream_chat_flutter/test/src/bottom_sheets/error_alert_sheet_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/error_alert_sheet_test.dart @@ -2,6 +2,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_input/error_alert_sheet.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../material_app_wrapper.dart'; diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/error_alert_sheet_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/error_alert_sheet_0.png similarity index 100% rename from packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/error_alert_sheet_0.png rename to packages/stream_chat_flutter/test/src/message_input/goldens/ci/error_alert_sheet_0.png diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png index af406a0fbf..6dcf57ca13 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png index 902391c99f..925a01fe36 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png index b2306639df..04cffd0976 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png index 1abd965c95..e8b83c313a 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart index c818e9c2f8..a51b819aab 100644 --- a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart @@ -55,7 +55,7 @@ void main() { child: Scaffold( body: StreamThreadHeader( parent: Message(replyCount: 1), - showTypingIndicator: false, + subtitle: const Text('1 reply'), ), ), ), @@ -66,7 +66,6 @@ void main() { // wait for the initial state to be rendered. await tester.pumpAndSettle(); - expect(find.byType(StreamBackButton), findsOneWidget); expect(find.text('1 reply'), findsOneWidget); expect(find.text('Thread'), findsOneWidget); }, @@ -125,13 +124,11 @@ void main() { parent: Message(), subtitle: const Text('subtitle'), leading: const Text('leading'), - title: const Text('title'), - onTitleTap: () { - tapped = true; - }, - actions: const [ - Text('action'), - ], + title: GestureDetector( + onTap: () => tapped = true, + child: const Text('title'), + ), + trailing: const Text('action'), ), ), ), diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png index 04f95055ad..0e2a283af0 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png index fe09de5466..86dfabfc01 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png index 0e050a2025..a9199e74b5 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png index d81f700482..6c25f83f31 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png index 759b0640d1..9c032a8708 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png index 7c9f792dc4..320721ae0d 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png index 068a7b731a..3ba1e274a9 100644 Binary files a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png index f3d96d6b98..942405a5ed 100644 Binary files a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png index 0ece714064..1a779cb39f 100644 Binary files a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png index f140acfcd1..e7fc9188c8 100644 Binary files a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png index 8172794fef..b9b5f2f76e 100644 Binary files a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png index 4299d0dca6..01cd62f57f 100644 Binary files a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png index f3d87824a5..89b449e71c 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png index c818e5acfb..f531ec7135 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png differ diff --git a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart deleted file mode 100644 index 65cd2b5a0f..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('ChannelHeaderThemeData copyWith, ==, hashCode basics', () { - expect(const StreamChannelHeaderThemeData(), const StreamChannelHeaderThemeData().copyWith()); - expect(const StreamChannelHeaderThemeData().hashCode, const StreamChannelHeaderThemeData().copyWith().hashCode); - }); - - group('ChannelHeaderThemeData lerps', () { - test('''Light ChannelHeaderThemeData lerps completely to dark ChannelHeaderThemeData''', () { - expect( - const StreamChannelHeaderThemeData().lerp(_channelThemeControl, _channelThemeControlDark, 1), - _channelThemeControlDark, - ); - }); - - test('''Light ChannelHeaderThemeData lerps halfway to dark ChannelHeaderThemeData''', () { - expect( - const StreamChannelHeaderThemeData().lerp( - _channelThemeControl, - _channelThemeControlDark, - 0.5, - ), - _channelThemeControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test('''Dark ChannelHeaderThemeData lerps completely to light ChannelHeaderThemeData''', () { - expect( - const StreamChannelHeaderThemeData().lerp(_channelThemeControlDark, _channelThemeControl, 1), - _channelThemeControl, - ); - }); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect(_channelThemeControl.merge(_channelThemeControlDark), _channelThemeControlDark); - }); -} - -final _channelThemeControl = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const Color(0xff101418), - titleStyle: const StreamTextTheme.light().headlineBold.copyWith( - color: const Color(0xffffffff), - ), - subtitleStyle: const StreamTextTheme.light().footnote.copyWith( - color: const Color(0xff7a7a7a), - ), -); - -final _channelThemeControlMidLerp = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const Color(0xff111417), - titleStyle: const TextStyle( - color: Color(0xffffffff), - fontWeight: FontWeight.w500, - fontSize: 16, - ), - subtitleStyle: const StreamTextTheme.light().footnote.copyWith( - color: const Color(0xff7a7a7a), - ), -); - -final _channelThemeControlDark = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const StreamColorTheme.dark().barsBg, - titleStyle: const StreamTextTheme.dark().headlineBold, - subtitleStyle: const StreamTextTheme.dark().footnote.copyWith( - color: const Color(0xff7A7A7A), - ), -); diff --git a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart deleted file mode 100644 index 883fb441be..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('ChannelListHeaderThemeData copyWith, ==, hashCode basics', () { - expect(const StreamChannelListHeaderThemeData(), const StreamChannelListHeaderThemeData().copyWith()); - expect( - const StreamChannelListHeaderThemeData().hashCode, - const StreamChannelListHeaderThemeData().copyWith().hashCode, - ); - }); - - group('ChannelListHeaderThemeData lerps', () { - test('''Light ChannelListHeaderThemeData lerps completely to dark ChannelListHeaderThemeData''', () { - expect( - const StreamChannelListHeaderThemeData().lerp( - _channelListHeaderThemeControl, - _channelListHeaderThemeControlDark, - 1, - ), - _channelListHeaderThemeControlDark, - ); - }); - - test('''Light ChannelListHeaderThemeData lerps halfway to dark ChannelListHeaderThemeData''', () { - expect( - const StreamChannelListHeaderThemeData().lerp( - _channelListHeaderThemeControl, - _channelListHeaderThemeControlDark, - 0.5, - ), - _channelListHeaderThemeControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test('''Dark ChannelListHeaderThemeData lerps completely to light ChannelListHeaderThemeData''', () { - expect( - const StreamChannelListHeaderThemeData().lerp( - _channelListHeaderThemeControlDark, - _channelListHeaderThemeControl, - 1, - ), - _channelListHeaderThemeControl, - ); - }); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _channelListHeaderThemeControl.merge(_channelListHeaderThemeControlDark), - _channelListHeaderThemeControlDark, - ); - }); -} - -final _channelListHeaderThemeControl = StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const StreamColorTheme.light().barsBg, - titleStyle: const StreamTextTheme.light().headlineBold, -); - -final _channelListHeaderThemeControlMidLerp = StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const Color(0xff88898a), - titleStyle: const TextStyle( - color: Color(0xff7f7f7f), - fontSize: 16, - fontWeight: FontWeight.w500, - ), -); - -final _channelListHeaderThemeControlDark = StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const StreamColorTheme.dark().barsBg, - titleStyle: const StreamTextTheme.dark().headlineBold, -); diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart deleted file mode 100644 index 3f5a718c99..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart +++ /dev/null @@ -1,143 +0,0 @@ -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'; - -class MockStreamChatClient extends Mock implements StreamChatClient {} - -void main() { - test('GalleryHeaderThemeData copyWith, ==, hashCode basics', () { - expect(const StreamGalleryHeaderThemeData(), const StreamGalleryHeaderThemeData().copyWith()); - expect(const StreamGalleryHeaderThemeData().hashCode, const StreamGalleryHeaderThemeData().copyWith().hashCode); - }); - - test('''Light GalleryHeaderThemeData lerps completely to dark GalleryHeaderThemeData''', () { - expect( - const StreamGalleryHeaderThemeData().lerp(_galleryHeaderThemeDataControl, _galleryHeaderThemeDataDarkControl, 1), - _galleryHeaderThemeDataDarkControl, - ); - }); - - test('''Light GalleryHeaderThemeData lerps halfway to dark GalleryHeaderThemeData''', () { - expect( - const StreamGalleryHeaderThemeData().lerp( - _galleryHeaderThemeDataControl, - _galleryHeaderThemeDataDarkControl, - 0.5, - ), - _galleryHeaderThemeDataHalfLerpControl, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test('''Dark GalleryHeaderThemeData lerps completely to light GalleryHeaderThemeData''', () { - expect( - const StreamGalleryHeaderThemeData().lerp(_galleryHeaderThemeDataDarkControl, _galleryHeaderThemeDataControl, 1), - _galleryHeaderThemeDataControl, - ); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _galleryHeaderThemeDataControl.merge(_galleryHeaderThemeDataDarkControl), - _galleryHeaderThemeDataDarkControl, - ); - }); - - testWidgets('Passing no GalleryHeaderThemeData returns default light theme values', (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageHeaderTheme = StreamGalleryHeaderTheme.of(_context); - expect(imageHeaderTheme.closeButtonColor, _galleryHeaderThemeDataControl.closeButtonColor); - expect(imageHeaderTheme.backgroundColor, _galleryHeaderThemeDataControl.backgroundColor); - expect(imageHeaderTheme.iconMenuPointColor, _galleryHeaderThemeDataControl.iconMenuPointColor); - expect(imageHeaderTheme.titleTextStyle, _galleryHeaderThemeDataControl.titleTextStyle); - expect(imageHeaderTheme.subtitleTextStyle, _galleryHeaderThemeDataControl.subtitleTextStyle); - expect(imageHeaderTheme.bottomSheetBarrierColor, _galleryHeaderThemeDataControl.bottomSheetBarrierColor); - }); - - testWidgets('Passing no GalleryHeaderThemeData returns default dark theme values', (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - streamChatThemeData: StreamChatThemeData.dark(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageHeaderTheme = StreamGalleryHeaderTheme.of(_context); - expect(imageHeaderTheme.closeButtonColor, _galleryHeaderThemeDataDarkControl.closeButtonColor); - expect(imageHeaderTheme.backgroundColor, _galleryHeaderThemeDataDarkControl.backgroundColor); - expect(imageHeaderTheme.iconMenuPointColor, _galleryHeaderThemeDataDarkControl.iconMenuPointColor); - expect(imageHeaderTheme.titleTextStyle, _galleryHeaderThemeDataDarkControl.titleTextStyle); - expect(imageHeaderTheme.subtitleTextStyle, _galleryHeaderThemeDataDarkControl.subtitleTextStyle); - expect(imageHeaderTheme.bottomSheetBarrierColor, _galleryHeaderThemeDataDarkControl.bottomSheetBarrierColor); - }); -} - -// Light theme test control. -const _galleryHeaderThemeDataControl = StreamGalleryHeaderThemeData( - closeButtonColor: Color(0xff000000), - backgroundColor: Color(0xffffffff), - iconMenuPointColor: Color(0xff000000), - titleTextStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - subtitleTextStyle: null, - bottomSheetBarrierColor: Color.fromRGBO(0, 0, 0, 0.2), -); - -// Light theme test control. -const _galleryHeaderThemeDataHalfLerpControl = StreamGalleryHeaderThemeData( - closeButtonColor: Color(0xff7f7f7f), - backgroundColor: Color(0xff88898a), - iconMenuPointColor: Color(0xff7f7f7f), - titleTextStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xff7f7f7f), - ), - subtitleTextStyle: null, - bottomSheetBarrierColor: Color(0x4c000000), -); - -// Dark theme test control. -const _galleryHeaderThemeDataDarkControl = StreamGalleryHeaderThemeData( - closeButtonColor: Color(0xffffffff), - backgroundColor: Color(0xff121416), - iconMenuPointColor: Color(0xffffffff), - titleTextStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - subtitleTextStyle: null, - bottomSheetBarrierColor: Color.fromRGBO(0, 0, 0, 0.4), -); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index b8de2eeb5d..50af5ccea0 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -29,6 +29,7 @@ - Added `viewAllLabel` translation for all supported locales. - Added `pollVotesLabel` translation for all supported locales. - Added `endVoteConfirmationMessage` translation for all supported locales. +- Added `reactionsCountText(int count)` translation for all supported locales. 🔄 Changed diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 19b2be6011..03b4421369 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -806,6 +806,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'Tap to remove'; + @override + String reactionsCountText(int count) => count == 1 ? '1 Reaction' : '$count Reactions'; + @override String get justNowLabel => 'Just now'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index c4678d4852..e06e8051fc 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -780,6 +780,9 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'Toca per eliminar'; + @override + String reactionsCountText(int count) => '$count reaccions'; + @override String get justNowLabel => 'Ara mateix'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index a8ecf96331..f3fa63af7c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -778,6 +778,9 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'Zum Entfernen tippen'; + @override + String reactionsCountText(int count) => '$count Reaktionen'; + @override String get justNowLabel => 'Gerade eben'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 95dfd1e022..4e0f127f58 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -778,6 +778,9 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'Tap to remove'; + @override + String reactionsCountText(int count) => count == 1 ? '1 Reaction' : '$count Reactions'; + @override String get justNowLabel => 'Just now'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index a22c3400e8..5c504bb93f 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -782,6 +782,9 @@ No es posible añadir más de $limit archivos adjuntos @override String get tapToRemoveReactionLabel => 'Toca para eliminar'; + @override + String reactionsCountText(int count) => '$count reacciones'; + @override String get justNowLabel => 'Ahora mismo'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index ca4a6bfa5d..583bedf922 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -784,6 +784,9 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get tapToRemoveReactionLabel => 'Appuyer pour supprimer'; + @override + String reactionsCountText(int count) => '$count Réactions'; + @override String get justNowLabel => "À l'instant"; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 16418da714..bb55615abc 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -780,6 +780,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'हटाने के लिए टैप करें'; + @override + String reactionsCountText(int count) => '$count प्रतिक्रियाएँ'; + @override String get justNowLabel => 'अभी अभी'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 36516e371c..4c2eb13809 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -787,6 +787,14 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get tapToRemoveReactionLabel => 'Tocca per rimuovere'; + @override + String reactionsCountText(int count) { + if (count == 1) { + return '1 Reazione'; + } + return '$count Reazioni'; + } + @override String get justNowLabel => 'Proprio ora'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index a982903d85..366772a388 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -760,6 +760,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'タップして削除'; + @override + String reactionsCountText(int count) => '$count件のリアクション'; + @override String get justNowLabel => 'たった今'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 42291c489b..2cbeec63e7 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -763,6 +763,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => '탭하여 제거'; + @override + String reactionsCountText(int count) => '반응 $count개'; + @override String get justNowLabel => '방금'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index b1d59a9593..445af3725e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -763,6 +763,9 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get tapToRemoveReactionLabel => 'Trykk for å fjerne'; + @override + String reactionsCountText(int count) => '$count reaksjoner'; + @override String get justNowLabel => 'Akkurat nå'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 92be1f8ff9..76cc5f8500 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -781,6 +781,9 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get tapToRemoveReactionLabel => 'Toque para remover'; + @override + String reactionsCountText(int count) => '$count reações'; + @override String get justNowLabel => 'Agora mesmo'; diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 660be89f1f..992cbfff17 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -352,6 +352,9 @@ void main() { expect(localizations.emptyReactionsText, isNotNull); expect(localizations.loadingReactionsError, isNotNull); expect(localizations.tapToRemoveReactionLabel, isNotNull); + // singular vs. plural — both branches exercised + expect(localizations.reactionsCountText(1), isNotNull); + expect(localizations.reactionsCountText(5), isNotNull); expect(localizations.justNowLabel, isNotNull); expect(localizations.replyToUserLabel('TestUser'), isNotNull); expect(localizations.multipleAnswersDescription, isNotNull); diff --git a/sample_app/android/.ruby-version b/sample_app/android/.ruby-version index fd2a01863f..3b47f2e4f8 100644 --- a/sample_app/android/.ruby-version +++ b/sample_app/android/.ruby-version @@ -1 +1 @@ -3.1.0 +3.3.9 diff --git a/sample_app/ios/.ruby-version b/sample_app/ios/.ruby-version index fd2a01863f..3b47f2e4f8 100644 --- a/sample_app/ios/.ruby-version +++ b/sample_app/ios/.ruby-version @@ -1 +1 @@ -3.1.0 +3.3.9 diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index b1e78bd3c9..f6bfffa0e8 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -10,21 +10,12 @@ class SampleAppConfigScreen extends StatelessWidget { Widget build(BuildContext context) { final config = context.sampleAppConfig; final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; final spacing = context.streamSpacing; final icons = context.streamIcons; return Scaffold( backgroundColor: colorScheme.backgroundApp, - appBar: AppBar( - title: Text( - 'Configuration', - style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), - ), - backgroundColor: colorScheme.backgroundSurfaceCard, - surfaceTintColor: Colors.transparent, - iconTheme: IconThemeData(color: colorScheme.textPrimary), - ), + appBar: StreamAppBar(title: const Text('Configuration')), body: SingleChildScrollView( padding: EdgeInsets.symmetric(horizontal: spacing.md), child: Column( @@ -453,12 +444,13 @@ class _LocaleRow extends StatelessWidget { fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), - subtitle: item.code != null - ? Text( - item.code!, - style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), - ) - : null, + subtitle: switch (item.code) { + final code? => Text( + code, + style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), + ), + null => null, + }, trailing: StreamCheckbox.circular( value: isSelected, size: StreamCheckboxSize.sm, diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index 9c7d7d40f9..d6fc35e73f 100644 --- a/sample_app/lib/pages/advanced_options_page.dart +++ b/sample_app/lib/pages/advanced_options_page.dart @@ -102,24 +102,7 @@ class _AdvancedOptionsPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - elevation: 1, - centerTitle: true, - title: Text( - 'Custom settings', - style: StreamChatTheme.of( - context, - ).textTheme.headlineBold.copyWith(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis), - ), - leading: IconButton( - icon: Icon(context.streamIcons.chevronLeft), - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - onPressed: () { - Navigator.pop(context); - }, - ), - ), + appBar: StreamAppBar(title: const Text('Custom settings')), body: Builder( builder: (context) { return Padding( diff --git a/sample_app/lib/pages/channel_file_display_screen.dart b/sample_app/lib/pages/channel_file_display_screen.dart index ee11f065dd..5cc931d83a 100644 --- a/sample_app/lib/pages/channel_file_display_screen.dart +++ b/sample_app/lib/pages/channel_file_display_screen.dart @@ -1,10 +1,14 @@ -// ignore_for_file: deprecated_member_use - import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; +/// Lists every file shared in the enclosing channel, grouped by the +/// month each file was sent. +/// +/// Matches Figma frames `8833:437706` (with content) and `8833:437407` +/// (empty). Built on the same [StreamMessageSearchListController] the +/// legacy implementation used, filtered to `attachments.type == 'file'`. class ChannelFileDisplayScreen extends StatefulWidget { + /// Creates a [ChannelFileDisplayScreen]. const ChannelFileDisplayScreen({super.key}); @override @@ -12,132 +16,221 @@ class ChannelFileDisplayScreen extends StatefulWidget { } class _ChannelFileDisplayScreenState extends State { - final Map controllerCache = {}; - - late final controller = StreamMessageSearchListController( + late final StreamMessageSearchListController _controller = StreamMessageSearchListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'cid', - [StreamChannel.of(context).channel.cid!], - ), - messageFilter: Filter.in_( - 'attachments.type', - const ['file'], - ), - sort: [ - const SortOption.asc('created_at'), - ], + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.in_('attachments.type', const ['file']), + sort: const [SortOption.desc('created_at')], limit: 20, ); + @override + void initState() { + super.initState(); + _controller.doInitialLoad(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - 'Files', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Files')), + body: ValueListenableBuilder>( + valueListenable: _controller, + builder: (context, value, _) => value.when( + (items, nextPageKey, _) { + // Flatten messages → individual file attachments paired with + // the message timestamp we'll bucket on. + final entries = <_FileEntry>[ + for (final response in items) + for (final attachment in response.message.attachments) + if (attachment.type == 'file') + _FileEntry( + attachment: attachment, + sentAt: response.message.createdAt, + ), + ]; + + if (entries.isEmpty) return const Center(child: _EmptyState()); + + // Pre-build a flat row list — interleave a header row above + // each month bucket so a single ListView.builder can render + // both kinds of rows without a CustomScrollView + slivers. + final rows = _buildRows(entries); + + return LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: ListView.builder( + itemCount: rows.length, + itemBuilder: (context, index) => rows[index].build(context), + ), + ); + }, + loading: () => const Center(child: StreamScrollViewLoadingWidget()), + error: (_) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: const Text('Failed to load files'), + onRetryPressed: _controller.refresh, + ), ), ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, ), - body: ValueListenableBuilder( - valueListenable: controller, - builder: - ( - BuildContext context, - PagedValue value, - Widget? child, - ) { - return value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - context.streamIcons.file, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - 'No Files', - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - ), - ), - const SizedBox(height: 8), - Text( - 'Files sent in this chat will appear here', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ], - ), - ); - } - final media = {}; - - for (final item in items) { - item.message.attachments.where((e) => e.type == 'file').forEach((e) { - media[e] = item.message; - }); - } - - return LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) { - controller.loadMore(nextPageKey); - } - }, - child: ListView.builder( - itemBuilder: (context, position) { - return Padding( - padding: const EdgeInsets.all(1), - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamFileAttachment( - message: media.values.toList()[position], - file: media.keys.toList()[position], - ), - ), - ); - }, - itemCount: media.length, - ), - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (_) => const Offstage(), - ); - }, + ); + } + + List<_Row> _buildRows(List<_FileEntry> entries) { + final rows = <_Row>[]; + DateTime? currentBucket; + for (final entry in entries) { + final bucket = entry.sentAt == null ? null : DateTime(entry.sentAt!.year, entry.sentAt!.month); + if (bucket != currentBucket) { + currentBucket = bucket; + rows.add(_HeaderRow(bucket)); + } + rows.add(_FileRow(entry.attachment)); + } + return rows; + } +} + +/// Single file attachment paired with the message timestamp used to +/// bucket it under a month header. +class _FileEntry { + const _FileEntry({required this.attachment, required this.sentAt}); + + final Attachment attachment; + final DateTime? sentAt; +} + +/// Row shape interface — the [ListView.builder] doesn't care whether it +/// renders a section header or a file row, only that both can build +/// themselves. +sealed class _Row { + Widget build(BuildContext context); +} + +/// Section header above each month bucket — light surface background, +/// bold "Month YYYY" caption (or "Earlier" for entries with no +/// timestamp). +class _HeaderRow implements _Row { + const _HeaderRow(this.bucket); + + final DateTime? bucket; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + final label = bucket == null ? 'Earlier' : Jiffy.parseFromDateTime(bucket!).format(pattern: 'MMMM yyyy'); + + return Container( + color: colorScheme.backgroundSurfaceCard, + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xs, + ), + width: double.infinity, + child: Text( + label, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), ), ); } +} + +/// Single file row — leading [StreamFileTypeIcon], filename title, +/// human-readable file size subtitle. Pure preview; tapping doesn't +/// open the file (mirrors the legacy implementation). +class _FileRow implements _Row { + const _FileRow(this.attachment); + + final Attachment attachment; @override - void dispose() { - controller.dispose(); - super.dispose(); + Widget build(BuildContext context) { + return StreamListTile( + leading: StreamFileTypeIcon.fromMimeType( + mimeType: attachment.mimeType, + size: StreamFileTypeIconSize.lg, + ), + title: Text( + attachment.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(_humanizeBytes(attachment.fileSize)), + ); } +} + +/// Empty state for [ChannelFileDisplayScreen] — folder icon, "No files" +/// headline, and a textSecondary subtitle ("Share a file to see it +/// here"). +class _EmptyState extends StatelessWidget { + const _EmptyState(); @override - void initState() { - controller.doInitialLoad(); - super.initState(); + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + context.streamIcons.folder, + size: 32, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.sm), + Text( + 'No files', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + SizedBox(height: spacing.xxs), + Text( + 'Share a file to see it here', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +/// Pretty-prints a byte count as `B` / `KB` / `MB` / `GB`. Returns an +/// empty string when [bytes] is null so the subtitle row collapses +/// rather than showing a noisy placeholder. +String _humanizeBytes(int? bytes) { + if (bytes == null) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + var size = bytes.toDouble(); + var unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit++; } + // Whole-number formatting once we're past kilobytes — matches the + // Figma's "4 MB" / "5 MB" style. Bytes / KB are rare enough in + // practice that the extra precision isn't useful. + final formatted = size >= 10 || unit == 0 ? size.toStringAsFixed(0) : size.toStringAsFixed(1); + return '$formatted ${units[unit]}'; } diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 48095179bf..9e126e0a1b 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -77,9 +77,7 @@ class _ChannelListPageState extends State { return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamChannelListHeader( - titleBuilder: (_, __, ___) => Text(enabledTabs[_currentIndex].label, style: textTheme.headingSm), - onNewChatButtonTap: () => GoRouter.of(context).pushNamed(Routes.NEW_CHAT.name), - preNavigationCallback: () => FocusScope.of(context).requestFocus(FocusNode()), + title: Text(enabledTabs[_currentIndex].label, style: textTheme.headingSm), ), drawer: LeftDrawer(user: user), bottomNavigationBar: DecoratedBox( diff --git a/sample_app/lib/pages/channel_media_display_screen.dart b/sample_app/lib/pages/channel_media_display_screen.dart index 9a82291b29..fb65e46148 100644 --- a/sample_app/lib/pages/channel_media_display_screen.dart +++ b/sample_app/lib/pages/channel_media_display_screen.dart @@ -1,12 +1,15 @@ -// ignore_for_file: deprecated_member_use - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; +/// Lists every photo + video shared in the enclosing channel as a 3-up +/// grid. Tapping a tile opens the [StreamFullScreenMedia] gallery. +/// +/// Matches Figma frames `8833:437788` (grid), `13495:418984` (scrolled), +/// and `8833:437329` (empty). class ChannelMediaDisplayScreen extends StatefulWidget { + /// Creates a [ChannelMediaDisplayScreen]. const ChannelMediaDisplayScreen({super.key}); @override @@ -14,189 +17,187 @@ class ChannelMediaDisplayScreen extends StatefulWidget { } class _ChannelMediaDisplayScreenState extends State { - final Map controllerCache = {}; - - late final controller = StreamMessageSearchListController( + late final StreamMessageSearchListController _controller = StreamMessageSearchListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'cid', - [StreamChannel.of(context).channel.cid!], - ), - messageFilter: Filter.in_( - 'attachments.type', - const ['image', 'video'], - ), - sort: [ - const SortOption.asc('created_at'), - ], + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.in_('attachments.type', const ['image', 'video']), + sort: const [SortOption.asc('created_at')], limit: 20, ); + @override + void initState() { + super.initState(); + _controller.doInitialLoad(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - 'Photos & Videos', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - ), - body: ValueListenableBuilder( - valueListenable: controller, - builder: (BuildContext context, PagedValue value, Widget? child) { - return value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - context.streamIcons.imageLarge, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - 'No Media', - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - ), - ), - const SizedBox(height: 8), - Text( - 'Photos or videos sent in this chat will \nappear here', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ], - ), - ); - } - final media = <_AssetPackage>[]; - - for (final item in value.asSuccess.items) { - item.message.attachments - .where((e) => (e.type == 'image' || e.type == 'video') && e.ogScrapeUrl == null) - .forEach((e) { - VideoPlayerController? controller; - if (e.type == 'video') { - final cachedController = controllerCache[e.assetUrl]; - - if (cachedController == null) { - final url = Uri.parse(e.assetUrl!); - controller = VideoPlayerController.networkUrl(url); - controller.initialize(); - controllerCache[e.assetUrl] = controller; - } else { - controller = cachedController; - } - } - media.add(_AssetPackage(e, item.message, controller)); - }); - } - - return LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) { - controller.loadMore(nextPageKey); - } - }, - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), - itemBuilder: (context, position) { - final channel = StreamChannel.of(context).channel; - return Padding( - padding: const EdgeInsets.all(1), - child: InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: StreamFullScreenMedia( - mediaAttachmentPackages: media - .map( - (e) => StreamAttachmentPackage( - attachment: e.attachment, - message: e.message, - ), - ) - .toList(), - startIndex: position, - userName: media[position].message.user!.name, - onShowMessage: (m, c) async { - final router = GoRouter.of(context); - if (channel.state == null) { - await channel.watch(); - } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(m), - ); - }, - ), - ), - ), - ); - }, - child: media[position].attachment.type == 'image' - ? IgnorePointer( - child: StreamImageAttachment( - image: media[position].attachment, - message: media[position].message, - // showTitle: false, - // messageTheme: widget.messageTheme, - ), - ) - : VideoPlayer(media[position].videoPlayer!), - ), - ); - }, - itemCount: media.length, + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Photos & Videos')), + body: ValueListenableBuilder>( + valueListenable: _controller, + builder: (context, value, _) => value.when( + (items, nextPageKey, _) { + // Flatten messages → individual image/video attachments. + // Excludes link previews (`ogScrapeUrl != null`) so we don't + // render every shared URL's thumbnail in the grid. + final media = <_MediaItem>[ + for (final response in items) + for (final attachment in response.message.attachments) + if ((attachment.type == 'image' || attachment.type == 'video') && attachment.ogScrapeUrl == null) + _MediaItem(attachment, response.message), + ]; + + if (media.isEmpty) return const Center(child: _EmptyState()); + + return LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 1, + crossAxisSpacing: 1, ), - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), + itemCount: media.length, + itemBuilder: (context, index) => _MediaTile( + index: index, + items: media, + ), + ), + ); + }, + loading: () => const Center(child: StreamScrollViewLoadingWidget()), + error: (_) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: const Text('Failed to load media'), + onRetryPressed: _controller.refresh, ), - error: (_) => const Offstage(), - ); - }, + ), + ), ), ); } +} + +/// Single attachment + its enclosing message — paired so the full-screen +/// gallery can show sender / timestamp metadata when opened. +class _MediaItem { + const _MediaItem(this.attachment, this.message); + + final Attachment attachment; + final Message message; +} + +/// One cell in the photo grid. Renders the attachment's thumbnail +/// (image or video) via [StreamNetworkImage]; videos overlay a +/// [StreamVideoPlayIndicator]. Tapping opens the full-screen gallery +/// at this index — every other media item in the channel is wired up +/// as a swipeable sibling. +class _MediaTile extends StatelessWidget { + const _MediaTile({required this.index, required this.items}); + + final int index; + final List<_MediaItem> items; @override - void dispose() { - controller.dispose(); - super.dispose(); + Widget build(BuildContext context) { + final item = items[index]; + final attachment = item.attachment; + final isVideo = attachment.type == 'video'; + final thumbUrl = attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; + + return GestureDetector( + onTap: () => _open(context), + child: Stack( + fit: StackFit.expand, + children: [ + if (thumbUrl != null) + StreamNetworkImage(thumbUrl, fit: BoxFit.cover) + else + ColoredBox(color: context.streamColorScheme.backgroundSurfaceCard), + if (isVideo) const Center(child: StreamVideoPlayIndicator()), + ], + ), + ); } - @override - void initState() { - controller.doInitialLoad(); - super.initState(); + void _open(BuildContext context) { + final channel = StreamChannel.of(context).channel; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamFullScreenMedia( + mediaAttachmentPackages: [ + for (final m in items) StreamAttachmentPackage(attachment: m.attachment, message: m.message), + ], + startIndex: index, + userName: items[index].message.user?.name ?? '', + onShowMessage: (message, _) async { + final router = GoRouter.of(context); + if (channel.state == null) await channel.watch(); + router.pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: Routes.CHANNEL_PAGE.queryParams(message), + ); + }, + ), + ), + ), + ); } } -class _AssetPackage { - _AssetPackage(this.attachment, this.message, this.videoPlayer); - Attachment attachment; - Message message; - VideoPlayerController? videoPlayer; +/// Empty state for [ChannelMediaDisplayScreen] — image icon, "No photos +/// or videos" headline, and a centered subtitle ("Share a photo or +/// video to see it here"). +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + context.streamIcons.image, + size: 32, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.sm), + Text( + 'No photos or videos', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + SizedBox(height: spacing.xxs), + Text( + 'Share a photo or video to see it here', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); + } } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index adca6ef203..8935feffa0 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -1,6 +1,5 @@ // ignore_for_file: deprecated_member_use, avoid_redundant_argument_values -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/config/sample_app_config.dart'; @@ -67,30 +66,14 @@ class _ChannelPageState extends State { return Scaffold( backgroundColor: colorTheme.appBg, appBar: StreamChannelHeader( - showTypingIndicator: false, - onBackPressed: () => GoRouter.of(context).pop(), - onImageTap: () async { - final channel = StreamChannel.of(context).channel; - final router = GoRouter.of(context); - - if (channel.memberCount == 2 && channel.isDistinct) { - final currentUser = StreamChat.of(context).currentUser; - final otherUser = channel.state!.members.firstWhereOrNull( - (element) => element.user!.id != currentUser!.id, - ); - if (otherUser != null) { - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), - extra: otherUser.user, - ); - } - } else { - GoRouter.of(context).pushNamed( - Routes.GROUP_INFO_SCREEN.name, - pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), - ); - } + onChannelAvatarPressed: (channel) { + final isOneToOne = channel.isOneToOne; + final currentUserId = StreamChat.of(context).currentUser?.id; + + final channelMembers = channel.state?.members ?? []; + final otherUser = isOneToOne ? channelMembers.firstWhere((m) => m.userId != currentUserId).user : null; + + _pushChannelInfo(context, channel, otherUser); }, ), body: Column( @@ -204,3 +187,24 @@ class _ChannelPageState extends State { return channel.sendStaticLocation(location: result.coordinates); } } + +// Pushes the chat / group info screen depending on whether [user] was +// resolved. 1-1 channels pass the other member here (forwarded as `extra` +// to the chat-info route); group channels pass `null` and route to the +// group info screen. +Future _pushChannelInfo(BuildContext context, Channel channel, User? user) { + final router = GoRouter.of(context); + + if (user != null) { + return router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: user, + ); + } + + return router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); +} diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 095bd6b065..912a788111 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -1,532 +1,372 @@ -// ignore_for_file: deprecated_member_use - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:sample_app/pages/channel_file_display_screen.dart'; import 'package:sample_app/pages/channel_media_display_screen.dart'; import 'package:sample_app/pages/pinned_messages_screen.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// Detail screen for a 1:1 chat correspondence -class ChatInfoScreen extends StatefulWidget { - const ChatInfoScreen({ - super.key, - this.user, - }); - - /// User in consideration +/// Detail screen for a 1:1 chat correspondence. +/// +/// Surfaces the other party's avatar, name, online status, plus channel +/// shortcuts (pinned messages, media, files) and conversation actions +/// (mute, block, delete) — see Figma frame `8833:431680`. +class ChatInfoScreen extends StatelessWidget { + /// Creates a [ChatInfoScreen]. + const ChatInfoScreen({super.key, this.user}); + + /// The other user in the conversation. + /// + /// Required at runtime — the screen renders an [Offstage] when null so + /// callers don't need to thread an additional null check. final User? user; @override - State createState() => _ChatInfoScreenState(); -} - -class _ChatInfoScreenState extends State { - ValueNotifier mutedBool = ValueNotifier(false); + Widget build(BuildContext context) { + final user = this.user; + if (user == null) return const Offstage(); - @override - void initState() { - super.initState(); - mutedBool = ValueNotifier(StreamChannel.of(context).channel.isMuted); - } + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - body: ListView( - children: [ - _buildUserHeader(), - Container( - height: 8, - color: StreamChatTheme.of(context).colorTheme.disabled, + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Contact Info')), + // Action / chevron icons share a uniform 20px size — set once at the + // top of the body so individual rows stay style-free. + body: IconTheme.merge( + data: const IconThemeData(size: 20), + child: SingleChildScrollView( + padding: .directional( + top: spacing.xxl, + bottom: spacing.xxxl, + start: spacing.md, + end: spacing.md, ), - _buildOptionListTiles(), - Container( - height: 8, - color: StreamChatTheme.of(context).colorTheme.disabled, + child: Column( + mainAxisSize: .min, + children: [ + _ContactInfoHeader(user: user), + SizedBox(height: spacing.xxl), + const _MediaSection(), + SizedBox(height: spacing.md), + const _ActionsSection(), + ], ), - if (channel.canDeleteChannel) _buildDeleteListTile(), - ], - ), - ); - } - - Widget _buildUserHeader() { - return Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: SafeArea( - child: Stack( - children: [ - Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: StreamUserAvatar( - size: .xl, - user: widget.user!, - showOnlineIndicator: false, - ), - ), - Text( - widget.user!.name, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 7), - _buildConnectedTitleState(), - const SizedBox(height: 15), - StreamOptionListTile( - title: '@${widget.user!.id}', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - trailing: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - widget.user!.name, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - fontSize: 16, - ), - ), - ), - onTap: () {}, - ), - ], - ), - const Positioned( - top: 0, - left: 0, - width: 58, - child: StreamBackButton(), - ), - ], ), ), ); } +} - Widget _buildOptionListTiles() { - final channel = StreamChannel.of(context); +/// Hero header — large avatar with optional online indicator, name with an +/// inline mute-state icon, and an online / last-seen subtitle. +class _ContactInfoHeader extends StatelessWidget { + const _ContactInfoHeader({required this.user}); + + final User user; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final channel = StreamChannel.of(context).channel; return Column( children: [ - StreamBuilder( - stream: StreamChannel.of(context).channel.isMutedStream, - builder: (context, snapshot) { - mutedBool.value = snapshot.data; - - return StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - title: 'Mute user', - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: Icon( - context.streamIcons.mute, - size: 24, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - trailing: snapshot.data == null - ? const CircularProgressIndicator() - : ValueListenableBuilder( - valueListenable: mutedBool, - builder: (context, value, _) { - return CupertinoSwitch( - value: value!, - onChanged: (val) { - mutedBool.value = val; - - if (snapshot.data!) { - channel.channel.unmute(); - } else { - channel.channel.mute(); - } - }, - ); - }, - ), - onTap: () {}, - ); - }, + StreamUserAvatar( + user: user, + size: .xxl, + showOnlineIndicator: user.online, ), - StreamOptionListTile( - title: 'Pinned Messages', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: Icon( - context.streamIcons.pin, - size: 24, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - trailing: Icon( - context.streamIcons.chevronRight, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const PinnedMessagesScreen(), - ), + SizedBox(height: spacing.md), + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + Flexible( + child: Text( + user.name, + style: textTheme.headingLg.copyWith(color: colorScheme.textPrimary), + overflow: TextOverflow.ellipsis, ), - ); - }, - ), - StreamOptionListTile( - title: 'Photos & Videos', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - context.streamIcons.imageLarge, - size: 36, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), ), - ), - trailing: Icon( - context.streamIcons.chevronRight, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const ChannelMediaDisplayScreen(), - ), - ), - ); - }, - ), - StreamOptionListTile( - title: 'Files', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18), - child: Icon( - context.streamIcons.file, - size: 32, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) { + if (!isMuted) return const SizedBox.shrink(); + return Icon( + context.streamIcons.mute, + color: colorScheme.textTertiary, + ); + }, ), - ), - trailing: Icon( - context.streamIcons.chevronRight, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const ChannelFileDisplayScreen(), - ), - ), - ); - }, + ], ), - StreamOptionListTile( - title: 'Shared Groups', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: Icon( - context.streamIcons.users, - size: 24, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - trailing: Icon( - context.streamIcons.chevronRight, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => _SharedGroupsScreen(StreamChat.of(context).currentUser, widget.user), - ), - ); - }, + SizedBox(height: spacing.xs), + Text( + _onlineLabel(user), + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), ), ], ); } - Widget _buildDeleteListTile() { - return StreamOptionListTile( - title: 'Delete Conversation', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentError, - ), - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: Icon( - context.streamIcons.delete, - size: 24, - color: StreamChatTheme.of(context).colorTheme.accentError, + String _onlineLabel(User user) { + if (user.online) return 'Online'; + final lastActive = user.lastActive; + if (lastActive == null) return 'Offline'; + return 'Last seen ${Jiffy.parseFromDateTime(lastActive).fromNow()}'; + } +} + +/// Card grouping the read-only channel-content shortcuts (pinned, media, +/// files). +class _MediaSection extends StatelessWidget { + const _MediaSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + return _Section( + children: [ + _Tile( + icon: icons.pin, + label: 'Pinned Messages', + onTap: () => _push(context, const PinnedMessagesScreen()), ), - ), - onTap: _showDeleteDialog, - titleColor: StreamChatTheme.of(context).colorTheme.accentError, + _Tile( + icon: icons.image, + label: 'Photos & Videos', + onTap: () => _push(context, const ChannelMediaDisplayScreen()), + ), + _Tile( + icon: icons.folder, + label: 'Files', + onTap: () => _push(context, const ChannelFileDisplayScreen()), + ), + ], ); } - void _showDeleteDialog() async { - final streamChannel = StreamChannel.of(context); - final res = await showConfirmationBottomSheet( - context, - title: 'Delete Conversation', - okText: 'Delete'.toUpperCase(), - question: 'Are you sure you want to delete this conversation?', - cancelText: 'Cancel'.toUpperCase(), - icon: Icon( - context.streamIcons.delete, - color: StreamChatTheme.of(context).colorTheme.accentError, + void _push(BuildContext context, Widget destination) { + final channel = StreamChannel.of(context).channel; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel(channel: channel, child: destination), ), ); - final channel = streamChannel.channel; - if (res == true) { - await channel.delete().then((value) { - Navigator.pop(context); - Navigator.pop(context); - }); - } } +} - Widget _buildConnectedTitleState() { - late Text alternativeWidget; - - final otherMember = widget.user; - - if (otherMember != null) { - if (otherMember.online) { - alternativeWidget = Text( - 'Online', - style: TextStyle(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5)), - ); - } else { - alternativeWidget = Text( - 'Last seen ${Jiffy.parseFromDateTime(otherMember.lastActive!).fromNow()}', - style: TextStyle(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5)), - ); - } - } +/// Card grouping the conversation-level actions — mute, block, delete. +class _ActionsSection extends StatelessWidget { + const _ActionsSection(); - return Row( - mainAxisAlignment: MainAxisAlignment.center, + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + + return _Section( children: [ - if (widget.user!.online) - Material( - type: MaterialType.circle, - color: StreamChatTheme.of(context).colorTheme.barsBg, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - constraints: const BoxConstraints.tightFor( - width: 24, - height: 12, - ), - child: Material( - shape: const CircleBorder(), - color: StreamChatTheme.of(context).colorTheme.accentInfo, - ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) => _Tile( + icon: isMuted ? icons.audio : icons.mute, + label: isMuted ? 'Unmute User' : 'Mute User', + trailing: StreamSwitch( + value: isMuted, + onChanged: (_) { + if (isMuted) { + channel.unmute(); + } else { + channel.mute(); + } + }, ), ), - alternativeWidget, - if (widget.user!.online) - const SizedBox( - width: 24, + ), + _Tile( + icon: icons.noSign, + label: 'Block User', + onTap: () => _showNotImplementedSnack(context), + ), + if (channel.canDeleteChannel) + _Tile( + icon: icons.delete, + label: 'Delete Conversation', + destructive: true, + onTap: () => _confirmDelete(context), ), ], ); } + + void _showNotImplementedSnack(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Blocking users is not implemented in the sample app.')), + ); + } + + Future _confirmDelete(BuildContext context) async { + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Delete conversation', + content: 'Are you sure you want to delete this conversation?', + confirmLabel: 'Delete', + ); + if (confirmed != true) return; + + await channel.delete(); + // Pop every screen until we land on the channel list — going back to + // the channel page would crash trying to read state from the now + // deleted channel. + navigator.popUntil((route) => route.isFirst); + } } -class _SharedGroupsScreen extends StatefulWidget { - const _SharedGroupsScreen(this.mainUser, this.otherUser); - final User? mainUser; - final User? otherUser; +/// A rounded section card that visually groups its [children] with a single +/// background colour and clipped ink ripples — matches the Figma's "soft +/// grey card" pattern shared across detail screens. +class _Section extends StatelessWidget { + const _Section({required this.children}); + + final List children; @override - __SharedGroupsScreenState createState() => __SharedGroupsScreenState(); + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final colorScheme = context.streamColorScheme; + + return Material( + color: colorScheme.backgroundSurfaceCard, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.lg)), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: .symmetric(vertical: spacing.xs, horizontal: spacing.xxs), + child: Column(mainAxisSize: .min, children: children), + ), + ); + } } -class __SharedGroupsScreenState extends State<_SharedGroupsScreen> { +/// A single row inside a [_Section] — leading icon, label, trailing widget. +/// +/// Defaults the trailing to a chevron when [onTap] is provided and no +/// explicit [trailing] was passed. Setting [destructive] paints both the +/// icon and the label with [StreamColorScheme.accentError] via a local +/// [StreamListTileTheme] override. +class _Tile extends StatelessWidget { + const _Tile({ + required this.icon, + required this.label, + this.onTap, + this.trailing, + this.destructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + final Widget? trailing; + final bool destructive; + @override Widget build(BuildContext context) { - final chat = StreamChat.of(context); + final icons = context.streamIcons; + final spacing = context.streamSpacing; - return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - 'Shared Groups', - style: TextStyle(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, fontSize: 16), - ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, + final colorScheme = context.streamColorScheme; + + var trailing = this.trailing; + if (trailing == null && onTap != null) { + trailing = Icon(icons.chevronRight, color: colorScheme.textSecondary); + } + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), ), - body: StreamBuilder>( - stream: chat.client.queryChannels( - filter: Filter.and([ - Filter.in_('members', [widget.otherUser!.id]), - Filter.in_('members', [widget.mainUser!.id]), - ]), - ), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (snapshot.data!.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - context.streamIcons.messageBubbleLarge, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - 'No Shared Groups', - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - ), - ), - const SizedBox(height: 8), - Text( - 'Group shared with User will appear here.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ], - ), - ); - } - - final channels = snapshot.data! - .where( - (c) => - c.state!.members.any((m) => m.userId != widget.mainUser!.id && m.userId != widget.otherUser!.id) || - !c.isDistinct, - ) - .toList(); - - return ListView.builder( - itemCount: channels.length, - itemBuilder: (context, position) { - return StreamChannel( - channel: channels[position], - child: _buildListTile(channels[position]), - ); - }, - ); - }, + child: StreamListTile( + leading: Icon(icon), + trailing: trailing, + title: Text(label), + onTap: onTap, ), ); } +} - Widget _buildListTile(Channel channel) { - final extraData = channel.extraData; - final members = channel.state!.members; - - const textStyle = TextStyle(fontSize: 14, fontWeight: FontWeight.bold); - - return SizedBox( - height: 64, - child: LayoutBuilder( - builder: (context, constraints) { - String? title; - if (extraData['name'] == null) { - final otherMembers = members.where((member) => member.userId != StreamChat.of(context).currentUser!.id); - if (otherMembers.isNotEmpty) { - final maxWidth = constraints.maxWidth; - final maxChars = maxWidth / textStyle.fontSize!; - var currentChars = 0; - final currentMembers = []; - for (final element in otherMembers) { - final newLength = currentChars + element.user!.name.length; - if (newLength < maxChars) { - currentChars = newLength; - currentMembers.add(element); - } - } - - final exceedingMembers = otherMembers.length - currentMembers.length; - title = - '${currentMembers.map((e) => e.user!.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; - } else { - title = 'No title'; - } - } else { - title = extraData['name']! as String; - } - - return Column( - children: [ - Expanded( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamChannelAvatar( - size: .lg, - channel: channel, - ), - ), - Expanded( - child: Text( - title, - style: textStyle, - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Text( - '${channel.memberCount} members', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ), - ], - ), - ), - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(.08), - ), - ], - ); - }, - ), +// Stream-styled confirmation dialog with a destructive primary action. +// +// Mirrors the dialog pattern used by the poll interactor (e.g. +// `showPollEndVoteDialog` / `showPollDeleteOptionDialog`) and the +// SDK-internal `StreamMessageActionConfirmationModal`: a Material +// [AlertDialog] with two ghost [StreamButton]s, secondary for cancel and +// destructive for confirm. +// +// Resolves to `true` on confirm, `false` on cancel, `null` on dismiss. +Future _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) => _ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + ), + ); +} + +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ + required this.title, + required this.content, + required this.confirmLabel, + }); + + final String title; + final String content; + final String confirmLabel; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return AlertDialog( + backgroundColor: colorScheme.backgroundElevation1, + title: Text(title), + content: Text(content), + actions: [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: Text(context.translations.cancelLabel), + ), + StreamButton( + type: .ghost, + style: .destructive, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: Text(confirmLabel), + ), + ], ); } } diff --git a/sample_app/lib/pages/group_chat_details_screen.dart b/sample_app/lib/pages/group_chat_details_screen.dart index 6cb6decde3..ee2c3fc0b1 100644 --- a/sample_app/lib/pages/group_chat_details_screen.dart +++ b/sample_app/lib/pages/group_chat_details_screen.dart @@ -50,94 +50,38 @@ class _GroupChatDetailsScreenState extends State { }, child: Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Text( - 'Name of Group Chat', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - ), - centerTitle: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), - child: Row( - children: [ - Text( - 'Name'.toUpperCase(), - style: TextStyle( - fontSize: 12, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextField( - controller: _groupNameController, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - hintText: 'Choose a group chat name', - hintStyle: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - ), - ), - ), - ], - ), - ), + appBar: StreamAppBar( + title: const Text('Name of Group Chat'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + onPressed: _isGroupNameEmpty + ? null + : () async { + try { + final groupName = _groupNameController.text; + final client = StreamChat.of(context).client; + final router = GoRouter.of(context); + final channel = client.channel( + 'messaging', + id: const Uuid().v4(), + extraData: { + 'members': [ + client.state.currentUser!.id, + ...widget.groupChatState.users.map((e) => e.id), + ], + 'name': groupName, + }, + ); + await channel.watch(); + router.goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); + } catch (err) { + _showErrorAlert(); + } + }, ), - actions: [ - StreamNeumorphicButton( - child: IconButton( - iconSize: 24, - padding: EdgeInsets.zero, - color: _isGroupNameEmpty - ? StreamChatTheme.of(context).colorTheme.textLowEmphasis - : StreamChatTheme.of(context).colorTheme.accentPrimary, - icon: Icon(context.streamIcons.checkmark), - onPressed: _isGroupNameEmpty - ? null - : () async { - try { - final groupName = _groupNameController.text; - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final channel = client.channel( - 'messaging', - id: const Uuid().v4(), - extraData: { - 'members': [ - client.state.currentUser!.id, - ...widget.groupChatState.users.map((e) => e.id), - ], - 'name': groupName, - }, - ); - await channel.watch(); - router.goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - ); - } catch (err) { - _showErrorAlert(); - } - }, - ), - ), - ], ), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { @@ -163,6 +107,40 @@ class _GroupChatDetailsScreenState extends State { message: statusString, child: Column( children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + child: Row( + children: [ + Text( + 'Name'.toUpperCase(), + style: TextStyle( + fontSize: 12, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _groupNameController, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: 'Choose a group chat name', + hintStyle: TextStyle( + fontSize: 14, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), + ), + ), + ), + ], + ), + ), Container( width: double.maxFinite, decoration: BoxDecoration( diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 1d4688597d..8531086ff2 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -1,1046 +1,470 @@ -// ignore_for_file: deprecated_member_use - -import 'dart:async'; - -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter/cupertino.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/channel_file_display_screen.dart'; import 'package:sample_app/pages/channel_media_display_screen.dart'; import 'package:sample_app/pages/pinned_messages_screen.dart'; -import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/widgets/add_members_sheet.dart'; +import 'package:sample_app/widgets/all_members_sheet.dart'; +import 'package:sample_app/widgets/edit_group_sheet.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -class GroupInfoScreen extends StatefulWidget { +/// Detail screen for a group channel. +/// +/// Surfaces the group avatar, name, member count, plus channel content +/// shortcuts (pinned messages, media, files), a preview of the member list +/// (with _Add_ / _View all_ affordances), and conversation actions +/// (mute, leave) — see Figma frame `8779:381156`. +class GroupInfoScreen extends StatelessWidget { + /// Creates a [GroupInfoScreen]. const GroupInfoScreen({super.key}); @override - State createState() => _GroupInfoScreenState(); + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final channel = StreamChannel.of(context).channel; + + return Scaffold( + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar( + title: const Text('Group Info'), + trailing: switch (channel.canUpdateChannel) { + true => StreamButton( + type: .outline, + style: .secondary, + size: .small, + onPressed: () => showEditGroupSheet(context, channel), + child: const Text('Edit'), + ), + false => null, + }, + ), + // Action / chevron icons share a uniform 20px size — set once at the + // top of the body so individual rows stay style-free. + body: IconTheme.merge( + data: const IconThemeData(size: 20), + child: SingleChildScrollView( + padding: .directional( + top: spacing.xxl, + bottom: spacing.xxxl, + start: spacing.md, + end: spacing.md, + ), + child: Column( + mainAxisSize: .min, + children: [ + const _GroupInfoHeader(), + SizedBox(height: spacing.xxl), + const _MediaSection(), + SizedBox(height: spacing.md), + const _MembersSection(), + SizedBox(height: spacing.md), + const _ActionsSection(), + ], + ), + ), + ), + ); + } } -class _GroupInfoScreenState extends State { - late final TextEditingController _nameController = TextEditingController.fromValue( - TextEditingValue(text: (channel.extraData['name'] as String?) ?? ''), - ); - - late final TextEditingController _searchController = TextEditingController()..addListener(_userNameListener); - - String _userNameQuery = ''; - - Timer? _debounce; - Function? modalSetStateCallback; - - final FocusNode _focusNode = FocusNode(); - - bool listExpanded = false; - - late ValueNotifier mutedBool = ValueNotifier(channel.isMuted); - - late ValueNotifier isPinned = ValueNotifier(channel.isPinned); - - late ValueNotifier isArchived = ValueNotifier(channel.isArchived); - - late final channel = StreamChannel.of(context).channel; +/// Hero header — channel avatar group, channel name with optional inline +/// mute state icon, and a "X members · Y online" subtitle driven by +/// [StreamChannelInfo]. +class _GroupInfoHeader extends StatelessWidget { + const _GroupInfoHeader(); - late StreamUserListController _userListController; + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final channel = StreamChannel.of(context).channel; - void _userNameListener() { - if (_searchController.text == _userNameQuery) { - return; - } - if (_debounce?.isActive ?? false) _debounce!.cancel(); - _debounce = Timer(const Duration(milliseconds: 350), () { - if (mounted) { - _userNameQuery = _searchController.text; - _userListController.filter = Filter.and( - [ - if (_searchController.text.isNotEmpty) Filter.autoComplete('name', _userNameQuery), - Filter.notIn('id', [ - StreamChat.of(context).currentUser!.id, - ...channel.state!.members.map((e) => e.userId).whereType(), - ]), + return Column( + children: [ + StreamChannelAvatar(channel: channel, size: .xxl), + SizedBox(height: spacing.md), + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + Flexible( + child: StreamChannelName( + channel: channel, + textStyle: textTheme.headingLg.copyWith(color: colorScheme.textPrimary), + ), + ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) { + if (!isMuted) return const SizedBox.shrink(); + return Icon( + context.streamIcons.mute, + color: colorScheme.textTertiary, + ); + }, + ), ], - ); - _userListController.doInitialLoad(); - } - }); + ), + SizedBox(height: spacing.xs), + StreamChannelInfo( + channel: channel, + showTypingIndicator: false, + textStyle: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + ], + ); } +} - @override - void initState() { - super.initState(); - - _nameController.addListener(() { - setState(() {}); - }); - mutedBool = ValueNotifier(channel.isMuted); - } +/// Card grouping the read-only channel-content shortcuts. +class _MediaSection extends StatelessWidget { + const _MediaSection(); @override - void didChangeDependencies() { - _userListController = StreamUserListController( - client: StreamChat.of(context).client, - limit: 25, - filter: Filter.and( - [ - if (_searchController.text.isNotEmpty) Filter.autoComplete('name', _userNameQuery), - Filter.notIn('id', [ - StreamChat.of(context).currentUser!.id, - ...channel.state!.members.map((e) => e.userId).whereType(), - ]), - ], - ), - sort: [ - const SortOption.asc(UserSortKey.name), + Widget build(BuildContext context) { + final icons = context.streamIcons; + return _Section( + children: [ + _Tile( + icon: icons.pin, + label: 'Pinned Messages', + onTap: () => _push(context, const PinnedMessagesScreen()), + ), + _Tile( + icon: icons.image, + label: 'Photos & Videos', + onTap: () => _push(context, const ChannelMediaDisplayScreen()), + ), + _Tile( + icon: icons.folder, + label: 'Files', + onTap: () => _push(context, const ChannelFileDisplayScreen()), + ), ], ); - super.didChangeDependencies(); } - @override - void dispose() { - _nameController.dispose(); - _searchController.dispose(); - _userListController.dispose(); - super.dispose(); + void _push(BuildContext context, Widget destination) { + final channel = StreamChannel.of(context).channel; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel(channel: channel, child: destination), + ), + ); } +} + +/// Members card — header with count and _Add_ affordance, the first +/// [_kPreviewLimit] members, and a _View all_ footer when the channel has +/// more. +const _kPreviewLimit = 5; + +class _MembersSection extends StatelessWidget { + const _MembersSection(); @override Widget build(BuildContext context) { - return StreamBuilder>( - stream: channel.state!.membersStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return ColoredBox( - color: StreamChatTheme.of(context).colorTheme.disabled, - child: const Center(child: CircularProgressIndicator()), - ); - } - - return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - toolbarHeight: 56, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Column( - children: [ - StreamBuilder( - stream: channel.state?.channelStateStream, - builder: (context, state) { - if (!state.hasData) { - return Text( - 'Loading...', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } + final channel = StreamChannel.of(context).channel; + final currentUserId = StreamChat.of(context).currentUser?.id; - return Text( - _getChannelName( - 2 * MediaQuery.of(context).size.width / 3, - members: snapshot.data, - extraData: state.data!.channel!.extraData, - maxFontSize: 16, - )!, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + // Sort the current user to the top so the "You" row is always the + // first member rendered, matching the Figma. + final sorted = [...members].sorted((a, b) { + if (a.userId == currentUserId) return -1; + if (b.userId == currentUserId) return 1; + return 0; + }); + + final preview = sorted.take(_kPreviewLimit).toList(); + final overflow = sorted.length - preview.length; + + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return _Section( + children: [ + _MembersHeader(count: members.length), + for (final member in preview) + ChannelMemberTile( + member: member, + isCurrentUser: member.userId == currentUserId, + onTap: switch (member.userId) { + final id? when id != currentUserId => () { + final user = member.user; + if (user != null) openContactDetail(context, user); }, - ), - const SizedBox( - height: 3, - ), - Text( - '${channel.memberCount} Members, ${snapshot.data?.where((e) => e.user!.online).length ?? 0} Online', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - fontSize: 12, - ), - ), - ], - ), - centerTitle: true, - actions: [ - if (channel.canUpdateChannelMembers) - StreamNeumorphicButton( - child: InkWell( - onTap: () { - _buildAddUserModal(context); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - context.streamIcons.userAdd, - color: StreamChatTheme.of(context).colorTheme.accentPrimary, - ), - ), - ), - ), - ], - ), - body: ListView( - children: [ - _buildMembers(snapshot.data!), - Container( - height: 8, - color: StreamChatTheme.of(context).colorTheme.disabled, + _ => null, + }, + ), + if (overflow > 0) ...[ + SizedBox(height: spacing.sm), + Divider(height: 1, color: colorScheme.borderDefault), + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => showAllMembersSheet(context, channel), + child: const Text('View all'), ), - if (channel.canUpdateChannel) _buildNameTile(), - _buildOptionListTiles(), ], - ), + ], ); }, ); } +} - Widget _buildMembers(List members) { - final groupMembers = members - ..sort((prev, curr) { - if (curr.userId == channel.createdBy?.id) return 1; - return 0; - }); - - int groupMembersLength; - - if (listExpanded) { - groupMembersLength = groupMembers.length; - } else { - groupMembersLength = groupMembers.length > 6 ? 6 : groupMembers.length; - } - - return Column( - children: [ - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: groupMembersLength, - itemBuilder: (context, index) { - final member = groupMembers[index]; - return Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: InkWell( - onTap: () { - final userMember = groupMembers.firstWhereOrNull( - (e) => e.user!.id == StreamChat.of(context).currentUser!.id, - ); - _showUserInfoModal(member.user, userMember?.userId == channel.createdBy?.id); - }, - child: SizedBox( - height: 65, - child: Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 12, - ), - child: StreamUserAvatar( - size: .lg, - user: member.user!, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - member.user!.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox( - height: 1, - ), - Text( - _getLastSeen(member.user!), - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Text( - member.userId == channel.createdBy?.id ? 'Owner' : '', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ), - ], - ), - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - ], - ), - ), - ), - ); - }, - ), - if (groupMembersLength != groupMembers.length) - InkWell( - onTap: () { - setState(() { - listExpanded = true; - }); - }, - child: Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: SizedBox( - height: 65, - child: Column( - children: [ - Expanded( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 21, vertical: 12), - child: Icon( - context.streamIcons.chevronDown, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${members.length - groupMembersLength} more', - style: TextStyle(color: StreamChatTheme.of(context).colorTheme.textLowEmphasis), - ), - ], - ), - ), - ], - ), - ), - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - ], - ), - ), - ), - ), - ], - ); - } +/// Header row at the top of [_MembersSection] — shows the total member +/// count on the left and an _Add_ button on the right (when the current +/// user can update the channel). +class _MembersHeader extends StatelessWidget { + const _MembersHeader({required this.count}); - Widget _buildNameTile() { - final channelName = (channel.extraData['name'] as String?) ?? ''; + final int count; - return Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: Container( - height: 56, - alignment: Alignment.center, + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final channel = StreamChannel.of(context).channel; + + return Padding( + padding: .symmetric(horizontal: spacing.md), + // Pin a min height so the header stays the same size whether or not + // the _Add_ button is rendered — without it, hiding the button + // (distinct channels, insufficient permissions) collapses the row to + // the title's natural text height. + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), child: Row( children: [ - Padding( - padding: const EdgeInsets.all(7), - child: Text( - 'Name'.toUpperCase(), - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ), - const SizedBox( - width: 7, - ), Expanded( - child: TextField( - enabled: channel.canUpdateChannel, - focusNode: _focusNode, - controller: _nameController, - cursorColor: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - decoration: InputDecoration.collapsed( - hintText: 'Add a group name', - hintStyle: StreamChatTheme.of(context).textTheme.bodyBold.copyWith( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - style: const TextStyle( - fontWeight: FontWeight.bold, - height: 0.82, - ), + child: Text( + '$count members', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), ), ), - if (channelName != _nameController.text.trim()) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(context.streamIcons.xmark), - onPressed: () { - setState(() { - _nameController.text = _getChannelName( - 2 * MediaQuery.of(context).size.width / 3, - members: channel.state!.members, - extraData: channel.extraData, - maxFontSize: 16, - )!; - _focusNode.unfocus(); - }); - }, - ), - IconButton( - color: StreamChatTheme.of(context).colorTheme.accentPrimary, - icon: Icon(context.streamIcons.checkmark), - onPressed: () { - try { - channel.update({ - 'name': _nameController.text.trim(), - }); - } catch (_) { - setState(() { - _nameController.text = channelName; - _focusNode.unfocus(); - }); - } - }, - ), - ], + // Hide the affordance when the channel is distinct (1:1) — the + // API rejects member changes on those, so showing a tappable + // button that always errors is worse than no button at all. + if (channel.canUpdateChannelMembers && !channel.isDistinct) + StreamButton( + type: .outline, + style: .secondary, + size: .small, + onPressed: () => showAddMembersSheet(context, channel), + child: const Text('Add'), ), ], ), ), ); } +} - Widget _buildOptionListTiles() { - return Column( - children: [ - if (channel.canMuteChannel) - _GroupInfoToggle( - title: 'Mute group', - icon: context.streamIcons.mute, - channelStream: channel.isMutedStream, - localNotifier: mutedBool, - onTurnOff: channel.unmute, - onTurnOn: channel.mute, - ), - _GroupInfoToggle( - title: 'Pin group', - icon: context.streamIcons.pin, - channelStream: channel.isPinnedStream, - localNotifier: isPinned, - onTurnOff: channel.unpin, - onTurnOn: channel.pin, - ), - _GroupInfoToggle( - title: 'Archive group', - icon: context.streamIcons.save, - channelStream: channel.isArchivedStream, - localNotifier: isArchived, - onTurnOff: channel.unarchive, - onTurnOn: channel.archive, - ), - _GroupInfoListTile( - title: 'Pinned Messages', - icon: context.streamIcons.pin, - iconSize: 24, - iconPadding: 16, - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const PinnedMessagesScreen(), - ), - ), - ); - }, - ), - _GroupInfoListTile( - title: 'Photos & Videos', - icon: context.streamIcons.imageLarge, - iconSize: 32, - iconPadding: 12, - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const ChannelMediaDisplayScreen(), - ), - ), - ); - }, - ), - _GroupInfoListTile( - title: 'Files', - icon: context.streamIcons.file, - iconSize: 32, - iconPadding: 12, - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const ChannelFileDisplayScreen(), - ), - ), - ); - }, - ), - if (!channel.isDistinct && channel.canLeaveChannel) - StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: StreamChatTheme.of(context).colorTheme.disabled, - title: 'Leave Group', - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - context.streamIcons.userRemove, - size: 24, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - trailing: const SizedBox( - height: 24, - width: 24, - ), - onTap: () async { - final streamChannel = StreamChannel.of(context); - final streamChat = StreamChat.of(context); - final router = GoRouter.of(context); - final res = await showConfirmationBottomSheet( - context, - title: 'Leave conversation', - okText: 'Leave'.toUpperCase(), - question: 'Are you sure you want to leave this conversation?', - cancelText: 'Cancel'.toUpperCase(), - icon: Icon( - context.streamIcons.userRemove, - color: StreamChatTheme.of(context).colorTheme.accentError, - ), - ); - if (res == true) { - final channel = streamChannel.channel; - await channel.removeMembers([streamChat.currentUser!.id]); - router.pop(); - } - }, - ), - ], - ); - } - - void _buildAddUserModal(context) { - showDialog( - useRootNavigator: false, - context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, - builder: (context) { - return Padding( - padding: const EdgeInsets.only(top: 16, left: 8, right: 8), - child: Material( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - clipBehavior: Clip.antiAlias, - child: Scaffold( - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: _buildTextInputSection(), - ), - Expanded( - child: StreamUserGridView( - controller: _userListController, - onUserTap: (user) async { - _searchController.clear(); - final navigator = Navigator.of(context); +/// Card grouping the conversation-level actions — mute and leave. Group +/// channels intentionally don't expose a destructive delete here; that +/// action lives on the channel-list long-press sheet. +class _ActionsSection extends StatelessWidget { + const _ActionsSection(); - await channel.addMembers([user.id]); - navigator.pop(); - setState(() {}); - }, - emptyBuilder: (_) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: Icon( - context.streamIcons.search, - size: 96, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - ), - const Text('No user matches these keywords...'), - ], - ), - ), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - ).whenComplete(() { - _searchController.clear(); - }); - } + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; - Widget _buildTextInputSection() { - final theme = StreamChatTheme.of(context); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return _Section( children: [ - Expanded( - child: SizedBox( - height: 36, - child: TextField( - controller: _searchController, - cursorColor: theme.colorTheme.textHighEmphasis, - autofocus: true, - decoration: InputDecoration( - hintText: 'Search', - hintStyle: theme.textTheme.body.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - prefixIconConstraints: BoxConstraints.tight(const Size(40, 24)), - prefixIcon: Icon( - context.streamIcons.search, - color: theme.colorTheme.textHighEmphasis, - size: 24, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide( - color: theme.colorTheme.borders, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide( - color: theme.colorTheme.borders, - ), - ), - contentPadding: EdgeInsets.zero, - ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) => _Tile( + icon: isMuted ? icons.audio : icons.mute, + label: isMuted ? 'Unmute Group' : 'Mute Group', + trailing: StreamSwitch( + value: isMuted, + onChanged: (_) { + if (isMuted) { + channel.unmute(); + } else { + channel.mute(); + } + }, ), ), ), - IconButton( - icon: Icon(context.streamIcons.xmark), - color: theme.colorTheme.textHighEmphasis, - onPressed: () => Navigator.pop(context), - ), + if (channel.canLeaveChannel) + _Tile( + icon: icons.leave, + label: 'Leave Group', + destructive: true, + onTap: () => _confirmLeave(context), + ), ], ); } - void _showUserInfoModal(User? user, bool isUserAdmin) { - final color = StreamChatTheme.of(context).colorTheme.barsBg; + Future _confirmLeave(BuildContext context) async { + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; + final currentUserId = StreamChat.of(context).currentUser?.id; + if (currentUserId == null) return; - showModalBottomSheet( + final confirmed = await _showConfirmationDialog( context: context, - clipBehavior: Clip.antiAlias, - isScrollControlled: true, - backgroundColor: color, - builder: (context) { - return SafeArea( - child: StreamChannel( - channel: channel, - child: Material( - color: color, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 24, - ), - Center( - child: Text( - user!.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox( - height: 5, - ), - _buildConnectedTitleState(user)!, - Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: StreamUserAvatar( - size: .xl, - user: user, - ), - ), - ), - if (StreamChat.of(context).currentUser!.id != user.id) - _buildModalListTile( - context, - Icon( - context.streamIcons.user, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - size: 24, - ), - 'View info', - () async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - - final c = client.channel( - 'messaging', - extraData: { - 'members': [ - user.id, - StreamChat.of(context).currentUser!.id, - ], - }, - ); - - await c.watch(); - - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(c), - extra: user, - ); - }, - ), - if (StreamChat.of(context).currentUser!.id != user.id) - _buildModalListTile( - context, - Icon( - context.streamIcons.messageBubble, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - size: 24, - ), - 'Message', - () async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - - final c = client.channel( - 'messaging', - extraData: { - 'members': [ - user.id, - StreamChat.of(context).currentUser!.id, - ], - }, - ); - - await c.watch(); - - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(c), - ); - }, - ), - if (!channel.isDistinct && StreamChat.of(context).currentUser!.id != user.id && isUserAdmin) - _buildModalListTile( - context, - Icon( - context.streamIcons.userRemove, - color: StreamChatTheme.of(context).colorTheme.accentError, - size: 24, - ), - 'Remove From Group', - () async { - final router = GoRouter.of(context); - final res = await showConfirmationBottomSheet( - context, - title: 'Remove member', - okText: 'Remove'.toUpperCase(), - question: 'Are you sure you want to remove this member?', - cancelText: 'Cancel'.toUpperCase(), - ); - - if (res == true) { - await channel.removeMembers([user.id]); - } - router.pop(); - }, - color: StreamChatTheme.of(context).colorTheme.accentError, - ), - _buildModalListTile( - context, - Icon( - context.streamIcons.xmark, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - size: 24, - ), - 'Cancel', - () { - Navigator.pop(context); - }, - ), - ], - ), - ), - ), - ); - }, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), + title: 'Leave group', + content: 'Are you sure you want to leave this group?', + confirmLabel: 'Leave', ); - } + if (confirmed != true) return; - Widget? _buildConnectedTitleState(User? user) { - late Text alternativeWidget; + await channel.removeMembers([currentUserId]); + // Pop every screen until we land on the channel list — going back to + // the channel page would crash since we're no longer a member. + navigator.popUntil((route) => route.isFirst); + } +} - final otherMember = user; +/// A rounded section card that visually groups its [children] with a single +/// background colour and clipped ink ripples — matches the Figma's "soft +/// grey card" pattern shared across detail screens. +class _Section extends StatelessWidget { + const _Section({required this.children}); - if (otherMember != null) { - if (otherMember.online) { - alternativeWidget = Text( - 'Online', - style: TextStyle(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5)), - ); - } else { - alternativeWidget = Text( - 'Last seen ${Jiffy.parseFromDateTime(otherMember.lastActive!).fromNow()}', - style: TextStyle(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5)), - ); - } - } + final List children; - return alternativeWidget; - } + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; - Widget _buildModalListTile(BuildContext context, Widget leading, String title, VoidCallback onTap, {Color? color}) { - color ??= StreamChatTheme.of(context).colorTheme.textHighEmphasis; + final colorScheme = context.streamColorScheme; return Material( - color: StreamChatTheme.of(context).colorTheme.barsBg, - child: InkWell( - onTap: onTap, - child: Column( - children: [ - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - SizedBox( - height: 64, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: leading, - ), - Expanded( - child: Text( - title, - style: TextStyle(color: color, fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ], - ), + color: colorScheme.backgroundSurfaceCard, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.lg)), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: .symmetric(vertical: spacing.xs, horizontal: spacing.xxs), + child: Column(mainAxisSize: .min, children: children), ), ); } - - String? _getChannelName( - double width, { - List? members, - required Map extraData, - double? maxFontSize, - }) { - String? title; - final client = StreamChat.of(context); - if (extraData['name'] == null) { - final otherMembers = members!.where((member) => member.user!.id != client.currentUser!.id); - if (otherMembers.isNotEmpty) { - final maxWidth = width; - final maxChars = maxWidth / maxFontSize!; - var currentChars = 0; - final currentMembers = []; - for (final element in otherMembers) { - final newLength = currentChars + element.user!.name.length; - if (newLength < maxChars) { - currentChars = newLength; - currentMembers.add(element); - } - } - - final exceedingMembers = otherMembers.length - currentMembers.length; - title = - '${currentMembers.map((e) => e.user!.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; - } else { - title = 'No title'; - } - } else { - title = extraData['name']; - } - return title; - } - - String _getLastSeen(User user) { - if (user.online) { - return 'Online'; - } else { - if (user.lastActive == null) { - return ''; - } - - return 'Last seen ${Jiffy.parseFromDateTime(user.lastActive!).fromNow()}'; - } - } } -class _GroupInfoToggle extends StatelessWidget { - const _GroupInfoToggle({ - required this.title, +/// A single row inside a [_Section] — leading icon, label, optional +/// trailing widget. Defaults the trailing to a chevron when [onTap] is +/// provided and no explicit [trailing] is passed. [destructive] paints +/// both the icon and the label with [StreamColorScheme.accentError] via a +/// local [StreamListTileTheme] override. +class _Tile extends StatelessWidget { + const _Tile({ required this.icon, - required this.channelStream, - required this.localNotifier, - required this.onTurnOff, - required this.onTurnOn, + required this.label, + this.onTap, + this.trailing, + this.destructive = false, }); - final String title; final IconData icon; - final Stream channelStream; - final ValueNotifier localNotifier; - final VoidCallback onTurnOff; - final VoidCallback onTurnOn; + final String label; + final VoidCallback? onTap; + final Widget? trailing; + final bool destructive; @override Widget build(BuildContext context) { - return StreamBuilder( - stream: channelStream, - builder: (context, snapshot) { - localNotifier.value = snapshot.data; + final icons = context.streamIcons; + final spacing = context.streamSpacing; - return StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: StreamChatTheme.of(context).colorTheme.disabled, - title: title, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Icon( - icon, - size: 24, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - trailing: snapshot.data == null - ? const CircularProgressIndicator() - : ValueListenableBuilder( - valueListenable: localNotifier, - builder: (context, value, _) { - return CupertinoSwitch( - value: value!, - onChanged: (val) { - localNotifier.value = val; - if (snapshot.data!) { - onTurnOff(); - } else { - onTurnOn(); - } - }, - ); - }, - ), - onTap: () {}, - ); - }, + final colorScheme = context.streamColorScheme; + + final effectiveTrailing = + trailing ?? (onTap != null ? Icon(icons.chevronRight, color: colorScheme.textSecondary) : null); + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: Icon(icon), + trailing: effectiveTrailing, + title: Text(label), + onTap: onTap, + ), ); } } -class _GroupInfoListTile extends StatelessWidget { - const _GroupInfoListTile({ +// Stream-styled confirmation dialog with a destructive primary action. +// +// Mirrors the dialog pattern used by the poll interactor and the +// SDK-internal `StreamMessageActionConfirmationModal` — a Material +// [AlertDialog] with two ghost [StreamButton]s, secondary for cancel and +// destructive for confirm. Resolves to `true` on confirm, `false` on +// cancel, `null` on dismiss. +Future _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) => _ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + ), + ); +} + +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ required this.title, - required this.icon, - required this.iconSize, - required this.iconPadding, - required this.onTap, + required this.content, + required this.confirmLabel, }); final String title; - final IconData icon; - final double iconSize; - final double iconPadding; - final VoidCallback onTap; + final String content; + final String confirmLabel; @override Widget build(BuildContext context) { - return StreamOptionListTile( - title: title, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: EdgeInsets.symmetric(horizontal: iconPadding), - child: Icon( - icon, - size: iconSize, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), + final colorScheme = context.streamColorScheme; + + return AlertDialog( + backgroundColor: colorScheme.backgroundElevation1, + title: Text(title), + content: Text(content), + actions: [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: Text(context.translations.cancelLabel), ), - ), - trailing: Icon( - context.streamIcons.chevronRight, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: onTap, + StreamButton( + type: .ghost, + style: .destructive, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: Text(confirmLabel), + ), + ], ); } } diff --git a/sample_app/lib/pages/new_chat_screen.dart b/sample_app/lib/pages/new_chat_screen.dart index 3a23d869bf..33d6d9f2e4 100644 --- a/sample_app/lib/pages/new_chat_screen.dart +++ b/sample_app/lib/pages/new_chat_screen.dart @@ -140,18 +140,7 @@ class _NewChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 0, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Text( - 'New Chat', - style: StreamChatTheme.of( - context, - ).textTheme.headlineBold.copyWith(color: StreamChatTheme.of(context).colorTheme.textHighEmphasis), - ), - centerTitle: true, - ), + appBar: StreamAppBar(title: const Text('New Chat')), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { var statusString = ''; diff --git a/sample_app/lib/pages/new_group_chat_screen.dart b/sample_app/lib/pages/new_group_chat_screen.dart index 3fc7dc792b..ecc17c61c5 100644 --- a/sample_app/lib/pages/new_group_chat_screen.dart +++ b/sample_app/lib/pages/new_group_chat_screen.dart @@ -68,31 +68,20 @@ class _NewGroupChatScreenState extends State { final state = groupChatState; return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Text( - 'Add Group Members', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, + appBar: StreamAppBar( + title: const Text('Add Group Members'), + trailing: switch (state.users.isNotEmpty) { + true => StreamButton.icon( + icon: Icon(context.streamIcons.arrowRight), + onPressed: () async { + GoRouter.of(context).pushNamed( + Routes.NEW_GROUP_CHAT_DETAILS.name, + extra: state, + ); + }, ), - ), - centerTitle: true, - actions: [ - if (state.users.isNotEmpty) - IconButton( - color: StreamChatTheme.of(context).colorTheme.accentPrimary, - icon: Icon(context.streamIcons.arrowRight), - onPressed: () async { - GoRouter.of(context).pushNamed( - Routes.NEW_GROUP_CHAT_DETAILS.name, - extra: state, - ); - }, - ), - ], + false => null, + }, ), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { diff --git a/sample_app/lib/pages/pinned_messages_screen.dart b/sample_app/lib/pages/pinned_messages_screen.dart index e933428fcd..f2b52a4b16 100644 --- a/sample_app/lib/pages/pinned_messages_screen.dart +++ b/sample_app/lib/pages/pinned_messages_screen.dart @@ -1,11 +1,17 @@ -// ignore_for_file: deprecated_member_use - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// Lists every pinned message in the enclosing channel. +/// +/// Matches Figma frame `8833:437280` (and `8833:437021` for the empty +/// state). The search-list controller is filtered by `pinned: true` on +/// the channel's `cid` — same data source the legacy implementation used, +/// rendered via the SDK's `StreamMessageSearchListView` so the row +/// styling stays in sync with the rest of the app. class PinnedMessagesScreen extends StatefulWidget { + /// Creates a [PinnedMessagesScreen]. const PinnedMessagesScreen({super.key}); @override @@ -13,111 +19,90 @@ class PinnedMessagesScreen extends StatefulWidget { } class _PinnedMessagesScreenState extends State { - late final controller = StreamMessageSearchListController( + late final StreamMessageSearchListController _controller = StreamMessageSearchListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'cid', - [StreamChannel.of(context).channel.cid!], - ), - messageFilter: Filter.equal( - 'pinned', - true, - ), - sort: [ - const SortOption.asc('created_at'), - ], + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.equal('pinned', true), + sort: const [SortOption.asc('created_at')], limit: 20, ); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - 'Pinned Messages', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - ), + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Pinned Messages')), body: StreamMessageSearchListView( - controller: controller, - emptyBuilder: (_) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - context.streamIcons.pinLarge, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - 'No pinned items', - style: TextStyle( - fontSize: 17, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - RichText( - textAlign: TextAlign.center, - text: TextSpan( - children: [ - TextSpan( - text: '${'Long-press an important message and\nchoose'} ', - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - TextSpan( - text: 'Pin to conversation', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ], - ), - ), - ], - ), - ); - }, - onMessageTap: (messageResponse) async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final message = messageResponse.message; - final channel = client.channel( - messageResponse.channel!.type, - id: messageResponse.channel!.id, - ); - if (channel.state == null) { - await channel.watch(); - } - router.goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ); - }, + controller: _controller, + emptyBuilder: (_) => const Center(child: _EmptyState()), + onMessageTap: _openMessage, ), ); } + Future _openMessage(GetMessageResponse response) async { + final client = StreamChat.of(context).client; + final router = GoRouter.of(context); + final message = response.message; + final channel = client.channel( + response.channel!.type, + id: response.channel!.id, + ); + if (channel.state == null) await channel.watch(); + router.goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: Routes.CHANNEL_PAGE.queryParams(message), + ); + } +} + +/// Empty state for [PinnedMessagesScreen] — pin icon, "No pinned +/// messages" headline, and a centered subtitle that nudges the user +/// toward the long-press flow that creates one. +class _EmptyState extends StatelessWidget { + const _EmptyState(); + @override - void dispose() { - controller.dispose(); - super.dispose(); + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + context.streamIcons.pin, + size: 32, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.sm), + Text( + 'No pinned messages', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + SizedBox(height: spacing.xxs), + Text( + 'Long-press a message to pin it to the chat', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); } } diff --git a/sample_app/lib/utils/client_extensions.dart b/sample_app/lib/utils/client_extensions.dart new file mode 100644 index 0000000000..4865dc62da --- /dev/null +++ b/sample_app/lib/utils/client_extensions.dart @@ -0,0 +1,25 @@ +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Sample-app helpers around `currentUser.mutes` and +/// `currentUser.blockedUserIds` — wrap the verbose +/// `currentUserStream.map(...).distinct()` pattern so the call sites stay +/// readable. +extension ClientUserStateExtensions on StreamChatClient { + /// Whether the current user has muted the user with the given [userId]. + bool isUserMuted(String userId) => state.currentUser?.mutes.any((m) => m.target.id == userId) ?? false; + + /// Reactive variant of [isUserMuted] — emits `true`/`false` as the current + /// user's mute list changes. + Stream userMutedStream(String userId) { + return state.currentUserStream.map((u) => u?.mutes.any((m) => m.target.id == userId) ?? false).distinct(); + } + + /// Whether the current user has blocked the user with the given [userId]. + bool isUserBlocked(String userId) => state.currentUser?.blockedUserIds.contains(userId) ?? false; + + /// Reactive variant of [isUserBlocked] — emits `true`/`false` as the + /// current user's blocked list changes. + Stream userBlockedStream(String userId) { + return state.currentUserStream.map((u) => u?.blockedUserIds.contains(userId) ?? false).distinct(); + } +} diff --git a/sample_app/lib/widgets/add_members_sheet.dart b/sample_app/lib/widgets/add_members_sheet.dart new file mode 100644 index 0000000000..6dbbe76261 --- /dev/null +++ b/sample_app/lib/widgets/add_members_sheet.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/search_text_field.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showAddMembersSheet} +/// Displays the Add Members bottom sheet for [channel] — Figma frame +/// `9857:114080` (and the `Selected` / `Search` / `No Result` variants). +/// +/// Resolves to `true` when the user picks one or more members and confirms +/// (the [Channel.addMembers] call has settled by then), `false` if they +/// dismiss without adding anyone. +/// {@endtemplate} +Future showAddMembersSheet(BuildContext context, Channel channel) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, scrollController) => StreamChannel( + channel: channel, + child: AddMembersSheet(scrollController: scrollController), + ), + ); +} + +/// {@template addMembersSheet} +/// A bottom sheet that lets the current user search the directory and +/// add one or more users to the enclosing channel. +/// +/// Layout: +/// +/// * [StreamSheetHeader] with the auto-implied close on the leading +/// side and a primary checkmark trailing button — the checkmark stays +/// disabled until at least one user is ticked. +/// * [StreamTextInput] search field. Edits are debounced before they +/// flow into the user-list controller's filter so we don't fire a +/// new query on every keystroke. +/// * Paginated [StreamUserListView] of users that aren't already +/// members of the channel. Rows are custom — avatar + name + +/// [StreamCheckbox.circular] trailing — and tapping anywhere on the +/// row toggles selection. +/// * A custom no-result empty state when the search query yields +/// nothing. +/// {@endtemplate} +class AddMembersSheet extends StatefulWidget { + /// {@macro addMembersSheet} + const AddMembersSheet({super.key, this.scrollController}); + + /// Scroll controller forwarded by [showStreamSheet] — wired into the + /// inner [StreamUserListView] so dragging the list past the top + /// dismisses the sheet. + final ScrollController? scrollController; + + @override + State createState() => _AddMembersSheetState(); +} + +class _AddMembersSheetState extends State { + late final Channel _channel = StreamChannel.of(context).channel; + late final StreamChatClient _client = StreamChat.of(context).client; + late final String? _currentUserId = _client.state.currentUser?.id; + + late final StreamUserListController _userListController = StreamUserListController( + client: _client, + limit: 25, + filter: _filter(query: ''), + sort: const [SortOption.asc(UserSortKey.name)], + ); + + late final TextEditingController _searchController = TextEditingController()..addListener(_onSearchChanged); + + Timer? _debounce; + String _query = ''; + + // Locally tracked selections — channel.addMembers fires only when the + // user taps the checkmark, so toggling rows in/out is purely UI state + // until then. + final Set _selectedIds = {}; + bool _saving = false; + + bool get _canConfirm => _selectedIds.isNotEmpty && !_saving; + + Filter _filter({required String query}) { + final excludedIds = { + if (_currentUserId case final id?) id, + for (final member in _channel.state!.members) + if (member.userId case final id?) id, + }; + return Filter.and([ + if (query.isNotEmpty) Filter.autoComplete('name', query), + Filter.notIn('id', excludedIds.toList()), + ]); + } + + void _onSearchChanged() { + final next = _searchController.text; + if (next == _query) return; + + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (!mounted) return; + setState(() => _query = next); + _userListController.filter = _filter(query: next); + _userListController.doInitialLoad(); + }); + } + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + _userListController.dispose(); + super.dispose(); + } + + void _toggle(User user) { + setState(() { + if (!_selectedIds.add(user.id)) _selectedIds.remove(user.id); + }); + } + + Future _confirm() async { + if (_selectedIds.isEmpty) return; + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + + setState(() => _saving = true); + try { + await _channel.addMembers(_selectedIds.toList()); + if (!mounted) return; + navigator.pop(true); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to add members: $e')), + ); + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final viewInsets = MediaQuery.viewInsetsOf(context); + + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamSheetHeader( + title: const Text('Add Members'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + type: .solid, + onPressed: _canConfirm ? _confirm : null, + ), + ), + SearchTextField(controller: _searchController), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: StreamUserListView( + controller: _userListController, + scrollController: widget.scrollController, + separatorBuilder: (_, _, _) => SizedBox(height: spacing.xxs), + itemBuilder: (context, users, index, _) { + final user = users[index]; + return _UserRow( + user: user, + selected: _selectedIds.contains(user.id), + onTap: _saving ? null : () => _toggle(user), + ); + }, + emptyBuilder: (context) => Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.search), + emptyTitle: const Text('No user found'), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// Single user row — leading [StreamUserAvatar], name, trailing circular +/// checkbox. Tapping anywhere on the row toggles selection. +class _UserRow extends StatelessWidget { + const _UserRow({ + required this.user, + required this.selected, + required this.onTap, + }); + + final User user; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: .symmetric(horizontal: spacing.xxs), + child: StreamListTileTheme( + data: StreamListTileThemeData( + minTileHeight: 48, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: StreamUserAvatar(user: user, size: .md), + title: Text(user.name), + trailing: StreamCheckbox.circular( + value: selected, + onChanged: onTap == null ? null : (_) => onTap!(), + ), + onTap: onTap, + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart new file mode 100644 index 0000000000..ce25be938d --- /dev/null +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -0,0 +1,454 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/utils/client_extensions.dart'; +import 'package:sample_app/widgets/add_members_sheet.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +// --------------------------------------------------------------------------- +// Public sheets + dispatcher +// --------------------------------------------------------------------------- + +/// {@template showAllMembersSheet} +/// Displays a bottom sheet listing every member of [channel] — Figma frame +/// `8833:434949`. Tapping a member stacks a [ContactDetailSheet] over this +/// one. +/// {@endtemplate} +Future showAllMembersSheet(BuildContext context, Channel channel) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, scrollController) => StreamChannel( + channel: channel, + child: AllMembersSheet(scrollController: scrollController), + ), + ); +} + +/// {@template showContactDetailSheet} +/// Displays a compact bottom sheet with quick actions for a single [user] +/// — Figma frame `8833:434317`. +/// +/// Resolves to the [ContactDetailAction] the user picked, or `null` if +/// they dismissed it. For the common case of opening the sheet *and* +/// running the action, prefer [openContactDetail] which combines both. +/// {@endtemplate} +Future showContactDetailSheet({ + required BuildContext context, + required User user, +}) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, _) => ContactDetailSheet(user: user), + ); +} + +/// Opens [showContactDetailSheet] and dispatches the action the user picks. +/// +/// Callers don't have to know what each action means — they just plug this +/// into a member-row's `onTap`. +Future openContactDetail(BuildContext context, User user) async { + final action = await showContactDetailSheet(context: context, user: user); + if (action == null || !context.mounted) return; + await _onContactDetailAction(context, action); +} + +/// {@template contactDetailAction} +/// A sealed class representing the actions a user can pick from a +/// [ContactDetailSheet]. Each action carries the [user] it targets so the +/// dispatcher has everything it needs without re-deriving context. +/// {@endtemplate} +sealed class ContactDetailAction { + /// {@macro contactDetailAction} + const ContactDetailAction({required this.user}); + + /// The user this action targets. + final User user; +} + +/// User tapped _Send Direct Message_. +final class SendDirectMessage extends ContactDetailAction { + /// {@macro contactDetailAction} + const SendDirectMessage({required super.user}); +} + +/// User tapped _Mute User_. +final class MuteUser extends ContactDetailAction { + /// {@macro contactDetailAction} + const MuteUser({required super.user}); +} + +/// User tapped _Unmute User_. +final class UnmuteUser extends ContactDetailAction { + /// {@macro contactDetailAction} + const UnmuteUser({required super.user}); +} + +/// User tapped _Block User_. +final class BlockUser extends ContactDetailAction { + /// {@macro contactDetailAction} + const BlockUser({required super.user}); +} + +// --------------------------------------------------------------------------- +// AllMembersSheet +// --------------------------------------------------------------------------- + +/// {@template allMembersSheet} +/// Bottom-sheet body listing every member of the enclosing channel. +/// +/// Tapping a member opens a stacked [ContactDetailSheet]; the parent sheet +/// stays mounted underneath so users return to the same scroll position +/// after dismissing the detail sheet. +/// {@endtemplate} +class AllMembersSheet extends StatelessWidget { + /// {@macro allMembersSheet} + const AllMembersSheet({super.key, this.scrollController}); + + /// Scroll controller forwarded by [showStreamSheet]; attached to the + /// inner [ListView] so dragging the list past the top dismisses the sheet. + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final channel = StreamChannel.of(context).channel; + final currentUserId = StreamChat.of(context).currentUser?.id; + + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + final sorted = [...members].sorted((a, b) { + if (a.userId == currentUserId) return -1; + if (b.userId == currentUserId) return 1; + return 0; + }); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader( + title: Text('${members.length} Members'), + trailing: switch (channel.canUpdateChannelMembers && !channel.isDistinct) { + true => StreamButton.icon( + icon: Icon(context.streamIcons.userAdd), + type: .outline, + style: .secondary, + onPressed: () => showAddMembersSheet(context, channel), + ), + false => null, + }, + ), + Expanded( + child: ListView.builder( + itemCount: sorted.length, + controller: scrollController, + padding: .symmetric(horizontal: spacing.xxs), + itemBuilder: (context, index) { + final member = sorted[index]; + return ChannelMemberTile( + member: member, + isCurrentUser: member.userId == currentUserId, + onTap: switch (member.userId) { + final id? when id != currentUserId => () { + final user = member.user; + if (user != null) openContactDetail(context, user); + }, + _ => null, + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// ContactDetailSheet +// --------------------------------------------------------------------------- + +/// {@template contactDetailSheet} +/// Compact bottom sheet showing a member's avatar / name / online status +/// followed by quick actions: _Send Direct Message_, _Mute / Unmute User_, +/// _Block User_. +/// +/// Pops the route with one of [ContactDetailAction]'s subtypes when the +/// user picks an action — caller dispatches via [openContactDetail]. +/// {@endtemplate} +class ContactDetailSheet extends StatelessWidget { + /// {@macro contactDetailSheet} + const ContactDetailSheet({super.key, required this.user}); + + /// The member the sheet is showing actions for. + final User user; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final icons = context.streamIcons; + final client = StreamChat.of(context).client; + + void emit(ContactDetailAction action) => Navigator.of(context).pop(action); + + return SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: spacing.xl, horizontal: spacing.sm), + child: _ContactDetailHeader(user: user), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ActionTile( + icon: icons.messageBubble, + label: 'Send Direct Message', + onTap: () => emit(SendDirectMessage(user: user)), + ), + // Reactively flip Mute / Unmute as the global mute list + // updates — emits MuteUser when not yet muted. + BetterStreamBuilder( + stream: client.userMutedStream(user.id), + initialData: client.isUserMuted(user.id), + builder: (context, isMuted) => _ActionTile( + icon: isMuted ? icons.audio : icons.mute, + label: isMuted ? 'Unmute User' : 'Mute User', + onTap: () => emit(isMuted ? UnmuteUser(user: user) : MuteUser(user: user)), + ), + ), + _ActionTile( + icon: icons.noSign, + label: 'Block User', + onTap: () => emit(BlockUser(user: user)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Compact header for [ContactDetailSheet] — avatar with online indicator +/// on the left, name + online status stacked on the right. +class _ContactDetailHeader extends StatelessWidget { + const _ContactDetailHeader({required this.user}); + + final User user; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Row( + spacing: spacing.sm, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + StreamUserAvatar(user: user, size: .lg, showOnlineIndicator: user.online), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xxs, + children: [ + Text( + user.name, + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + overflow: TextOverflow.ellipsis, + ), + Text( + _userStatus(user), + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({required this.icon, required this.label, this.onTap}); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return StreamListTileTheme( + data: StreamListTileThemeData( + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: Icon(icon), + title: Text(label), + onTap: onTap, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// ChannelMemberTile (shared) +// --------------------------------------------------------------------------- + +/// {@template channelMemberTile} +/// Single channel-member row — avatar with online indicator, name (with a +/// "You" substitution for the current user), an online / last-seen +/// subtitle, and an _Admin_ trailing label for moderators / owners. +/// +/// Used by both the group-info screen members preview and the +/// [AllMembersSheet]; lifted here so the two surfaces always render +/// identically. +/// {@endtemplate} +class ChannelMemberTile extends StatelessWidget { + /// {@macro channelMemberTile} + const ChannelMemberTile({ + super.key, + required this.member, + required this.isCurrentUser, + this.onTap, + }); + + /// The member being rendered. + final Member member; + + /// Whether [member] is the currently signed-in user. When `true`, the + /// tile shows the literal string "You" in place of the user's name. + final bool isCurrentUser; + + /// Optional tap handler — typically opens [ContactDetailSheet] for the + /// member's user. Pass `null` to make the row non-interactive. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + final user = member.user; + if (user == null) return const SizedBox.shrink(); + + final name = isCurrentUser ? 'You' : user.name; + final isAdmin = const {'admin', 'channel_moderator', 'owner'}.contains(member.channelRole); + + return StreamListTile( + leading: StreamUserAvatar(user: user, size: .md, showOnlineIndicator: user.online), + title: Text(name), + subtitle: Text(_userStatus(user)), + trailing: BetterStreamBuilder( + stream: StreamChat.of(context).client.userMutedStream(user.id), + initialData: StreamChat.of(context).client.isUserMuted(user.id), + builder: (context, isMuted) { + if (!isMuted && !isAdmin) return const SizedBox.shrink(); + return Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + if (isMuted) Icon(context.streamIcons.mute, color: colorScheme.textTertiary), + if (isAdmin) + Text( + 'Admin', + style: textTheme.bodyDefault.copyWith(color: colorScheme.textTertiary), + ), + ], + ); + }, + ), + onTap: onTap, + ); + } +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +Future _onContactDetailAction( + BuildContext context, + ContactDetailAction action, +) async => switch (action) { + SendDirectMessage(:final user) => _openDirectChannel(context, user), + MuteUser(:final user) => StreamChat.of(context).client.muteUser(user.id), + UnmuteUser(:final user) => StreamChat.of(context).client.unmuteUser(user.id), + BlockUser(:final user) => StreamChat.of(context).client.blockUser(user.id), +}; + +/// Finds (or creates) a 1-1 distinct messaging channel between the current +/// user and [user] and pushes it via [GoRouter]. Pops any enclosing +/// [StreamSheetRoute] first so the new channel page lands on the regular +/// page stack instead of stacking on top of a still-visible sheet. +Future _openDirectChannel(BuildContext context, User user) async { + final chat = StreamChat.of(context); + final router = GoRouter.of(context); + final currentUser = chat.currentUser; + if (currentUser == null) return; + + final existing = await chat.client.queryChannelsOnline( + state: false, + watch: false, + filter: Filter.raw( + value: { + 'members': [currentUser.id, user.id], + 'distinct': true, + }, + ), + messageLimit: 0, + paginationParams: const PaginationParams(limit: 1), + ); + + Channel channel; + if (existing.isNotEmpty) { + channel = existing.first; + if (channel.state == null) await channel.watch(); + } else { + channel = chat.client.channel( + 'messaging', + extraData: { + 'members': [currentUser.id, user.id], + }, + ); + await channel.watch(); + } + + if (!context.mounted) return; + // Drop the enclosing all-members sheet (if any) so the channel page + // doesn't render on top of a half-open sheet. + if (StreamSheetRoute.hasParentSheet(context)) { + StreamSheetRoute.popSheet(context); + } + router.pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); +} + +String _userStatus(User user) { + if (user.online) return 'Online'; + final lastActive = user.lastActive; + if (lastActive == null) return 'Offline'; + return 'Last seen ${Jiffy.parseFromDateTime(lastActive).fromNow()}'; +} diff --git a/sample_app/lib/widgets/channel_detail_sheet.dart b/sample_app/lib/widgets/channel_detail_sheet.dart new file mode 100644 index 0000000000..99cb3dee76 --- /dev/null +++ b/sample_app/lib/widgets/channel_detail_sheet.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/utils/client_extensions.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template channelDetailAction} +/// A sealed class that represents the actions a user can pick from a +/// [ChannelDetailSheet]. +/// +/// The sheet pops itself with one of these values when an action is tapped, +/// and callers switch on the returned action to decide what to do. +/// {@endtemplate} +sealed class ChannelDetailAction { + /// {@macro channelDetailAction} + const ChannelDetailAction(); +} + +/// User tapped _View Info_ — caller is expected to push the chat or group +/// info screen depending on whether [user] is set. +/// +/// On 1-1 channels, [user] carries the other member; the caller pushes +/// `ChatInfoScreen` for that user. On group channels, [user] is `null` and +/// the caller pushes `GroupInfoScreen`. +final class ViewChannelInfo extends ChannelDetailAction { + /// {@macro channelDetailAction} + const ViewChannelInfo({this.user}); + + /// The other member of the 1-1 channel, or `null` for group channels. + final User? user; +} + +/// User tapped _Pin Chat_ — caller is expected to invoke [Channel.pin]. +final class PinChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const PinChannel(); +} + +/// User tapped _Unpin Chat_ — caller is expected to invoke [Channel.unpin]. +final class UnpinChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const UnpinChannel(); +} + +/// User tapped _Mute User_ — caller is expected to invoke +/// [StreamChatClient.muteUser] for [user]. +final class MuteChannelMember extends ChannelDetailAction { + /// {@macro channelDetailAction} + const MuteChannelMember({required this.user}); + + /// The mute target — the other member of the 1-1 channel. + final User user; +} + +/// User tapped _Unmute User_ — caller is expected to invoke +/// [StreamChatClient.unmuteUser] for [user]. +final class UnmuteChannelMember extends ChannelDetailAction { + /// {@macro channelDetailAction} + const UnmuteChannelMember({required this.user}); + + /// The unmute target — the other member of the 1-1 channel. + final User user; +} + +/// User tapped _Block User_ — caller is expected to invoke +/// [StreamChatClient.blockUser] for [user]. +/// +/// Block is one-way from this sheet: blocked users' channels are filtered out +/// of the channel list, so the sheet can never re-open for an already-blocked +/// user. Unblock lives on a different surface (e.g. blocked-users settings). +final class BlockChannelMember extends ChannelDetailAction { + /// {@macro channelDetailAction} + const BlockChannelMember({required this.user}); + + /// The block target — the other member of the 1-1 channel. + final User user; +} + +/// User tapped _Leave Group_ — caller is expected to confirm and remove the +/// current user from the channel members. +final class LeaveChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const LeaveChannel(); +} + +/// User tapped _Delete Group_ / _Delete Conversation_ — caller is expected +/// to confirm and invoke [Channel.delete]. +final class DeleteChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const DeleteChannel(); +} + +/// {@template showChannelDetailSheet} +/// Displays a [ChannelDetailSheet] for [channel] — the redesigned long-press +/// menu surfaced from the channel list. +/// +/// Resolves to the [ChannelDetailAction] the user picked, or `null` if the +/// sheet was dismissed without selecting one (drag-down, scrim tap, back +/// gesture). Callers should switch on the returned action. +/// +/// Built on top of [showStreamSheet] so it inherits the design system's drag +/// handle, scrim, and drag-to-dismiss interaction. +/// {@endtemplate} +Future showChannelDetailSheet({ + required BuildContext context, + required Channel channel, +}) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, _) => StreamChannel( + channel: channel, + child: ChannelDetailSheet(channel: channel), + ), + ); +} + +/// {@template channelDetailSheet} +/// A bottom sheet that displays detailed information and actions for a +/// [Channel]. +/// +/// Composed of: +/// +/// * A header with the channel avatar, name (with mute / pin state +/// indicators), and member count. +/// * A list of channel actions — _View Info_, _Pin / Unpin Chat_, +/// _Mute / Unmute User_, _Block / Unblock User_ (1-1 only), +/// _Leave Group_, _Delete Group / Conversation_. +/// +/// Tapping an action pops the route with the corresponding +/// [ChannelDetailAction] subtype; the caller is responsible for performing +/// the action. Destructive actions (leave / delete) are styled via a local +/// [StreamListTileTheme] override that swaps the icon and title colors to +/// [StreamColorScheme.accentError]. +/// +/// Designed to be hosted inside a [showStreamSheet] route — see +/// [showChannelDetailSheet] for the convenience entry point. +/// {@endtemplate} +class ChannelDetailSheet extends StatelessWidget { + /// {@macro channelDetailSheet} + const ChannelDetailSheet({super.key, required this.channel}); + + /// The channel whose information and actions are displayed. + final Channel channel; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + final client = StreamChat.of(context).client; + final currentUserId = client.state.currentUser?.id; + + final isOneToOne = channel.isOneToOne; + final canLeave = !isOneToOne && channel.canLeaveChannel; + final canDelete = channel.canDeleteChannel; + + // For 1-1 channels, mute/block actions target the other member. + final channelMembers = channel.state?.members ?? []; + final otherUser = isOneToOne ? channelMembers.firstWhere((m) => m.userId != currentUserId).user : null; + + void emit(ChannelDetailAction action) => Navigator.of(context).pop(action); + + return SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 20), + child: Column( + mainAxisSize: .min, + children: [ + Padding( + padding: .symmetric(horizontal: spacing.sm, vertical: spacing.xl), + child: _ChannelDetailHeader(channel: channel), + ), + Padding( + padding: .symmetric(horizontal: spacing.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ChannelDetailAction( + icon: icons.info, + label: 'View Info', + onTap: () => emit(ViewChannelInfo(user: otherUser)), + ), + BetterStreamBuilder( + stream: channel.isPinnedStream, + initialData: channel.isPinned, + builder: (context, isPinned) => _ChannelDetailAction( + icon: isPinned ? icons.unpin : icons.pin, + label: isPinned ? 'Unpin Chat' : 'Pin Chat', + onTap: () => emit(isPinned ? const UnpinChannel() : const PinChannel()), + ), + ), + if (otherUser != null) ...[ + BetterStreamBuilder( + stream: client.userMutedStream(otherUser.id), + initialData: client.isUserMuted(otherUser.id), + builder: (context, isMuted) => _ChannelDetailAction( + icon: isMuted ? icons.audio : icons.mute, + label: isMuted ? 'Unmute User' : 'Mute User', + onTap: () => emit( + isMuted ? UnmuteChannelMember(user: otherUser) : MuteChannelMember(user: otherUser), + ), + ), + ), + _ChannelDetailAction( + icon: icons.noSign, + label: 'Block User', + onTap: () => emit(BlockChannelMember(user: otherUser)), + ), + ], + if (canLeave) + _ChannelDetailAction( + icon: icons.leave, + label: 'Leave Group', + destructive: true, + onTap: () => emit(const LeaveChannel()), + ), + if (canDelete) + _ChannelDetailAction( + icon: icons.delete, + label: isOneToOne ? 'Delete Chat' : 'Delete Group', + destructive: true, + onTap: () => emit(const DeleteChannel()), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// The header row of the [ChannelDetailSheet] — channel avatar on the left, +/// channel name (with mute / pin state) and member count on the right. +class _ChannelDetailHeader extends StatelessWidget { + const _ChannelDetailHeader({required this.channel}); + + final Channel channel; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Row( + spacing: spacing.sm, + crossAxisAlignment: .center, + children: [ + StreamChannelAvatar(channel: channel, size: .lg), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xxs, + children: [ + _ChannelDetailHeaderTitle(channel: channel), + StreamChannelInfo( + channel: channel, + showTypingIndicator: false, + textStyle: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ), + ], + ); + } +} + +/// The title row inside [_ChannelDetailHeader] — channel name followed by +/// optional mute and pin state-indicator icons. +class _ChannelDetailHeaderTitle extends StatelessWidget { + const _ChannelDetailHeaderTitle({required this.channel}); + + final Channel channel; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final icons = context.streamIcons; + + return Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + Flexible( + child: StreamChannelName( + channel: channel, + textStyle: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [ + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) { + if (!isMuted) return const SizedBox.shrink(); + return Icon(icons.mute, color: colorScheme.textPrimary); + }, + ), + BetterStreamBuilder( + stream: channel.isPinnedStream, + initialData: channel.isPinned, + builder: (context, isPinned) { + if (!isPinned) return const SizedBox.shrink(); + return Icon(icons.pin, color: colorScheme.textPrimary); + }, + ), + ], + ), + ], + ); + } +} + +/// A single tappable action row inside the [ChannelDetailSheet]. +/// +/// Wraps [StreamListTile] so all theming (typography, padding, ink effects, +/// disabled / selected state colors) flows from [StreamListTileTheme]. When +/// [destructive] is true, a local [StreamListTileTheme] override paints the +/// icon and title with [StreamColorScheme.accentError]. +class _ChannelDetailAction extends StatelessWidget { + const _ChannelDetailAction({ + required this.icon, + required this.label, + this.onTap, + this.destructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: Icon(icon), + title: Text(label), + onTap: onTap, + ), + ); + } +} diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 603357b2dd..01602ae161 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -4,9 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:go_router/go_router.dart'; -import 'package:sample_app/pages/chat_info_screen.dart'; -import 'package:sample_app/pages/group_info_screen.dart'; import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/widgets/channel_detail_sheet.dart'; import 'package:sample_app/widgets/search_text_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -114,7 +113,6 @@ class _ChannelListDefault extends StatelessWidget { @override Widget build(BuildContext context) { - final chatTheme = StreamChatTheme.of(context); return SlidableAutoCloseBehavior( child: RefreshIndicator( onRefresh: channelListController.refresh, @@ -122,6 +120,10 @@ class _ChannelListDefault extends StatelessWidget { controller: channelListController, itemBuilder: (context, channels, index, defaultWidget) { final channel = channels[index]; + + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + return Slidable( groupTag: 'channels-actions', endActionPane: ActionPane( @@ -129,86 +131,137 @@ class _ChannelListDefault extends StatelessWidget { motion: const BehindMotion(), children: [ CustomSlidableAction( - backgroundColor: context.streamColorScheme.backgroundSurface, - onPressed: (_) { - showChannelInfoModalBottomSheet( - context: context, - channel: channel, - onViewInfoTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - final isOneToOne = channel.memberCount == 2 && channel.isDistinct; - return StreamChannel( - channel: channel, - child: isOneToOne - ? ChatInfoScreen( - user: channel.state!.members - .where((m) => m.userId != channel.client.state.currentUser!.id) - .first - .user, - ) - : const GroupInfoScreen(), - ); - }, - ), - ); - }, - ); - }, - child: const Icon(Icons.more_horiz), + foregroundColor: colorScheme.textPrimary, + backgroundColor: colorScheme.backgroundSurface, + onPressed: (_) => _openChannelDetailSheet(context, channel), + child: Icon(icons.more, size: 20), ), BetterStreamBuilder( stream: channel.isMutedStream, initialData: channel.isMuted, builder: (context, isMuted) => CustomSlidableAction( - backgroundColor: chatTheme.colorTheme.accentPrimary, - foregroundColor: Colors.white, - onPressed: (_) async { - if (isMuted) { - await channel.unmute(); - } else { - await channel.mute(); - } + foregroundColor: colorScheme.textOnAccent, + backgroundColor: colorScheme.accentPrimary, + onPressed: (_) { + if (isMuted) return channel.unmute().ignore(); + return channel.mute().ignore(); }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isMuted ? context.streamIcons.audio : context.streamIcons.mute, - size: 20, - color: Colors.white, - ), - ], - ), + child: Icon(isMuted ? icons.audio : icons.mute, size: 20), ), ), ], ), - child: BetterStreamBuilder( - stream: channel.isPinnedStream, - initialData: channel.isPinned, - builder: (context, isPinned) => ColoredBox( - color: isPinned ? chatTheme.colorTheme.highlight : Colors.transparent, - child: defaultWidget, - ), - ), - ); - }, - onChannelTap: (channel) { - GoRouter.of(context).pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), + child: defaultWidget, ); }, + onChannelTap: (channel) => _openChannelPage(context, channel), + onChannelLongPress: (channel) => _openChannelDetailSheet(context, channel), ), ), ); } } +// Pushes the channel page for [channel] via [GoRouter]. +Future _openChannelPage(BuildContext context, Channel channel) { + return GoRouter.of(context).pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); +} + +// Opens the channel detail sheet and dispatches on the user's selection. +// +// The sheet pops itself with a [ChannelDetailAction] subtype; this function +// awaits that result and routes to the matching handler. +Future _openChannelDetailSheet( + BuildContext context, + Channel channel, +) async { + final action = await showChannelDetailSheet(context: context, channel: channel); + + if (action == null || !context.mounted) return; + return _onChannelDetailAction(context, channel, action).ignore(); +} + +// Switches on a [ChannelDetailAction] and dispatches to the per-action +// handler. +Future _onChannelDetailAction( + BuildContext context, + Channel channel, + ChannelDetailAction action, +) async { + final client = StreamChat.of(context).client; + return switch (action) { + ViewChannelInfo(:final user) => _pushChannelInfo(context, channel, user), + PinChannel() => channel.pin(), + UnpinChannel() => channel.unpin(), + MuteChannelMember(:final user) => client.muteUser(user.id), + UnmuteChannelMember(:final user) => client.unmuteUser(user.id), + BlockChannelMember(:final user) => client.blockUser(user.id), + LeaveChannel() => _maybeLeaveChannel(context, channel), + DeleteChannel() => _maybeDeleteChannel(context, channel), + }; +} + +// Pushes the chat / group info screen depending on whether [user] was +// resolved. 1-1 channels pass the other member here (forwarded as `extra` +// to the chat-info route); group channels pass `null` and route to the +// group info screen. +Future _pushChannelInfo(BuildContext context, Channel channel, User? user) { + final router = GoRouter.of(context); + + if (user != null) { + return router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: user, + ); + } + + return router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); +} + +// Shows a confirmation dialog before removing the current user from the +// channel. Leave is only surfaced for group channels in the detail sheet, +// so the copy is group-specific here. +Future _maybeLeaveChannel(BuildContext context, Channel channel) async { + final currentUserId = StreamChat.of(context).currentUser?.id; + if (currentUserId == null) return; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Leave group', + content: 'Are you sure you want to leave this group?', + confirmLabel: 'Leave', + ); + + if (confirmed != true) return; + await channel.removeMembers([currentUserId]); +} + +// Shows a confirmation dialog before deleting the channel. On success, pops +// the channel page if currently visible (e.g. when invoked from inside a +// channel route). +Future _maybeDeleteChannel(BuildContext context, Channel channel) async { + final router = GoRouter.of(context); + final subject = channel.isOneToOne ? 'conversation' : 'group'; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Delete ${subject.toLowerCase()}', + content: 'Are you sure you want to delete this $subject?', + confirmLabel: 'Delete', + ); + + if (confirmed != true) return; + await channel.delete(); + if (router.canPop()) router.pop(); +} + class _ChannelListSearch extends StatelessWidget { const _ChannelListSearch(this.messageSearchListController); @@ -249,36 +302,90 @@ class _ChannelListSearch extends StatelessWidget { }, ); }, - itemBuilder: - ( - context, - messageResponses, - index, - defaultWidget, - ) { - final messageResponse = messageResponses[index]; - - return defaultWidget.copyWith( - onTap: () async { - FocusScope.of(context).requestFocus(FocusNode()); - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final message = messageResponse.message; - final channel = client.channel( - messageResponse.channel!.type, - id: messageResponse.channel!.id, - ); - if (channel.state == null) { - await channel.watch(); - } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ); - }, + itemBuilder: (context, messageResponses, index, defaultWidget) { + final messageResponse = messageResponses[index]; + + return defaultWidget.copyWith( + onTap: () async { + FocusScope.of(context).requestFocus(FocusNode()); + final client = StreamChat.of(context).client; + final router = GoRouter.of(context); + final message = messageResponse.message; + final channel = client.channel( + messageResponse.channel!.type, + id: messageResponse.channel!.id, + ); + if (channel.state == null) { + await channel.watch(); + } + router.pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: Routes.CHANNEL_PAGE.queryParams(message), ); }, + ); + }, + ); + } +} + +// Shows a Stream-styled confirmation [AlertDialog] with a destructive +// primary action — used by the leave / delete handlers above. +// +// Resolves to `true` when the user taps confirm, `false` when they tap +// cancel, and `null` if the dialog is dismissed without a choice. +Future _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) => _ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + ), + ); +} + +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ + required this.title, + required this.content, + required this.confirmLabel, + }); + + final String title; + final String content; + final String confirmLabel; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return AlertDialog( + backgroundColor: colorScheme.backgroundElevation1, + title: Text(title), + content: Text(content), + actions: [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: Text(context.translations.cancelLabel), + ), + StreamButton( + type: .ghost, + style: .destructive, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: Text(confirmLabel), + ), + ], ); } } diff --git a/sample_app/lib/widgets/edit_group_sheet.dart b/sample_app/lib/widgets/edit_group_sheet.dart new file mode 100644 index 0000000000..2952d02e95 --- /dev/null +++ b/sample_app/lib/widgets/edit_group_sheet.dart @@ -0,0 +1,539 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showEditGroupSheet} +/// Displays the group-edit bottom sheet for [channel] — Figma frame +/// `8833:446261`. Resolves to `true` if the user saved a change, otherwise +/// `false` / `null` on dismiss. +/// {@endtemplate} +Future showEditGroupSheet(BuildContext context, Channel channel) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, _) => StreamChannel( + channel: channel, + child: const EditGroupSheet(), + ), + ); +} + +/// {@template editGroupSheet} +/// A bottom sheet that lets the current user rename the channel and replace +/// (or reset) the channel avatar. +/// +/// The avatar tap (or _Upload_ link) opens [_AvatarPickerSheet], which +/// surfaces _Take Photo_, _Choose Image_, and _Reset Picture_ as the +/// three quick actions. Picked images are uploaded immediately via +/// [StreamChatClient.sendImage] so the URL is settled by the time the user +/// taps the save checkmark. +/// {@endtemplate} +class EditGroupSheet extends StatefulWidget { + /// {@macro editGroupSheet} + const EditGroupSheet({super.key}); + + @override + State createState() => _EditGroupSheetState(); +} + +class _EditGroupSheetState extends State { + late final Channel _channel = StreamChannel.of(context).channel; + late final StreamChatClient _client = StreamChat.of(context).client; + late final TextEditingController _nameController = TextEditingController( + text: _channel.name ?? '', + ); + + // Path to the picked image file on the local device. Drives the + // preview via Image.file so the swap is instant (no CDN round-trip), + // independent of the upload's URL state. Mirrors the LLC's + // sendMessage attachment flow — local file is the source of truth + // until the URL takes over on the server. + String? _pickedPath; + + // CDN URL returned from the standalone upload. Used only to persist + // the avatar on save — the in-sheet preview reads from [_pickedPath]. + String? _imageOverride; + + // True when the user tapped Reset Picture. Persisted as an `image` + // unset only when save is tapped. + bool _imageRemoved = false; + + bool _saving = false; + + // Determinate upload progress in [0, 1], or `null` when no upload is in + // flight. Drives the spinner overlay on the avatar preview and gates the + // save checkmark so users can't persist before the URL has settled. + double? _uploadProgress; + + // Standalone uploads we've kicked off in this session. On save, we + // strip the URL we're about to persist and delete the rest — they + // were superseded by a later pick. On dispose without save, we + // delete every entry so abandoned uploads don't leak on the CDN. + final List _trackedUploads = []; + + String get _name => _nameController.text.trim(); + String get _initialName => (_channel.extraData['name'] as String?) ?? ''; + + bool get _isDirty { + if (_name != _initialName) return true; + if (_pickedPath != null) return true; + if (_imageRemoved) return true; + return false; + } + + // Save is gated on at least one change *and* a non-empty name (the API + // won't accept blanks) *and* no upload in flight (so we never persist a + // stale URL). + bool get _canSave => _isDirty && _name.isNotEmpty && !_saving && _uploadProgress == null; + + @override + void initState() { + super.initState(); + _nameController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _nameController.dispose(); + // Sheet was dismissed without saving — delete every standalone + // upload we held on to. Save() clears the list before pop, so this + // only fires for the abandon case. + for (final url in _trackedUploads) { + _deleteOrphan(url); + } + _trackedUploads.clear(); + super.dispose(); + } + + // Fire-and-forget cleanup of a CDN upload via the standalone delete + // API. Failure to delete just leaks one orphan, which the user can + // survive. + void _deleteOrphan(String url) { + _client.deleteImage(url, _channel.id!, _channel.type).ignore(); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + // The sheet route consumes the top inset via its own SafeArea, but + // intentionally leaves the bottom inset so descendants can opt in. + // The keyboard inset arrives via viewInsets — pad the body's bottom + // by it so the text input never disappears behind the keyboard. + final viewInsets = MediaQuery.viewInsetsOf(context); + + return SafeArea( + top: false, + child: Column( + // Shrink-wrap to content height — the sheet sits as high as it + // needs to be (header + avatar + input + keyboard inset) and no + // higher. With Stack(StackFit.loose) upstream the StreamSheet + // honours the min size and rests just above the keyboard. + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader( + title: const Text('Edit'), + // Default `.medium` size — matches the auto-implied close + // button on the leading side so the header stays balanced. + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + type: .solid, + onPressed: _canSave ? _save : null, + ), + ), + Padding( + padding: EdgeInsets.all(spacing.md) + viewInsets, + child: Column( + children: [ + _AvatarPreview( + pickedPath: _pickedPath, + imageOverride: _imageOverride, + imageRemoved: _imageRemoved, + uploadProgress: _uploadProgress, + onTap: _openAvatarPicker, + ), + SizedBox(height: spacing.xxl), + StreamTextInput( + controller: _nameController, + autofocus: true, + hintText: 'Group name', + ), + ], + ), + ), + ], + ), + ); + } + + Future _openAvatarPicker() async { + final action = await _showAvatarPickerSheet(context); + if (action == null || !mounted) return; + switch (action) { + case _AvatarPickerAction.takePhoto: + await _pickAndUpload(ImageSource.camera); + case _AvatarPickerAction.chooseImage: + await _pickAndUpload(ImageSource.gallery); + case _AvatarPickerAction.resetPicture: + _resetPicture(); + } + } + + Future _pickAndUpload(ImageSource source) async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: source); + if (picked == null || !mounted) return; + + final file = File(picked.path); + final size = await file.length(); + final attachmentFile = AttachmentFile( + path: picked.path, + size: size, + name: picked.name, + ); + + // Show the local file in the preview immediately (LLC pattern — + // sendMessage's attachments render via attachment.file.path until + // the upload settles). Clear any prior URL since it's superseded. + setState(() { + _pickedPath = picked.path; + _imageOverride = null; + _imageRemoved = false; + // Start at `0` so the spinner appears as a determinate bar + // immediately; the first onSendProgress callback may not arrive + // for a few hundred ms on slow networks. + _uploadProgress = 0; + }); + + try { + // Standalone upload — returns a CDN URL we can persist on the + // channel without creating a message. + final response = await _client.sendImage( + attachmentFile, + _channel.id!, + _channel.type, + onSendProgress: (count, total) { + if (!mounted) return; + // Fall back to indeterminate (`null`) when the server doesn't + // report a content length — prevents the spinner from claiming + // a fake 0% for the entire upload. + setState(() { + _uploadProgress = total > 0 ? count / total : null; + }); + }, + ); + final url = response.file; + if (url == null || !mounted) return; + _trackedUploads.add(url); + setState(() => _imageOverride = url); + } catch (e) { + if (mounted) { + // Drop the local preview so the user sees the channel revert — + // the snackbar tells them why and they can re-pick. + setState(() => _pickedPath = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Upload failed: $e')), + ); + } + } finally { + if (mounted) setState(() => _uploadProgress = null); + } + } + + void _resetPicture() { + setState(() { + _pickedPath = null; + _imageOverride = null; + _imageRemoved = true; + }); + } + + Future _save() async { + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + + setState(() => _saving = true); + try { + // Single-round-trip persist — name changes, the new avatar URL, + // or an `image` unset for a reset all flow through one + // updatePartial. + final set = {}; + final unset = []; + + if (_name != _initialName) set['name'] = _name; + if (_imageOverride != null) { + set['image'] = _imageOverride; + } else if (_imageRemoved) { + unset.add('image'); + } + + if (set.isNotEmpty || unset.isNotEmpty) { + await _channel.updatePartial(set: set, unset: unset); + } + + // Strip the saved URL from the orphan list so dispose() doesn't + // delete what we just persisted; everything else (a previous pick + // the user replaced before saving) is now genuinely orphaned — + // delete it via the standalone API. + if (_imageOverride case final saved?) _trackedUploads.remove(saved); + for (final url in _trackedUploads) { + _deleteOrphan(url); + } + _trackedUploads.clear(); + + if (!mounted) return; + navigator.pop(true); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Failed to save: $e'))); + if (mounted) setState(() => _saving = false); + } + } +} + +/// The hero avatar block — local picked-file preview, the uploaded +/// CDN URL once it's settled, the session "removed" preview, or the +/// channel's current avatar — with an _Upload_ button below. While an +/// upload is in flight, a translucent overlay + [StreamLoadingSpinner] +/// is layered on top of the avatar — same pattern as +/// `StreamAttachmentUploadStateBuilder` in the SDK. +class _AvatarPreview extends StatelessWidget { + const _AvatarPreview({ + required this.pickedPath, + required this.imageOverride, + required this.imageRemoved, + required this.uploadProgress, + required this.onTap, + }); + + /// Path to the picked image file on the local device. Used as the + /// placeholder for the URL branch so the swap is seamless — while + /// the CDN copy is being fetched, the file shows through. + final String? pickedPath; + + /// CDN URL returned by the standalone upload. Once set, the preview + /// reads from the URL (so memory doesn't hold the file image once we + /// have the canonical CDN copy) — the [pickedPath] keeps acting as + /// the placeholder during the brief network round-trip. + final String? imageOverride; + + /// `true` after the user explicitly tapped Reset Picture. Falls back + /// to the member-group avatar even if the channel still carries an + /// image (it'll be unset on save). + final bool imageRemoved; + + /// Determinate upload progress in [0, 1], or `null` when no upload is + /// in flight. A `null` value while uploading renders as an + /// indeterminate spinner — matches the SDK's fallback when content + /// length is unknown. + final double? uploadProgress; + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final channel = StreamChannel.of(context).channel; + final size = StreamAvatarGroupSize.xxl.value; + + Widget? filePlaceholder(String? path) { + if (path == null) return null; + return Image.file( + File(path), + width: size, + height: size, + fit: BoxFit.cover, + ); + } + + final base = switch ((imageOverride, pickedPath, imageRemoved)) { + // Upload finished — render the CDN URL via StreamAvatar; the + // local file (if still around) acts as the placeholder during + // CachedNetworkImage's first fetch so there's no flicker on the + // file→URL swap. + (final url?, final path, _) => StreamAvatar( + imageUrl: url, + size: .xxl, + placeholder: (_) => filePlaceholder(path) ?? _MemberFallbackAvatar(channel: channel), + ), + // Picked but upload not yet settled — render the local file via + // Image.file slotted into StreamAvatar's placeholder so the + // surrounding chrome (size, 1px border, circle clip) matches + // StreamChannelAvatar's image branch pixel-for-pixel. + (null, final path?, _) => StreamAvatar( + imageUrl: null, + size: .xxl, + placeholder: (_) => filePlaceholder(path)!, + ), + // User reset — render the member-group fallback even if the + // channel still carries an image (it'll be unset on save). + (null, null, true) => _MemberFallbackAvatar(channel: channel), + // Untouched — defer to the channel's current avatar; reloads + // automatically off `channel.imageStream` if the image changes + // out from under us. + _ => StreamChannelAvatar(channel: channel, size: .xxl), + }; + + return Column( + children: [ + // Avatar is purely a preview — only the Upload button below + // triggers the picker. Avoids two overlapping hit targets and + // keeps the affordance unambiguous. + Stack( + alignment: Alignment.center, + children: [ + base, + if (uploadProgress != null) + Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.backgroundOverlayLight, + ), + alignment: Alignment.center, + child: StreamLoadingSpinner( + value: uploadProgress, + size: .md, + ), + ), + ], + ), + SizedBox(height: spacing.xs), + StreamButton( + type: .ghost, + style: .primary, + size: .small, + onPressed: onTap, + child: const Text('Upload'), + ), + ], + ); + } +} + +/// Renders the member-group avatar fallback — used to preview the "reset +/// picture" state before save round-trips, and as the placeholder for an +/// in-flight network image. +class _MemberFallbackAvatar extends StatelessWidget { + const _MemberFallbackAvatar({required this.channel}); + + final Channel channel; + + @override + Widget build(BuildContext context) { + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + final users = [ + for (final m in members) + if (m.user case final user?) user, + ]; + return StreamUserAvatarGroup(users: users, size: .xxl); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Avatar picker (stacked) +// --------------------------------------------------------------------------- + +enum _AvatarPickerAction { takePhoto, chooseImage, resetPicture } + +Future<_AvatarPickerAction?> _showAvatarPickerSheet(BuildContext context) { + return showStreamSheet<_AvatarPickerAction>( + context: context, + isDismissible: true, + builder: (_, _) => const _AvatarPickerSheet(), + ); +} + +class _AvatarPickerSheet extends StatelessWidget { + const _AvatarPickerSheet(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final icons = context.streamIcons; + + void emit(_AvatarPickerAction action) => Navigator.of(context).pop(action); + + return SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader(title: const Text('Edit Group Picture')), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _PickerTile( + icon: icons.camera, + label: 'Take Photo', + onTap: () => emit(_AvatarPickerAction.takePhoto), + ), + _PickerTile( + icon: icons.image, + label: 'Choose Image', + onTap: () => emit(_AvatarPickerAction.chooseImage), + ), + _PickerTile( + icon: icons.delete, + label: 'Reset Picture', + destructive: true, + onTap: () => emit(_AvatarPickerAction.resetPicture), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// A single tappable row in [_AvatarPickerSheet]. Mirrors the `_Tile` shape +/// used by `GroupInfoScreen` / `ChatInfoScreen` — same min tap target and +/// content padding, with a [destructive] flag that flips the icon and +/// label to [StreamColorScheme.accentError] via a local +/// [StreamListTileTheme] override. +class _PickerTile extends StatelessWidget { + const _PickerTile({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: Icon(icon), + title: Text(label), + onTap: onTap, + ), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_detail_dialog.dart b/sample_app/lib/widgets/location/location_detail_dialog.dart index b6e39b48b9..50afbc4945 100644 --- a/sample_app/lib/widgets/location/location_detail_dialog.dart +++ b/sample_app/lib/widgets/location/location_detail_dialog.dart @@ -53,10 +53,7 @@ class LocationDetailDialog extends StatelessWidget { return Scaffold( backgroundColor: colorTheme.appBg, - appBar: AppBar( - backgroundColor: colorTheme.barsBg, - title: const Text('Shared Location'), - ), + appBar: StreamAppBar(title: const Text('Shared Location')), body: BetterStreamBuilder( stream: locationStream, errorBuilder: (_, __) => const Center(child: LocationNotFound()), diff --git a/sample_app/lib/widgets/location/location_picker_dialog.dart b/sample_app/lib/widgets/location/location_picker_dialog.dart index fd565fdac8..b61914dc70 100644 --- a/sample_app/lib/widgets/location/location_picker_dialog.dart +++ b/sample_app/lib/widgets/location/location_picker_dialog.dart @@ -97,10 +97,7 @@ class _LocationPickerDialogState extends State with Widget return Scaffold( backgroundColor: colorTheme.appBg, - appBar: AppBar( - backgroundColor: colorTheme.barsBg, - title: const Text('Share Location'), - ), + appBar: StreamAppBar(title: const Text('Share Location')), body: Stack( alignment: AlignmentDirectional.bottomCenter, children: [ diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 0363d3a210..d4b8f9c184 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: flutter_svg: ^2.0.10+1 geolocator: ^13.0.0 go_router: ^14.6.2 + image_picker: ^1.1.2 latlong2: ^0.9.1 lottie: ^3.1.2 rxdart: ^0.28.0