Skip to content

Commit da5adb6

Browse files
VelikovPetarclaudexsahil03x
authored
feat(ui): add configurable StreamBackButton unread count (#2816)
* feat(ui): add configurable StreamBackButton unread count Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore: Update Goldens * 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 <noreply@anthropic.com> * Fix PR remarks. * Address PR remarks * Fix formatting --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: VelikovPetar <15679533+VelikovPetar@users.noreply.github.com> Co-authored-by: Sahil Kumar <xdsahil@gmail.com>
1 parent 26bc0ac commit da5adb6

17 files changed

Lines changed: 752 additions & 19 deletions

docs/docs_screenshots/test/localization/localization_rtl_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ void main() {
191191
child: Scaffold(
192192
appBar: const StreamChannelHeader(
193193
automaticallyImplyLeading: false,
194-
leading: StreamBackButton(showUnreadCount: false),
194+
leading: StreamBackButton(),
195195
),
196196
body: Column(
197197
children: [

migrations/redesign/headers_and_icons.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,36 @@ The default leading is now [`StreamBackButton`] with a channel-aware
185185
unread badge; the default trailing is the channel avatar wrapped in a
186186
48×48 tap target wired to `onChannelAvatarPressed`.
187187

188+
### `StreamBackButton`
189+
190+
The unread badge is now supplied as a widget through a single `unreadIndicator`
191+
parameter (typically a `StreamUnreadIndicator`) instead of the `showUnreadCount`
192+
/ `channelId` flags, which are **deprecated** but still functional. The badge is
193+
overlaid on the button's top-end corner and hides itself when its count is zero.
194+
195+
| Old | New equivalent |
196+
| --------------------------------------- | ----------------------------------------------------------- |
197+
| `showUnreadCount: false` (or omitted) | `unreadIndicator:` omitted — no badge |
198+
| `showUnreadCount: true` | `unreadIndicator: StreamUnreadIndicator()` |
199+
| `showUnreadCount: true, channelId: cid` | `unreadIndicator: StreamUnreadIndicator.channels(cid: cid)` |
200+
201+
`StreamUnreadIndicator` also takes an optional `excludeCid` to omit one channel
202+
from the total. The default `StreamChannelHeader` leading uses
203+
`StreamUnreadIndicator(excludeCid: channel.cid)` so its badge counts the unread
204+
messages in *other* channels.
205+
206+
**Before:**
207+
208+
```dart
209+
StreamBackButton(showUnreadCount: true)
210+
```
211+
212+
**After:**
213+
214+
```dart
215+
StreamBackButton(unreadIndicator: StreamUnreadIndicator())
216+
```
217+
188218
### `StreamChannelListHeader`
189219

190220
| Old parameter | New equivalent |

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@
77
- Added an `errorSubtitle` to `StreamScrollViewErrorWidget`, which now falls back to the design's generic error copy (title, description, and a "Try Again" retry label) when values aren't provided.
88
- Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`.
99
- Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction).
10+
- 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.
1011

1112
⚠️ Deprecated
1213

1314
- Deprecated `StreamMessageReactionPicker.onReactionPicked` in favor of `onReactionSelected`.
1415
- Deprecated `onReactionsTap` (and the `OnReactionsTap` typedef) on `StreamMessageItem` and `StreamMessageListView` in favor of `onReactionTap`.
1516
- Deprecated `height`/`width` of `StreamScrollViewLoadingWidget` in favor of `size`.
17+
- Deprecated `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadIndicator`.
1618

1719
🐞 Fixed
1820

1921
- Fixed the default `StreamChannel` loading and error states not being themed or localized; `StreamChat` now installs themed, connection-aware defaults, overridable per `StreamChannel` or via `DefaultStreamChannelBuilders`.
2022
- Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text.
2123
- Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame.
2224
- Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided.
25+
- Fixed the `StreamBackButton` unread badge including the currently open channel in its total count.
2326

2427
## 10.2.0
2528

packages/stream_chat_flutter/example/lib/main.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,18 +225,21 @@ class _ChannelPageState extends State<ChannelPage> {
225225

226226
@override
227227
Widget build(BuildContext context) {
228+
// Show the unread count of the other channels on the back button,
229+
// excluding the currently open one.
230+
final unreadIndicator = switch (StreamChannel.of(context).channel.cid) {
231+
final cid? => StreamUnreadIndicator(excludeCid: cid),
232+
_ => const StreamUnreadIndicator(),
233+
};
234+
228235
return Scaffold(
229236
appBar: StreamChannelHeader(
230237
leading: switch ((widget.showBackButton, widget.onBackPressed)) {
231238
(true, final cb?) => StreamBackButton(
232-
channelId: StreamChannel.of(context).channel.cid,
239+
unreadIndicator: unreadIndicator,
233240
onPressed: () => cb(context),
234-
showUnreadCount: true,
235-
),
236-
(true, null) => StreamBackButton(
237-
channelId: StreamChannel.of(context).channel.cid,
238-
showUnreadCount: true,
239241
),
242+
(true, null) => StreamBackButton(unreadIndicator: unreadIndicator),
240243
_ => const SizedBox(),
241244
},
242245
trailing: GestureDetector(

packages/stream_chat_flutter/lib/src/channel/channel_header.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget
139139

140140
var leading = this.leading;
141141
if (leading == null && automaticallyImplyLeading) {
142-
leading = const StreamBackButton(showUnreadCount: true);
142+
leading = StreamBackButton(
143+
unreadIndicator: StreamUnreadIndicator(excludeCid: channel.cid),
144+
);
143145
}
144146

145147
var title = this.title;

packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:rxdart/rxdart.dart';
23
import 'package:stream_chat_flutter/src/misc/empty_widget.dart';
34
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
45

@@ -17,12 +18,16 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
1718
/// {@endtemplate}
1819
class StreamUnreadIndicator extends StatelessWidget {
1920
/// Displays the total unread count.
21+
///
22+
/// Optionally, provide [excludeCid] to omit a specific channel's unread
23+
/// messages from the total — for example, the currently open channel.
2024
const StreamUnreadIndicator({
2125
super.key,
2226
this.child,
2327
this.alignment,
2428
this.offset,
2529
this.semanticLabel,
30+
this.excludeCid,
2631
}) : _unreadType = const _TotalUnreadCount();
2732

2833
/// Displays the unreadChannel count.
@@ -35,7 +40,8 @@ class StreamUnreadIndicator extends StatelessWidget {
3540
this.alignment,
3641
this.offset,
3742
this.semanticLabel,
38-
}) : _unreadType = _UnreadChannels(cid: cid);
43+
}) : _unreadType = _UnreadChannels(cid: cid),
44+
excludeCid = null;
3945

4046
/// Displays the unreadThreads count.
4147
///
@@ -47,10 +53,18 @@ class StreamUnreadIndicator extends StatelessWidget {
4753
this.alignment,
4854
this.offset,
4955
this.semanticLabel,
50-
}) : _unreadType = _UnreadThreads(id: id);
56+
}) : _unreadType = _UnreadThreads(id: id),
57+
excludeCid = null;
5158

5259
final _UnreadTypes _unreadType;
5360

61+
/// The cid of a channel whose unread messages are excluded from the total
62+
/// unread count.
63+
///
64+
/// Only applies to the default (total) constructor; ignored by
65+
/// [StreamUnreadIndicator.channels] and [StreamUnreadIndicator.threads].
66+
final String? excludeCid;
67+
5468
/// Optional child widget to overlay the badge on.
5569
///
5670
/// When non-null, the badge is positioned on top of this widget.
@@ -85,7 +99,7 @@ class StreamUnreadIndicator extends StatelessWidget {
8599
final client = StreamChat.of(context).client;
86100

87101
final stream = switch (_unreadType) {
88-
_TotalUnreadCount() => client.state.totalUnreadCountStream,
102+
_TotalUnreadCount() => _totalUnreadCountStream(client, excludeCid),
89103
_UnreadChannels(cid: final cid) => switch (cid) {
90104
final cid? => client.state.channels[cid]?.state?.unreadCountStream,
91105
_ => client.state.unreadChannelsStream,
@@ -97,7 +111,7 @@ class StreamUnreadIndicator extends StatelessWidget {
97111
};
98112

99113
final initialData = switch (_unreadType) {
100-
_TotalUnreadCount() => client.state.totalUnreadCount,
114+
_TotalUnreadCount() => _totalUnreadCount(client, excludeCid),
101115
_UnreadChannels(cid: final cid) => switch (cid) {
102116
final cid? => client.state.channels[cid]?.state?.unreadCount,
103117
_ => client.state.unreadChannels,
@@ -135,6 +149,42 @@ class StreamUnreadIndicator extends StatelessWidget {
135149
}
136150
}
137151

152+
/// Returns the client's total unread message count as a stream, optionally
153+
/// subtracting the unread messages of the channel identified by [excludeCid].
154+
Stream<int> _totalUnreadCountStream(
155+
StreamChatClient client,
156+
String? excludeCid,
157+
) {
158+
final totalUnreadCount = client.state.totalUnreadCountStream;
159+
if (excludeCid == null) return totalUnreadCount;
160+
161+
final excludedUnreadCount = client.state.channels[excludeCid]?.state?.unreadCountStream ?? Stream.value(0);
162+
163+
// The total and the excluded channel's unread count update through separate
164+
// streams. Both settle within the same event-loop turn, so debouncing on a
165+
// zero duration coalesces them into a single emission and avoids rendering a
166+
// transient count before the two values agree.
167+
return Rx.combineLatest2<int, int, int>(
168+
totalUnreadCount,
169+
excludedUnreadCount,
170+
_subtractExcluded,
171+
).debounceTime(Duration.zero).distinct();
172+
}
173+
174+
/// Returns the client's total unread message count, optionally subtracting the
175+
/// unread messages of the channel identified by [excludeCid].
176+
int _totalUnreadCount(StreamChatClient client, String? excludeCid) {
177+
final totalUnreadCount = client.state.totalUnreadCount;
178+
if (excludeCid == null) return totalUnreadCount;
179+
180+
final excludedUnreadCount = client.state.channels[excludeCid]?.state?.unreadCount ?? 0;
181+
182+
return _subtractExcluded(totalUnreadCount, excludedUnreadCount);
183+
}
184+
185+
/// Subtracts [excluded] from [total], flooring the result at zero.
186+
int _subtractExcluded(int total, int excluded) => total > excluded ? total - excluded : 0;
187+
138188
sealed class _UnreadTypes {
139189
const _UnreadTypes._();
140190
}

packages/stream_chat_flutter/lib/src/misc/back_button.dart

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,44 @@ class StreamBackButton extends StatelessWidget {
99
const StreamBackButton({
1010
super.key,
1111
this.onPressed,
12+
@Deprecated(
13+
"Use 'unreadIndicator: StreamUnreadIndicator()' instead. "
14+
'This will be removed in a future version.',
15+
)
1216
this.showUnreadCount = false,
17+
@Deprecated(
18+
"Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. "
19+
'This will be removed in a future version.',
20+
)
1321
this.channelId,
14-
});
22+
Widget? unreadIndicator = _unset,
23+
}) : _unreadIndicator = unreadIndicator;
1524

1625
/// Callback for when button is pressed
1726
final VoidCallback? onPressed;
1827

1928
/// Show unread count
29+
@Deprecated(
30+
"Use 'unreadIndicator: StreamUnreadIndicator()' instead. "
31+
'This will be removed in a future version.',
32+
)
2033
final bool showUnreadCount;
2134

2235
/// Channel ID used to retrieve unread count
36+
@Deprecated(
37+
"Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. "
38+
'This will be removed in a future version.',
39+
)
2340
final String? channelId;
2441

42+
/// The unread badge overlaid on the top-end corner of the button.
43+
///
44+
/// Typically a [StreamUnreadIndicator]. The badge hides itself when its
45+
/// count is zero. Null when not explicitly set.
46+
Widget? get unreadIndicator => identical(_unreadIndicator, _unset) ? null : _unreadIndicator;
47+
48+
final Widget? _unreadIndicator;
49+
2550
@override
2651
Widget build(BuildContext context) {
2752
final localizations = MaterialLocalizations.of(context);
@@ -47,13 +72,43 @@ class StreamBackButton extends StatelessWidget {
4772
},
4873
);
4974

50-
if (showUnreadCount) {
51-
button = switch (channelId) {
52-
final cid? => StreamUnreadIndicator.channels(offset: .zero, cid: cid, child: button),
53-
_ => StreamUnreadIndicator(offset: .zero, child: button),
54-
};
75+
if (_effectiveUnreadIndicator case final indicator?) {
76+
// The indicator is childless here, so it renders only the bare badge
77+
// (or nothing when the count is zero). Overlay it on the top-end corner
78+
// of the button.
79+
button = Stack(
80+
clipBehavior: Clip.none,
81+
children: [
82+
button,
83+
Positioned.fill(
84+
child: FittedBox(
85+
fit: BoxFit.none,
86+
alignment: AlignmentDirectional.topEnd,
87+
child: indicator,
88+
),
89+
),
90+
],
91+
);
5592
}
5693

5794
return button;
5895
}
96+
97+
Widget? get _effectiveUnreadIndicator {
98+
if (!identical(_unreadIndicator, _unset)) return _unreadIndicator;
99+
if (!showUnreadCount) return null;
100+
return switch (channelId) {
101+
final cid? => StreamUnreadIndicator.channels(cid: cid),
102+
_ => const StreamUnreadIndicator(),
103+
};
104+
}
59105
}
106+
107+
class _WidgetSentinel extends Widget {
108+
const _WidgetSentinel();
109+
110+
@override
111+
Element createElement() => throw StateError('_WidgetSentinel must never be built.');
112+
}
113+
114+
const _unset = _WidgetSentinel();

packages/stream_chat_flutter/lib/src/misc/thread_header.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget
7171

7272
var leading = this.leading;
7373
if (leading == null && automaticallyImplyLeading) {
74-
leading = StreamBackButton(channelId: channel?.cid, showUnreadCount: true);
74+
final unreadIndicator = switch (channel?.cid) {
75+
final cid? => StreamUnreadIndicator.channels(cid: cid),
76+
null => const StreamUnreadIndicator(),
77+
};
78+
leading = StreamBackButton(unreadIndicator: unreadIndicator);
7579
}
7680

7781
Widget? fallbackSubtitle;

packages/stream_chat_flutter/test/src/channel/channel_header_test.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ void main() {
4242
when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1));
4343
when(() => clientState.totalUnreadCount).thenAnswer((i) => 1);
4444
when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1));
45+
when(() => clientState.channels).thenReturn({channel.cid!: channel});
4546
when(() => channelState.membersStream).thenAnswer(
4647
(i) => Stream.value([
4748
Member(
@@ -122,6 +123,7 @@ void main() {
122123
when(() => client.wsConnectionStatus).thenReturn(ConnectionStatus.disconnected);
123124
when(() => clientState.totalUnreadCount).thenAnswer((i) => 1);
124125
when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1));
126+
when(() => clientState.channels).thenReturn({channel.cid!: channel});
125127

126128
await tester.pumpWidget(
127129
MaterialApp(
@@ -188,6 +190,7 @@ void main() {
188190
when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting));
189191
when(() => clientState.totalUnreadCount).thenAnswer((i) => 1);
190192
when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1));
193+
when(() => clientState.channels).thenReturn({channel.cid!: channel});
191194

192195
await tester.pumpWidget(
193196
MaterialApp(
@@ -399,6 +402,7 @@ void main() {
399402
when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting));
400403
when(() => clientState.totalUnreadCount).thenAnswer((i) => 1);
401404
when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1));
405+
when(() => clientState.channels).thenReturn({channel.cid!: channel});
402406

403407
var backPressed = false;
404408
var imageTapped = false;
1.29 KB
Loading

0 commit comments

Comments
 (0)