From a1f86e7d8eb5e8e5c12bca30eec7b14739c54197 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 14 Jul 2026 21:11:53 +0200 Subject: [PATCH 1/7] feat(ui): add configurable StreamBackButton unread count Co-Authored-By: Claude Opus 4.8 --- .../localization/localization_rtl_test.dart | 2 +- migrations/redesign/headers_and_icons.md | 29 ++ packages/stream_chat_flutter/CHANGELOG.md | 6 + .../stream_chat_flutter/example/lib/main.dart | 14 +- .../lib/src/channel/channel_header.dart | 4 +- .../lib/src/indicators/unread_indicator.dart | 58 +++- .../lib/src/misc/back_button.dart | 71 ++++- .../lib/src/misc/thread_header.dart | 5 +- .../test/src/channel/channel_header_test.dart | 4 + .../src/indicators/unread_indicator_test.dart | 131 +++++++++ .../test/src/misc/back_button_test.dart | 270 ++++++++++++++++++ 11 files changed, 577 insertions(+), 17 deletions(-) diff --git a/docs/docs_screenshots/test/localization/localization_rtl_test.dart b/docs/docs_screenshots/test/localization/localization_rtl_test.dart index da6ef24743..377890c123 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 48d6e58ea2..44d82d125a 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 180dc7e90e..c4f3c05450 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 f159e2f228..99258715a3 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 d74684965a..3bb674b568 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 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..b515c1a0da 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 dd5b7639a0..0e6d3438fe 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 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/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart index c6fe1a058a..77d16764df 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 ccb6d43c04..3fffb9a5f0 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); + }, + ); } From 4f3aa78456061a992b29da5cea8610c2801995e2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 21 Jul 2026 22:19:26 +0200 Subject: [PATCH 2/7] refactor(ui): accept a Widget unreadIndicator on StreamBackButton Replace the typed StreamBackButtonUnreadCount config with a plain unreadIndicator Widget (typically a StreamUnreadIndicator), per review feedback on #2816. StreamBackButton now overlays the indicator on its top-end corner and hides it at zero count; badge placement is unchanged, verified pixel-identical via new golden coverage for the back button and the unread indicator. Co-Authored-By: Claude Opus 4.8 --- .../localization/localization_rtl_test.dart | 2 +- migrations/redesign/headers_and_icons.md | 31 +++---- packages/stream_chat_flutter/CHANGELOG.md | 4 +- .../stream_chat_flutter/example/lib/main.dart | 13 +-- .../lib/src/channel/channel_header.dart | 2 +- .../lib/src/misc/back_button.dart | 81 +++++++------------ .../lib/src/misc/thread_header.dart | 2 +- .../src/indicators/unread_indicator_test.dart | 53 ++++++++++++ .../test/src/misc/back_button_test.dart | 80 +++++++++++++++--- 9 files changed, 182 insertions(+), 86 deletions(-) diff --git a/docs/docs_screenshots/test/localization/localization_rtl_test.dart b/docs/docs_screenshots/test/localization/localization_rtl_test.dart index 377890c123..b096c518f6 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(unreadCount: null), + leading: StreamBackButton(unreadIndicator: null), ), body: Column( children: [ diff --git a/migrations/redesign/headers_and_icons.md b/migrations/redesign/headers_and_icons.md index 44d82d125a..385b0eaec9 100644 --- a/migrations/redesign/headers_and_icons.md +++ b/migrations/redesign/headers_and_icons.md @@ -187,20 +187,21 @@ unread badge; the default trailing is the channel avatar wrapped in a ### `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. +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:** @@ -211,7 +212,7 @@ StreamBackButton(showUnreadCount: true) **After:** ```dart -StreamBackButton(unreadCount: StreamBackButtonUnreadCount.total()) +StreamBackButton(unreadIndicator: StreamUnreadIndicator()) ``` ### `StreamChannelListHeader` diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 113100c6e3..3bf0449e98 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -4,11 +4,11 @@ - 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)`). +- 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 `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadCount`. +- Deprecated `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadIndicator`. 🐞 Fixed diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 99258715a3..8334d9ed8e 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -225,20 +225,21 @@ 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(), + // 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( - unreadCount: unreadCount, + unreadIndicator: unreadIndicator, onPressed: () => cb(context), ), - (true, null) => StreamBackButton(unreadCount: unreadCount), + (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 3bb674b568..821862d081 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -140,7 +140,7 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget var leading = this.leading; if (leading == null && automaticallyImplyLeading) { leading = StreamBackButton( - unreadCount: StreamBackButtonUnreadCount.total(excludeCid: channel.cid), + unreadIndicator: StreamUnreadIndicator(excludeCid: channel.cid), ); } 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 b515c1a0da..1026eb104e 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -10,16 +10,16 @@ class StreamBackButton extends StatelessWidget { super.key, this.onPressed, @Deprecated( - "Use 'unreadCount: StreamBackButtonUnreadCount.total()' instead. " + "Use 'unreadIndicator: StreamUnreadIndicator()' instead. " 'This will be removed in a future version.', ) this.showUnreadCount = false, @Deprecated( - "Use 'unreadCount: StreamBackButtonUnreadCount.channel(cid)' instead. " + "Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. " 'This will be removed in a future version.', ) this.channelId, - this.unreadCount, + this.unreadIndicator, }); /// Callback for when button is pressed @@ -27,20 +27,23 @@ class StreamBackButton extends StatelessWidget { /// Show unread count @Deprecated( - "Use 'unreadCount: StreamBackButtonUnreadCount.total()' instead. " + "Use 'unreadIndicator: StreamUnreadIndicator()' 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. " + "Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. " 'This will be removed in a future version.', ) final String? channelId; - /// The unread count configuration for the back button. - final StreamBackButtonUnreadCount? unreadCount; + /// The unread badge overlaid on the top-end corner of the button. + /// + /// Typically a [StreamUnreadIndicator]. The badge hides itself when its + /// count is zero. + final Widget? unreadIndicator; @override Widget build(BuildContext context) { @@ -67,56 +70,34 @@ class StreamBackButton extends StatelessWidget { }, ); - 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, - ), - }; + 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; } - StreamBackButtonUnreadCount? get _effectiveUnreadCount { - if (unreadCount case final effective?) return effective; + Widget? get _effectiveUnreadIndicator { + if (unreadIndicator case final effective?) return effective; if (!showUnreadCount) return null; return switch (channelId) { - final cid? => StreamBackButtonUnreadCount.channel(cid), - _ => const StreamBackButtonUnreadCount.total(), + final cid? => StreamUnreadIndicator.channels(cid: cid), + _ => const StreamUnreadIndicator(), }; } } - -/// 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 0e6d3438fe..fde93c5ae7 100644 --- a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart @@ -73,7 +73,7 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget if (leading == null && automaticallyImplyLeading) { final cid = channel?.cid; leading = StreamBackButton( - unreadCount: cid != null ? StreamBackButtonUnreadCount.channel(cid) : const StreamBackButtonUnreadCount.total(), + unreadIndicator: cid != null ? StreamUnreadIndicator.channels(cid: cid) : const StreamUnreadIndicator(), ); } 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 77d16764df..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() { @@ -269,4 +271,55 @@ void main() { 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 3fffb9a5f0..93bff92057 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() { @@ -141,7 +143,7 @@ void main() { ); testWidgets( - 'unreadCount.total() shows the total unread count', + 'a total unreadIndicator shows the total unread count', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -157,7 +159,7 @@ void main() { child: StreamChat( client: client, child: const StreamBackButton( - unreadCount: StreamBackButtonUnreadCount.total(), + unreadIndicator: StreamUnreadIndicator(), ), ), ), @@ -172,7 +174,7 @@ void main() { ); testWidgets( - 'unreadCount.total(excludeCid:) excludes the given channel', + 'a total unreadIndicator with excludeCid excludes the given channel', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -194,7 +196,7 @@ void main() { child: StreamChat( client: client, child: StreamBackButton( - unreadCount: StreamBackButtonUnreadCount.total( + unreadIndicator: StreamUnreadIndicator( excludeCid: channel.cid, ), ), @@ -211,7 +213,7 @@ void main() { ); testWidgets( - 'unreadCount.channel(cid) shows that channel unread count', + 'a channels unreadIndicator shows that channel unread count', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -231,7 +233,9 @@ void main() { child: StreamChat( client: client, child: StreamBackButton( - unreadCount: StreamBackButtonUnreadCount.channel(channel.cid!), + unreadIndicator: StreamUnreadIndicator.channels( + cid: channel.cid, + ), ), ), ), @@ -246,7 +250,7 @@ void main() { ); testWidgets( - 'no unreadCount shows no badge', + 'no unreadIndicator shows no badge', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -275,7 +279,7 @@ void main() { ); testWidgets( - 'unreadCount takes precedence over the deprecated flags', + 'unreadIndicator takes precedence over the deprecated flags', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -297,9 +301,9 @@ void main() { child: StreamChat( client: client, child: StreamBackButton( - // The config wins over showUnreadCount: true, so the badge + // The indicator wins over showUnreadCount: true, so the badge // shows the excluded total (7), not the raw total (10). - unreadCount: StreamBackButtonUnreadCount.total( + unreadIndicator: StreamUnreadIndicator( excludeCid: channel.cid, ), showUnreadCount: true, @@ -409,4 +413,60 @@ void main() { 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), + ), + ), + ); } From fe2ba03176da9c46a888101c180f3c10858bdd5e Mon Sep 17 00:00:00 2001 From: VelikovPetar <15679533+VelikovPetar@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:28:46 +0000 Subject: [PATCH 3/7] chore: Update Goldens --- .../goldens/ci/stream_unread_indicator_dark.png | Bin 0 -> 1320 bytes .../goldens/ci/stream_unread_indicator_light.png | Bin 0 -> 1149 bytes .../stream_unread_indicator_overflow_light.png | Bin 0 -> 1064 bytes .../ci/stream_back_button_no_unread_light.png | Bin 0 -> 420 bytes .../ci/stream_back_button_unread_dark.png | Bin 0 -> 1291 bytes .../ci/stream_back_button_unread_light.png | Bin 0 -> 1109 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_dark.png create mode 100644 packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_light.png create mode 100644 packages/stream_chat_flutter/test/src/indicators/goldens/ci/stream_unread_indicator_overflow_light.png create mode 100644 packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_no_unread_light.png create mode 100644 packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_dark.png create mode 100644 packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_back_button_unread_light.png 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 0000000000000000000000000000000000000000..c33061f032f195b214c131b1dcd3e923fea773a4 GIT binary patch literal 1320 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^bmSQK*5Dp-y;YjHK@)?VR+?^QK zos)S9l)L2V;uumf=k1-l+0msk$3K4eEGoLCw)*C^bu;Hid{|h#X7LowxSpOf=*IOYgjeovMo9rEVy;fF?DY~@Q9{N zy-l~cBYcl0O%1i1+l(Ila}yu^neG$!Z{C#~k(VZ47yGYq?WnLzZ2r{uCohFu;hT4MiRXDAW`V-h z6>nPmJ-5A$zqGM_j@6N!%J~z{r!sb2y%H1~8hq(1+rlKK^uLkIf`b`U%-%2fx;4Kb z`_**QWWB%Y%lXTVN+0tuyne*FCnMDJn)H;eimr3di@OW2=VvBAf4$t|+qL?cTRNX7 z?f>xDJ=Z7B{%lFsq!YEJEr*}}Hdr@zbE~Mw`DF=1q%ZXd^cvuxqaDtN!8)5^j#aPw!O9EaJY6pKu0XhW^>53ng_Fm&ENg{d~SC> z^M!9m^rWKVIp35`w&s5js-B=&v@_~+>J&eN%b~mno12y7 zcWmA)+z@@eiGPmNZ&8Npy=->Y8lu8Ko^&ebXvFa3ynB1UZ{ogEP6qR!ec{udFq@m3 zU%q(JQEl?WFU$1>55<2xvuFOZ=jY>=MCb^W+8%mXppdQ0`ttz8-o1M(<{dx!Ilb`w zo}G^>54X=tJLmt{g70HQu0poXRMYyMAD$iQRs9&8Kb-{0OYUcQ{Yp;6!N;qvC_$oWAb9eXjkgR`3_nrC8KcDC;6Y_m~=Bn-C_&fX#I_0e| z-sVgG_)~l7@&3uPXScSVPrK<}_Rsw^FHgbOSE0Lh@0OI1h`3f5cwJd;wW^5L{mHB9 zSDiRe&cnx7^yf!mTU(oiq-0`ANl9V)@yCTy($YIkie`EoIa<1S$HY&ydw=I^S$zn) zl7A=W($A$~hU$@1PoZboYkyuR6qj#nJ{p-&C>Ak?RV=-6AeeXVTdni`{>D3#1z6NE Nc)I$ztaD0e0swL3dqn^M literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cdef2b5778ebc5a16e34af3fd7585c60510c9801 GIT binary patch literal 1149 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^bmSQK*5Dp-y;YjHK@)?VR+?^QK zos)S9lvDR~aSW-L^Y)HoMl`F;@sIDbZ@G0#Dziz3x)^0kdh$-Ngxod^+bEk6Lc9U>AFS z;E_zk9O?>&$G@}=DAeQ#auB$CO{-=5A>IdWt?%MzPLj0fSak2*MfZ!%Rtp#-I+Lzn za6hi9Um(lUyL$Ca?GJxt=KSQe`^UC#F8BWXQyZ6mKa^~}pjbO+r*+M?rYemm4K+vQ z96g&{YNsTMa3oZ+##PAQla9Z?to{4p7sVF@a^oY`^&T!OVp^_{!6Cl9uKDl!!%wr9 z_GaAUiI;1h`@Q}3Rsl=N_7@4J?|yo3_?@aT=U4w?wugVTH)so=(|NM`_nbK$+dZ@S zPuJ#8v44MH_J&QL&fmQ1WnUm5H;<8Tev|!wt@bBftj^D$=zqRG{)tb&t8iLgZ2X5SuP+s51 z@ZrtmegoT02M(U;e|_)Yi^m4P!*Y+RdR7+%#>dMqe&2KHipaaY;(Gs1Jt>boww&2+ zcIMMdUv(LBZhv8CzxB-G*BQH8@0*XcU6uSdeeVCjm5q#1QBfBEZWUHlS9{+!kbk?= z^h=MbQ9)pNxw);aZAoeA#7YI{;^NO|ZL13lC+3)W|89Ppc%$a+o2}WZRv)VVd^+69 zJ9qy@q0dU%E77vRj#>5cIoqGvQafW1pV_{fKYIIdA4lp{ZD9Gq;OXk;vd$@?2>@{5 BCv*S+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e211a356f2bde71e4e24aa2d51aa46bb1794370c GIT binary patch literal 1064 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^bmSQK*5Dp-y;YjHK@)?VR+?^QK zos)UVz`%UT)5S5QV$Rz;=d(r9WsZORuA=sA!P1Tuxl-K!j&x{z%?fP!7I5U!V)ljR z{EF<%i`4bwazt$^1Z^~O^@BG2cMJ@!`f$N{V`#E;lZpUyYm!Oh8@0!Y^LBJ||4b}< zUit0tb5=!bj+yVzSR21P$8%CF?X%3}tAf=G*}Cjv&ksD3iI~IOU7S$Jr*p3HsO1I= z>I&w?zG0Lqak|Et@=$x(o*$>4H~u`adCzhargX(DsmoVd;y0~fe!;X#u}u2!%N;*< zZ(-i_?$)Cx|36%};ORf0Uw>f!S{vmbJZsLltn>0r;Ep<}U5EBmF17W${gJ8M>00QX8Y4z`{liyErg(&-CW+j)=VW6NP>kBX!>`eqQiE>B;uRKAW;`ZvXH?=*@fWZHn<8zyCkcke~d@ z|Mc;F{e}l`$?Rvkn)T7D`@deA=Tr7`U6+jy%)I^K{_0EW5t~;|H~0}8@xtf9<2yp9 z_+_qH*fMXg)2@B%Tl`qg=X_ghi@x|&yC|`~dlOG?x_+nT{2?=)yx^`yvIqI+P22Zu zS@mvSJ6H2xAHPRr=gqU`Kg`XZ7vlS4@lmh6^SW&hegc|!Xr4{PuUWI-y>@=+dT9Te zr|f*!3;N$^)}HnKUM4NHPcOFWfz6wqfA>J9|LvZxBfG=zhU9JY!<}Y!zV)RHzs%ZM z+1E|JUUuR<^M-}Xzi?j5apu1x%fr1-?5@Uok^9xV9{&3&A-v}72i6GrcZvFW-m50< zi2YH))K?oG^Vs*M`s@eSi#PmUoU~ImPyE>KE-U`Z9m<>6i>s|WcvF9o@8&fN^%LJ@ zRUcLTB`j-Vx5+rX@uKFA?<{$|hYxSGNPc_gN7Li^ea0oP46L^~l!w2)|L4THRm(Y+ zzixSd%0bX{{gS`SUd3j(kChCy$EUn|bcc83QY;s53!pXRq&lH0en2)URS; z^%oPu|DOxFVdQ&MBb@0Fo5^(*OVf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ca17a9900e309900a52f1f4a3ec487f94135f39d GIT binary patch literal 420 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^bmSQK*5Dp-y;YjHK@)?VR+?^QK zos)S9Wczu#IEGZrd3*UF=OG6X*Fa^>!%J_}+deX~O_{#2laqORG1m{99e>_h9j}=1 zHO97~no~x-V9tR;rsH&Ayj{(H=2KPuvU}Eb_g8)o`*@IOYq!TfmsssyATLix*K*2y ZXHQ(leZ47C2@Ad6*efNR zl(pm)t8Mj%_y;#4_|%@J2(L4pm9UYQQ!3fRIXhSR$`aE{TB58e;@`ekHK<;jA~J2E z&%B>|&b`?j|Ltt~jJY@0Y+k-|Yu60Xn=e^Ud<#l=EONtw)y%K4H<+5t?ss39nwCVZ zouvOnL!@YmcBevdyzLbjZHG zlGpZI|LOFdh5wXKEwy1d=_1_A+nF1iZ|fv&5g(CexS7G>S0|s<{r_zG7dNexJo?5~ zjV(U);rDEYH(pCSk4tS!-TRg6sp75}y|qX0-o1A8BENp|!^u@@Z=Y^`(%&=xWzxTM zEZs9J7xlkASlaw~6WjMU3DT+4jW<+9yt6Ug^t5vB-uTCE;s4n6*Pi3+y1=?FL`#Ir zl7VmUKgo;7p6QYpaVPp7`Dt(%_CtmBM*@F(j|#iE(DbKn0k+y3y^J@nYi@tP zsoQ>k*L%w*g_6eX>;1CFzt(Phts0j8+iq9hLhhK|c{bKRr@r5uuD(B}vd^O+?%IPj zPXr4W%Q0~8uY3Oc&8;&lUjMY)9k-BMru!JfiF>=>{eE-i%qgGq-5}9}JC|*9yB)~i zn|B7>_;jX7@$}W={D8G#ot1?#*Z&LesA|inTPxLfBu_!>=*x-#58JbPEVud*=cq?*#i2XjLH&S(06{KgH5#fumB^!FEk ze6+OG-b}i;sv?d@Uuw?x-LC^5lx}%z|KRnWxk9%luh|fQCm$MLU-b0gmS*baRWdXF aZ}Hmi*8Z+$%Y%WX8H1;*pUXO@geCx!Mt7+I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df16494549e0f3a536f438d3e02f7639ba5cc1b6 GIT binary patch literal 1109 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^bmSQK*5Dp-y;YjHK@)?VR+?^QK zos)UVz`*?9)5S5QV$Rz;_p`5-${hdry!?2jQm~RzvDL4QCebs(L}uNL+-<;d>JRgz zcY7w>W$6?bbalGe)zQ+TC-Qn(_off4W}nCvjk8GcWzD&+;_mUtb*-ErNBR{`~OaCexC5)@P@#hXD+X1nyO#0$!jLER(zOk>ktSR_px6qjQao z|4f^kov^Txao4Ue1B)Hg_uZQIGj^k7Sl*HT1dz@zh P%LN8cS3j3^P6yRCa literal 0 HcmV?d00001 From afaafde7f7f549d3d36603dd5534205139f04a68 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 21 Jul 2026 22:29:24 +0200 Subject: [PATCH 4/7] test(ui): assert StreamBackButton shows no badge at zero unread count Add an explicit widget-level assertion that a configured unreadIndicator renders the StreamUnreadIndicator but no StreamBadgeNotification when the count is zero, complementing the existing golden coverage. Co-Authored-By: Claude Opus 4.8 --- .../test/src/misc/back_button_test.dart | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 93bff92057..ebf2a167d2 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 @@ -278,6 +278,39 @@ void main() { }, ); + 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 { From 498ee03f8159f7ac775313bd27765ce60de69519 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 28 Jul 2026 15:15:42 +0200 Subject: [PATCH 5/7] Fix PR remarks. --- .../test/localization/localization_rtl_test.dart | 2 +- .../stream_chat_flutter/lib/src/misc/thread_header.dart | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/docs_screenshots/test/localization/localization_rtl_test.dart b/docs/docs_screenshots/test/localization/localization_rtl_test.dart index b096c518f6..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(unreadIndicator: null), + leading: StreamBackButton(), ), body: Column( children: [ 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 fde93c5ae7..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,10 +71,11 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget var leading = this.leading; if (leading == null && automaticallyImplyLeading) { - final cid = channel?.cid; - leading = StreamBackButton( - unreadIndicator: cid != null ? StreamUnreadIndicator.channels(cid: cid) : const StreamUnreadIndicator(), - ); + final unreadIndicator = switch (channel?.cid) { + final cid? => StreamUnreadIndicator.channels(cid: cid), + null => const StreamUnreadIndicator(), + }; + leading = StreamBackButton(unreadIndicator: unreadIndicator); } Widget? fallbackSubtitle; From 75d4ca45bac85ae0bdbbb6484b8851a5407d7d97 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 28 Jul 2026 17:06:24 +0200 Subject: [PATCH 6/7] Address PR remarks --- .../lib/src/misc/back_button.dart | 23 +++++++++--- .../test/src/misc/back_button_test.dart | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) 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 1026eb104e..68a2952175 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -19,8 +19,8 @@ class StreamBackButton extends StatelessWidget { 'This will be removed in a future version.', ) this.channelId, - this.unreadIndicator, - }); + Widget? unreadIndicator = _unset, + }) : _unreadIndicator = unreadIndicator; /// Callback for when button is pressed final VoidCallback? onPressed; @@ -42,8 +42,11 @@ class StreamBackButton extends StatelessWidget { /// The unread badge overlaid on the top-end corner of the button. /// /// Typically a [StreamUnreadIndicator]. The badge hides itself when its - /// count is zero. - final Widget? unreadIndicator; + /// count is zero. Null when not explicitly set. + Widget? get unreadIndicator => + identical(_unreadIndicator, _unset) ? null : _unreadIndicator; + + final Widget? _unreadIndicator; @override Widget build(BuildContext context) { @@ -93,7 +96,7 @@ class StreamBackButton extends StatelessWidget { } Widget? get _effectiveUnreadIndicator { - if (unreadIndicator case final effective?) return effective; + if (!identical(_unreadIndicator, _unset)) return _unreadIndicator; if (!showUnreadCount) return null; return switch (channelId) { final cid? => StreamUnreadIndicator.channels(cid: cid), @@ -101,3 +104,13 @@ class StreamBackButton extends StatelessWidget { }; } } + +class _WidgetSentinel extends Widget { + const _WidgetSentinel(); + + @override + Element createElement() => + throw StateError('_WidgetSentinel must never be built.'); +} + +const _unset = _WidgetSentinel(); \ No newline at end of file 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 ebf2a167d2..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 @@ -353,6 +353,41 @@ void main() { }, ); + 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 { From 2665d8404d27f48654aad38004bc61920e4a6866 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 28 Jul 2026 17:08:55 +0200 Subject: [PATCH 7/7] Fix formatting --- .../stream_chat_flutter/lib/src/misc/back_button.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 68a2952175..e7bceedbde 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -43,8 +43,7 @@ class StreamBackButton extends StatelessWidget { /// /// 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; + Widget? get unreadIndicator => identical(_unreadIndicator, _unset) ? null : _unreadIndicator; final Widget? _unreadIndicator; @@ -109,8 +108,7 @@ class _WidgetSentinel extends Widget { const _WidgetSentinel(); @override - Element createElement() => - throw StateError('_WidgetSentinel must never be built.'); + Element createElement() => throw StateError('_WidgetSentinel must never be built.'); } -const _unset = _WidgetSentinel(); \ No newline at end of file +const _unset = _WidgetSentinel();