diff --git a/docs/docs_screenshots/test/localization/localization_rtl_test.dart b/docs/docs_screenshots/test/localization/localization_rtl_test.dart index da6ef2474..377890c12 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(unreadCount: null), ), body: Column( children: [ diff --git a/migrations/redesign/headers_and_icons.md b/migrations/redesign/headers_and_icons.md index 48d6e58ea..44d82d125 100644 --- a/migrations/redesign/headers_and_icons.md +++ b/migrations/redesign/headers_and_icons.md @@ -185,6 +185,35 @@ 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 configured through a single `unreadCount` parameter +(a `StreamBackButtonUnreadCount`) instead of the `showUnreadCount` / +`channelId` flags, which are **deprecated** but still functional. + +| Old | New equivalent | +| --------------------------------------- | --------------------------------------------------------- | +| `showUnreadCount: false` (or omitted) | `unreadCount:` omitted — no badge | +| `showUnreadCount: true` | `unreadCount: StreamBackButtonUnreadCount.total()` | +| `showUnreadCount: true, channelId: cid` | `unreadCount: StreamBackButtonUnreadCount.channel(cid)` | + +`StreamBackButtonUnreadCount.total` also takes an optional `excludeCid` to omit +one channel from the total. The default `StreamChannelHeader` leading uses +`total(excludeCid: channel.cid)` so its badge counts the unread messages in +*other* channels. + +**Before:** + +```dart +StreamBackButton(showUnreadCount: true) +``` + +**After:** + +```dart +StreamBackButton(unreadCount: StreamBackButtonUnreadCount.total()) +``` + ### `StreamChannelListHeader` | Old parameter | New equivalent | diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 180dc7e90..c4f3c0545 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -4,12 +4,18 @@ - Added an `AccessibilityTranslations` namespace on `Translations`, accessed via `context.translations.accessibility`, holding all screen-reader labels, tooltips, hints, and live-region announcements used by the composer, voice recording, attachment picker, message actions, channel header, media gallery, and poll creator. Getter suffixes follow Flutter's `MaterialLocalizations` convention (`Tooltip`, `Label`, `Hint`, `TapHint`, `Announcement`). Added a `DateTime.toA11yTimestamp()` extension for locale-aware long-form timestamps in accessibility labels. - Added a `LastMessagePredicate` typedef for the `ChannelLastMessageText.lastMessagePredicate` filter. +- Added a `unreadCount` parameter to `StreamBackButton`, configured via `StreamBackButtonUnreadCount` (`.total({excludeCid})` or `.channel(cid)`). + +⚠️ Deprecated + +- Deprecated `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadCount`. 🐞 Fixed - Fixed last-message preview flicker during channel-state reloads. - Fixed shadowed messages not hidden in channel list items. - Fixed `StreamMessageListView` firing `markThreadRead` on a reply-less parent, which produced a guaranteed 404 every time the thread view was opened before the first reply. +- Fixed the `StreamBackButton` unread badge including the currently open channel in its total count. ## 10.1.0 diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index f159e2f22..99258715a 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -225,18 +225,20 @@ class _ChannelPageState extends State { @override Widget build(BuildContext context) { + // Show the current channel's own unread count on the back button. + final unreadCount = switch (StreamChannel.of(context).channel.cid) { + final cid? => StreamBackButtonUnreadCount.channel(cid), + _ => const StreamBackButtonUnreadCount.total(), + }; + return Scaffold( appBar: StreamChannelHeader( leading: switch ((widget.showBackButton, widget.onBackPressed)) { (true, final cb?) => StreamBackButton( - channelId: StreamChannel.of(context).channel.cid, + unreadCount: unreadCount, onPressed: () => cb(context), - showUnreadCount: true, - ), - (true, null) => StreamBackButton( - channelId: StreamChannel.of(context).channel.cid, - showUnreadCount: true, ), + (true, null) => StreamBackButton(unreadCount: unreadCount), _ => 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 d74684965..3bb674b56 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( + unreadCount: StreamBackButtonUnreadCount.total(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 e57578d3c..ea5174dde 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 0ffb01566..b515c1a0d 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,39 @@ class StreamBackButton extends StatelessWidget { const StreamBackButton({ super.key, this.onPressed, + @Deprecated( + "Use 'unreadCount: StreamBackButtonUnreadCount.total()' instead. " + 'This will be removed in a future version.', + ) this.showUnreadCount = false, + @Deprecated( + "Use 'unreadCount: StreamBackButtonUnreadCount.channel(cid)' instead. " + 'This will be removed in a future version.', + ) this.channelId, + this.unreadCount, }); /// Callback for when button is pressed final VoidCallback? onPressed; /// Show unread count + @Deprecated( + "Use 'unreadCount: StreamBackButtonUnreadCount.total()' instead. " + 'This will be removed in a future version.', + ) final bool showUnreadCount; /// Channel ID used to retrieve unread count + @Deprecated( + "Use 'unreadCount: StreamBackButtonUnreadCount.channel(cid)' instead. " + 'This will be removed in a future version.', + ) final String? channelId; + /// The unread count configuration for the back button. + final StreamBackButtonUnreadCount? unreadCount; + @override Widget build(BuildContext context) { final localizations = MaterialLocalizations.of(context); @@ -47,13 +67,56 @@ 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 (_effectiveUnreadCount case final effectiveUnreadCount?) { + button = switch (effectiveUnreadCount) { + _TotalUnreadCount(:final excludeCid) => StreamUnreadIndicator( + offset: .zero, + excludeCid: excludeCid, + child: button, + ), + _ChannelUnreadCount(:final cid) => StreamUnreadIndicator.channels( + offset: .zero, + cid: cid, + child: button, + ), }; } return button; } + + StreamBackButtonUnreadCount? get _effectiveUnreadCount { + if (unreadCount case final effective?) return effective; + if (!showUnreadCount) return null; + return switch (channelId) { + final cid? => StreamBackButtonUnreadCount.channel(cid), + _ => const StreamBackButtonUnreadCount.total(), + }; + } +} + +/// Configures the unread badge on a [StreamBackButton]. +sealed class StreamBackButtonUnreadCount { + const StreamBackButtonUnreadCount(); + + /// Shows the total unread message count across all channels. + /// + /// Set [excludeCid] to omit a channel's unread messages from the total - + /// for example, the currently open channel. + const factory StreamBackButtonUnreadCount.total({String? excludeCid}) = _TotalUnreadCount; + + /// Shows the unread message count of the channel identified by [cid]. + const factory StreamBackButtonUnreadCount.channel(String cid) = _ChannelUnreadCount; +} + +final class _TotalUnreadCount extends StreamBackButtonUnreadCount { + const _TotalUnreadCount({this.excludeCid}); + + final String? excludeCid; +} + +final class _ChannelUnreadCount extends StreamBackButtonUnreadCount { + const _ChannelUnreadCount(this.cid); + + final String cid; } 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 dd5b7639a..0e6d3438f 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,10 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget var leading = this.leading; if (leading == null && automaticallyImplyLeading) { - leading = StreamBackButton(channelId: channel?.cid, showUnreadCount: true); + final cid = channel?.cid; + leading = StreamBackButton( + unreadCount: cid != null ? StreamBackButtonUnreadCount.channel(cid) : const StreamBackButtonUnreadCount.total(), + ); } 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 5155f2505..7f1c2662b 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/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart index c6fe1a058..77d16764d 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 @@ -138,4 +138,135 @@ 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); + }, + ); } 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 ccb6d43c0..3fffb9a5f 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 @@ -139,4 +139,274 @@ void main() { expect(find.byType(StreamUnreadIndicator), findsOneWidget); }, ); + + testWidgets( + 'unreadCount.total() 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( + unreadCount: StreamBackButtonUnreadCount.total(), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('10'), findsOneWidget); + }, + ); + + testWidgets( + 'unreadCount.total(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( + unreadCount: StreamBackButtonUnreadCount.total( + excludeCid: channel.cid, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('7'), findsOneWidget); + }, + ); + + testWidgets( + 'unreadCount.channel(cid) 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( + unreadCount: StreamBackButtonUnreadCount.channel(channel.cid!), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('4'), findsOneWidget); + }, + ); + + testWidgets( + 'no unreadCount 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( + 'unreadCount 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 config wins over showUnreadCount: true, so the badge + // shows the excluded total (7), not the raw total (10). + unreadCount: StreamBackButtonUnreadCount.total( + excludeCid: channel.cid, + ), + showUnreadCount: true, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('7'), findsOneWidget); + }, + ); + + 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); + }, + ); }