diff --git a/docs/docs_screenshots/test/localization/localization_rtl_test.dart b/docs/docs_screenshots/test/localization/localization_rtl_test.dart index da6ef24743..9bea726f46 100644 --- a/docs/docs_screenshots/test/localization/localization_rtl_test.dart +++ b/docs/docs_screenshots/test/localization/localization_rtl_test.dart @@ -191,7 +191,7 @@ void main() { child: Scaffold( appBar: const StreamChannelHeader( automaticallyImplyLeading: false, - leading: StreamBackButton(showUnreadCount: false), + leading: StreamBackButton(), ), body: Column( children: [ diff --git a/migrations/redesign/headers_and_icons.md b/migrations/redesign/headers_and_icons.md index 48d6e58ea2..385b0eaec9 100644 --- a/migrations/redesign/headers_and_icons.md +++ b/migrations/redesign/headers_and_icons.md @@ -185,6 +185,36 @@ 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`. +### `StreamBackButton` + +The unread badge is now supplied as a widget through a single `unreadIndicator` +parameter (typically a `StreamUnreadIndicator`) instead of the `showUnreadCount` +/ `channelId` flags, which are **deprecated** but still functional. The badge is +overlaid on the button's top-end corner and hides itself when its count is zero. + +| Old | New equivalent | +| --------------------------------------- | ----------------------------------------------------------- | +| `showUnreadCount: false` (or omitted) | `unreadIndicator:` omitted — no badge | +| `showUnreadCount: true` | `unreadIndicator: StreamUnreadIndicator()` | +| `showUnreadCount: true, channelId: cid` | `unreadIndicator: StreamUnreadIndicator.channels(cid: cid)` | + +`StreamUnreadIndicator` also takes an optional `excludeCid` to omit one channel +from the total. The default `StreamChannelHeader` leading uses +`StreamUnreadIndicator(excludeCid: channel.cid)` so its badge counts the unread +messages in *other* channels. + +**Before:** + +```dart +StreamBackButton(showUnreadCount: true) +``` + +**After:** + +```dart +StreamBackButton(unreadIndicator: StreamUnreadIndicator()) +``` + ### `StreamChannelListHeader` | Old parameter | New equivalent | diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f8f78b51fd..b24ef222b3 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -7,12 +7,14 @@ - Added an `errorSubtitle` to `StreamScrollViewErrorWidget`, which now falls back to the design's generic error copy (title, description, and a "Try Again" retry label) when values aren't provided. - Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`. - Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). +- Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count. ⚠️ Deprecated - Deprecated `StreamMessageReactionPicker.onReactionPicked` in favor of `onReactionSelected`. - Deprecated `onReactionsTap` (and the `OnReactionsTap` typedef) on `StreamMessageItem` and `StreamMessageListView` in favor of `onReactionTap`. - Deprecated `height`/`width` of `StreamScrollViewLoadingWidget` in favor of `size`. +- Deprecated `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadIndicator`. 🐞 Fixed @@ -20,6 +22,7 @@ - Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text. - Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame. - Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided. +- Fixed the `StreamBackButton` unread badge including the currently open channel in its total count. ## 10.2.0 diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index f159e2f228..8334d9ed8e 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -225,18 +225,21 @@ class _ChannelPageState extends State { @override Widget build(BuildContext context) { + // Show the unread count of the other channels on the back button, + // excluding the currently open one. + final unreadIndicator = switch (StreamChannel.of(context).channel.cid) { + final cid? => StreamUnreadIndicator(excludeCid: cid), + _ => const StreamUnreadIndicator(), + }; + return Scaffold( appBar: StreamChannelHeader( leading: switch ((widget.showBackButton, widget.onBackPressed)) { (true, final cb?) => StreamBackButton( - channelId: StreamChannel.of(context).channel.cid, + unreadIndicator: unreadIndicator, onPressed: () => cb(context), - showUnreadCount: true, - ), - (true, null) => StreamBackButton( - channelId: StreamChannel.of(context).channel.cid, - showUnreadCount: true, ), + (true, null) => StreamBackButton(unreadIndicator: unreadIndicator), _ => const SizedBox(), }, trailing: GestureDetector( 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 d74684965a..821862d081 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -139,7 +139,9 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget var leading = this.leading; if (leading == null && automaticallyImplyLeading) { - leading = const StreamBackButton(showUnreadCount: true); + leading = StreamBackButton( + unreadIndicator: StreamUnreadIndicator(excludeCid: channel.cid), + ); } var title = this.title; diff --git a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart index e57578d3c7..ea5174dde6 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -17,12 +18,16 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@endtemplate} class StreamUnreadIndicator extends StatelessWidget { /// Displays the total unread count. + /// + /// Optionally, provide [excludeCid] to omit a specific channel's unread + /// messages from the total — for example, the currently open channel. const StreamUnreadIndicator({ super.key, this.child, this.alignment, this.offset, this.semanticLabel, + this.excludeCid, }) : _unreadType = const _TotalUnreadCount(); /// Displays the unreadChannel count. @@ -35,7 +40,8 @@ class StreamUnreadIndicator extends StatelessWidget { this.alignment, this.offset, this.semanticLabel, - }) : _unreadType = _UnreadChannels(cid: cid); + }) : _unreadType = _UnreadChannels(cid: cid), + excludeCid = null; /// Displays the unreadThreads count. /// @@ -47,10 +53,18 @@ class StreamUnreadIndicator extends StatelessWidget { this.alignment, this.offset, this.semanticLabel, - }) : _unreadType = _UnreadThreads(id: id); + }) : _unreadType = _UnreadThreads(id: id), + excludeCid = null; final _UnreadTypes _unreadType; + /// The cid of a channel whose unread messages are excluded from the total + /// unread count. + /// + /// Only applies to the default (total) constructor; ignored by + /// [StreamUnreadIndicator.channels] and [StreamUnreadIndicator.threads]. + final String? excludeCid; + /// Optional child widget to overlay the badge on. /// /// When non-null, the badge is positioned on top of this widget. @@ -85,7 +99,7 @@ class StreamUnreadIndicator extends StatelessWidget { final client = StreamChat.of(context).client; final stream = switch (_unreadType) { - _TotalUnreadCount() => client.state.totalUnreadCountStream, + _TotalUnreadCount() => _totalUnreadCountStream(client, excludeCid), _UnreadChannels(cid: final cid) => switch (cid) { final cid? => client.state.channels[cid]?.state?.unreadCountStream, _ => client.state.unreadChannelsStream, @@ -97,7 +111,7 @@ class StreamUnreadIndicator extends StatelessWidget { }; final initialData = switch (_unreadType) { - _TotalUnreadCount() => client.state.totalUnreadCount, + _TotalUnreadCount() => _totalUnreadCount(client, excludeCid), _UnreadChannels(cid: final cid) => switch (cid) { final cid? => client.state.channels[cid]?.state?.unreadCount, _ => client.state.unreadChannels, @@ -135,6 +149,42 @@ class StreamUnreadIndicator extends StatelessWidget { } } +/// Returns the client's total unread message count as a stream, optionally +/// subtracting the unread messages of the channel identified by [excludeCid]. +Stream _totalUnreadCountStream( + StreamChatClient client, + String? excludeCid, +) { + final totalUnreadCount = client.state.totalUnreadCountStream; + if (excludeCid == null) return totalUnreadCount; + + final excludedUnreadCount = client.state.channels[excludeCid]?.state?.unreadCountStream ?? Stream.value(0); + + // The total and the excluded channel's unread count update through separate + // streams. Both settle within the same event-loop turn, so debouncing on a + // zero duration coalesces them into a single emission and avoids rendering a + // transient count before the two values agree. + return Rx.combineLatest2( + totalUnreadCount, + excludedUnreadCount, + _subtractExcluded, + ).debounceTime(Duration.zero).distinct(); +} + +/// Returns the client's total unread message count, optionally subtracting the +/// unread messages of the channel identified by [excludeCid]. +int _totalUnreadCount(StreamChatClient client, String? excludeCid) { + final totalUnreadCount = client.state.totalUnreadCount; + if (excludeCid == null) return totalUnreadCount; + + final excludedUnreadCount = client.state.channels[excludeCid]?.state?.unreadCount ?? 0; + + return _subtractExcluded(totalUnreadCount, excludedUnreadCount); +} + +/// Subtracts [excluded] from [total], flooring the result at zero. +int _subtractExcluded(int total, int excluded) => total > excluded ? total - excluded : 0; + sealed class _UnreadTypes { const _UnreadTypes._(); } 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 0ffb015666..e7bceedbde 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -9,19 +9,44 @@ class StreamBackButton extends StatelessWidget { const StreamBackButton({ super.key, this.onPressed, + @Deprecated( + "Use 'unreadIndicator: StreamUnreadIndicator()' instead. " + 'This will be removed in a future version.', + ) this.showUnreadCount = false, + @Deprecated( + "Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. " + 'This will be removed in a future version.', + ) this.channelId, - }); + Widget? unreadIndicator = _unset, + }) : _unreadIndicator = unreadIndicator; /// Callback for when button is pressed final VoidCallback? onPressed; /// Show unread count + @Deprecated( + "Use 'unreadIndicator: StreamUnreadIndicator()' instead. " + 'This will be removed in a future version.', + ) final bool showUnreadCount; /// Channel ID used to retrieve unread count + @Deprecated( + "Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. " + 'This will be removed in a future version.', + ) final String? channelId; + /// The unread badge overlaid on the top-end corner of the button. + /// + /// Typically a [StreamUnreadIndicator]. The badge hides itself when its + /// count is zero. Null when not explicitly set. + Widget? get unreadIndicator => identical(_unreadIndicator, _unset) ? null : _unreadIndicator; + + final Widget? _unreadIndicator; + @override Widget build(BuildContext context) { final localizations = MaterialLocalizations.of(context); @@ -47,13 +72,43 @@ class StreamBackButton extends StatelessWidget { }, ); - if (showUnreadCount) { - button = switch (channelId) { - final cid? => StreamUnreadIndicator.channels(offset: .zero, cid: cid, child: button), - _ => StreamUnreadIndicator(offset: .zero, child: button), - }; + if (_effectiveUnreadIndicator case final indicator?) { + // The indicator is childless here, so it renders only the bare badge + // (or nothing when the count is zero). Overlay it on the top-end corner + // of the button. + button = Stack( + clipBehavior: Clip.none, + children: [ + button, + Positioned.fill( + child: FittedBox( + fit: BoxFit.none, + alignment: AlignmentDirectional.topEnd, + child: indicator, + ), + ), + ], + ); } return button; } + + Widget? get _effectiveUnreadIndicator { + if (!identical(_unreadIndicator, _unset)) return _unreadIndicator; + if (!showUnreadCount) return null; + return switch (channelId) { + final cid? => StreamUnreadIndicator.channels(cid: cid), + _ => const StreamUnreadIndicator(), + }; + } } + +class _WidgetSentinel extends Widget { + const _WidgetSentinel(); + + @override + Element createElement() => throw StateError('_WidgetSentinel must never be built.'); +} + +const _unset = _WidgetSentinel(); 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 dd5b7639a0..c6f373db01 100644 --- a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart @@ -71,7 +71,11 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget var leading = this.leading; if (leading == null && automaticallyImplyLeading) { - leading = StreamBackButton(channelId: channel?.cid, showUnreadCount: true); + final unreadIndicator = switch (channel?.cid) { + final cid? => StreamUnreadIndicator.channels(cid: cid), + null => const StreamUnreadIndicator(), + }; + leading = StreamBackButton(unreadIndicator: unreadIndicator); } Widget? fallbackSubtitle; 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 5155f2505d..7f1c2662bd 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 @@ -42,6 +42,7 @@ void main() { when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( @@ -122,6 +123,7 @@ void main() { when(() => client.wsConnectionStatus).thenReturn(ConnectionStatus.disconnected); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); await tester.pumpWidget( MaterialApp( @@ -188,6 +190,7 @@ void main() { when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); await tester.pumpWidget( MaterialApp( @@ -399,6 +402,7 @@ void main() { when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); var backPressed = false; var imageTapped = false; diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_dark.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_dark.png new file mode 100644 index 0000000000..c33061f032 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_light.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_light.png new file mode 100644 index 0000000000..cdef2b5778 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_light.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_overflow_light.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_overflow_light.png new file mode 100644 index 0000000000..e211a356f2 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_overflow_light.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart index c6fe1a058a..d8bf20b406 100644 --- a/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart @@ -1,8 +1,10 @@ +import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../material_app_wrapper.dart'; import '../mocks.dart'; void main() { @@ -138,4 +140,186 @@ void main() { expect(find.text('99+'), findsOneWidget); }, ); + + testWidgets( + 'it should exclude the given channel from the total unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.channels).thenReturn({ + channel.cid!: channel, + }); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(3)); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamUnreadIndicator(excludeCid: channel.cid), + ), + ), + ), + ), + ); + + // wait for the initial state to be rendered. + await tester.pumpAndSettle(); + + // 10 total - 3 in the excluded channel = 7 unread elsewhere. + expect(find.text('7'), findsOneWidget); + }, + ); + + testWidgets( + 'it should clamp the excluded total unread count to zero', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.channels).thenReturn({ + channel.cid!: channel, + }); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channelState.unreadCount).thenReturn(5); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(5)); + + when(() => clientState.totalUnreadCount).thenReturn(3); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(3)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamUnreadIndicator(excludeCid: channel.cid), + ), + ), + ), + ), + ); + + // wait for the initial state to be rendered. + await tester.pumpAndSettle(); + + // 3 total - 5 excluded clamps to 0, so no badge is shown. + expect(find.byType(StreamBadgeNotification), findsNothing); + }, + ); + + testWidgets( + 'it should fall back to the raw total for an untracked excluded channel', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.channels).thenReturn(const {}); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamUnreadIndicator(excludeCid: channel.cid), + ), + ), + ), + ), + ); + + // wait for the initial state to be rendered. + await tester.pumpAndSettle(); + + // The channel is absent from client state, so nothing is subtracted. + expect(find.text('10'), findsOneWidget); + }, + ); + + // Golden safety net: locks in the current badge appearance overlaid on a + // child, including the "99+" overflow pill. + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamUnreadIndicator on a child looks fine', + fileName: 'stream_unread_indicator_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 120, height: 120), + builder: () => _wrapWithMaterialApp( + const StreamUnreadIndicator(child: Icon(Icons.chat_bubble_outline)), + client: _clientWithTotalUnread(5), + brightness: brightness, + ), + ); + } + + goldenTest( + '[light] -> StreamUnreadIndicator shows 99+ overflow', + fileName: 'stream_unread_indicator_overflow_light', + constraints: const BoxConstraints.tightFor(width: 120, height: 120), + builder: () => _wrapWithMaterialApp( + const StreamUnreadIndicator(child: Icon(Icons.chat_bubble_outline)), + client: _clientWithTotalUnread(100), + ), + ); +} + +StreamChatClient _clientWithTotalUnread(int count) { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenReturn(count); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(count)); + return client; +} + +Widget _wrapWithMaterialApp( + Widget child, { + required StreamChatClient client, + Brightness brightness = Brightness.light, +}) { + return MaterialAppWrapper( + theme: ThemeData(brightness: brightness), + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: Scaffold( + body: Center(child: child), + ), + ), + ); } diff --git a/packages/stream_chat_flutter/test/src/misc/back_button_test.dart b/packages/stream_chat_flutter/test/src/misc/back_button_test.dart index ccb6d43c04..bc48d94857 100644 --- a/packages/stream_chat_flutter/test/src/misc/back_button_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/back_button_test.dart @@ -1,8 +1,10 @@ +import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../material_app_wrapper.dart'; import '../mocks.dart'; void main() { @@ -139,4 +141,400 @@ void main() { expect(find.byType(StreamUnreadIndicator), findsOneWidget); }, ); + + testWidgets( + 'a total unreadIndicator shows the total unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: const StreamBackButton( + unreadIndicator: StreamUnreadIndicator(), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('10'), findsOneWidget); + }, + ); + + testWidgets( + 'a total unreadIndicator with excludeCid excludes the given channel', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(3)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: StreamBackButton( + unreadIndicator: StreamUnreadIndicator( + excludeCid: channel.cid, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('7'), findsOneWidget); + }, + ); + + testWidgets( + 'a channels unreadIndicator shows that channel unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.unreadCount).thenReturn(4); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(4)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: StreamBackButton( + unreadIndicator: StreamUnreadIndicator.channels( + cid: channel.cid, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('4'), findsOneWidget); + }, + ); + + testWidgets( + 'no unreadIndicator shows no badge', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: const StreamBackButton(), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(StreamUnreadIndicator), findsNothing); + }, + ); + + testWidgets( + 'an unreadIndicator with a zero count shows no badge', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: const StreamBackButton( + unreadIndicator: StreamUnreadIndicator(), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // The indicator is configured, but with a zero count it renders no badge. + expect(find.byType(StreamUnreadIndicator), findsOneWidget); + expect(find.byType(StreamBadgeNotification), findsNothing); + }, + ); + + testWidgets( + 'unreadIndicator takes precedence over the deprecated flags', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(3)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: StreamBackButton( + // The indicator wins over showUnreadCount: true, so the badge + // shows the excluded total (7), not the raw total (10). + unreadIndicator: StreamUnreadIndicator( + excludeCid: channel.cid, + ), + showUnreadCount: true, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('7'), findsOneWidget); + }, + ); + + testWidgets( + 'unreadIndicator: null hides the badge even with showUnreadCount: true', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + // An explicit null wins over the deprecated flag, hiding the + // badge instead of falling back to showUnreadCount. + child: const StreamBackButton( + unreadIndicator: null, + showUnreadCount: true, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(StreamUnreadIndicator), findsNothing); + expect(find.text('10'), findsNothing); + }, + ); + + testWidgets( + 'showUnreadCount: true shows the total unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: const StreamBackButton(showUnreadCount: true), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('10'), findsOneWidget); + }, + ); + + testWidgets( + 'channelId shows that channel unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.unreadCount).thenReturn(4); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(4)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: StreamBackButton( + showUnreadCount: true, + channelId: channel.cid, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('4'), findsOneWidget); + }, + ); + + testWidgets( + 'showUnreadCount: false shows no badge', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 10); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: const StreamBackButton(showUnreadCount: false), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(StreamUnreadIndicator), findsNothing); + }, + ); + + // Golden safety net: locks in the current back-button appearance (icon + + // overlaid unread badge) so the unread-indicator API refactor stays + // pixel-identical. + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamBackButton with unread badge looks fine', + fileName: 'stream_back_button_unread_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 120, height: 120), + builder: () => _wrapWithMaterialApp( + const StreamBackButton( + unreadIndicator: StreamUnreadIndicator(), + ), + client: _clientWithTotalUnread(5), + brightness: brightness, + ), + ); + } + + goldenTest( + '[light] -> StreamBackButton without unread badge looks fine', + fileName: 'stream_back_button_no_unread_light', + constraints: const BoxConstraints.tightFor(width: 120, height: 120), + builder: () => _wrapWithMaterialApp( + const StreamBackButton( + unreadIndicator: StreamUnreadIndicator(), + ), + client: _clientWithTotalUnread(0), + ), + ); +} + +StreamChatClient _clientWithTotalUnread(int count) { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenReturn(count); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(count)); + return client; +} + +Widget _wrapWithMaterialApp( + Widget child, { + required StreamChatClient client, + Brightness brightness = Brightness.light, +}) { + return MaterialAppWrapper( + theme: ThemeData(brightness: brightness), + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: Scaffold( + body: Center(child: child), + ), + ), + ); } diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_no_unread_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_no_unread_light.png new file mode 100644 index 0000000000..ca17a9900e Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_no_unread_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_dark.png new file mode 100644 index 0000000000..6416f637a7 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_light.png new file mode 100644 index 0000000000..df16494549 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_light.png differ