Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ void main() {
child: Scaffold(
appBar: const StreamChannelHeader(
automaticallyImplyLeading: false,
leading: StreamBackButton(showUnreadCount: false),
leading: StreamBackButton(unreadCount: null),
),
body: Column(
children: [
Expand Down
29 changes: 29 additions & 0 deletions migrations/redesign/headers_and_icons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions packages/stream_chat_flutter/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -225,18 +225,20 @@ class _ChannelPageState extends State<ChannelPage> {

@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(),
};
Comment on lines +228 to +232

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show the total unread count excluding the current channel.

Configuring the back button to show the current channel's unread count (.channel(cid)) results in a suboptimal UX, as the count will typically be 0 while the user is actively viewing the channel.

Since navigating back from the ChannelPage takes the user to the channel list, it's generally preferable to show the total unread count excluding the current channel. This also accurately demonstrates the new default behavior of StreamChannelHeader introduced in this PR.

💡 Proposed fix
-    // 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 total unread count across other channels on the back button.
+    final unreadCount = StreamBackButtonUnreadCount.total(
+      excludeCid: StreamChannel.of(context).channel.cid,
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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 total unread count across other channels on the back button.
final unreadCount = StreamBackButtonUnreadCount.total(
excludeCid: StreamChannel.of(context).channel.cid,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat_flutter/example/lib/main.dart` around lines 228 - 232,
Update the unread-count selection in the back-button setup to use the total
count excluding the current channel rather than
StreamBackButtonUnreadCount.channel(cid). Preserve the existing fallback
behavior when no channel CID is available.


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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
Expand All @@ -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.
///
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<int> _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<int, int, int>(
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._();
}
Expand Down
71 changes: 67 additions & 4 deletions packages/stream_chat_flutter/lib/src/misc/back_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not completely sure about the naming here, let me know if you think we should use something different (.unreadMessages()perhaps, to serve as a forecast for the potentially upcoming .unreadChannels())


/// 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;
}
5 changes: 4 additions & 1 deletion packages/stream_chat_flutter/lib/src/misc/thread_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading