Skip to content
Merged
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(),
),
body: Column(
children: [
Expand Down
30 changes: 30 additions & 0 deletions migrations/redesign/headers_and_icons.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,36 @@ The default leading is now [`StreamBackButton`] with a channel-aware
unread badge; the default trailing is the channel avatar wrapped in a
48×48 tap target wired to `onChannelAvatarPressed`.

### `StreamBackButton`

The unread badge is now supplied as a widget through a single `unreadIndicator`
parameter (typically a `StreamUnreadIndicator`) instead of the `showUnreadCount`
/ `channelId` flags, which are **deprecated** but still functional. The badge is
overlaid on the button's top-end corner and hides itself when its count is zero.

| Old | New equivalent |
| --------------------------------------- | ----------------------------------------------------------- |
| `showUnreadCount: false` (or omitted) | `unreadIndicator:` omitted — no badge |
| `showUnreadCount: true` | `unreadIndicator: StreamUnreadIndicator()` |
| `showUnreadCount: true, channelId: cid` | `unreadIndicator: StreamUnreadIndicator.channels(cid: cid)` |

`StreamUnreadIndicator` also takes an optional `excludeCid` to omit one channel
from the total. The default `StreamChannelHeader` leading uses
`StreamUnreadIndicator(excludeCid: channel.cid)` so its badge counts the unread
messages in *other* channels.

**Before:**

```dart
StreamBackButton(showUnreadCount: true)
```

**After:**

```dart
StreamBackButton(unreadIndicator: StreamUnreadIndicator())
```

### `StreamChannelListHeader`

| Old parameter | New equivalent |
Expand Down
3 changes: 3 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
- Added an `errorSubtitle` to `StreamScrollViewErrorWidget`, which now falls back to the design's generic error copy (title, description, and a "Try Again" retry label) when values aren't provided.
- Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`.
- Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction).
- Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count.

⚠️ Deprecated

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

🐞 Fixed

- 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`.
- Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text.
- Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame.
- Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided.
- Fixed the `StreamBackButton` unread badge including the currently open channel in its total count.

## 10.2.0

Expand Down
15 changes: 9 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,21 @@ class _ChannelPageState extends State<ChannelPage> {

@override
Widget build(BuildContext context) {
// Show the unread count of the other channels on the back button,
// excluding the currently open one.
final unreadIndicator = switch (StreamChannel.of(context).channel.cid) {
final cid? => StreamUnreadIndicator(excludeCid: cid),
_ => const StreamUnreadIndicator(),
};

return Scaffold(
appBar: StreamChannelHeader(
leading: switch ((widget.showBackButton, widget.onBackPressed)) {
(true, final cb?) => StreamBackButton(
channelId: StreamChannel.of(context).channel.cid,
unreadIndicator: unreadIndicator,
onPressed: () => cb(context),
showUnreadCount: true,
),
(true, null) => StreamBackButton(
channelId: StreamChannel.of(context).channel.cid,
showUnreadCount: true,
),
(true, null) => StreamBackButton(unreadIndicator: unreadIndicator),
_ => const SizedBox(),
},
trailing: GestureDetector(
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(
unreadIndicator: StreamUnreadIndicator(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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can support excludeCid here too. wdyt?

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.

In theory yes, should be feasible. I decided to not do it now because it wasn't part of the initial bug report. However I have one concern: In that case, we could in theory pass both cid and excludeCid to the _UnreadChannels config -> This can become a bit confusing, but if we prioritize the setup in the following way:

  1. If cid != null -> show that specific channel unread count
  2. If excludeCid != null -> all unread channels - 1 (if excluded channel has unread)
  3. Else -> all unread channels

(Ideally we would have different factory methods for channels vs currentChannel, but currently the channels factory handles both cases, so I think introducing new API might be confusing).

What do you think about this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lets skip it until we have a usecase


/// 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
67 changes: 61 additions & 6 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,44 @@ class StreamBackButton extends StatelessWidget {
const StreamBackButton({
super.key,
this.onPressed,
@Deprecated(
"Use 'unreadIndicator: StreamUnreadIndicator()' instead. "
'This will be removed in a future version.',
)
this.showUnreadCount = false,
@Deprecated(
"Use 'unreadIndicator: StreamUnreadIndicator.channels(cid: cid)' instead. "
'This will be removed in a future version.',
)
this.channelId,
});
Widget? unreadIndicator = _unset,
}) : _unreadIndicator = unreadIndicator;

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

/// Show unread count
@Deprecated(
"Use 'unreadIndicator: StreamUnreadIndicator()' instead. "
'This will be removed in a future version.',
)
final bool showUnreadCount;

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

/// The unread badge overlaid on the top-end corner of the button.
///
/// Typically a [StreamUnreadIndicator]. The badge hides itself when its
/// count is zero. Null when not explicitly set.
Widget? get unreadIndicator => identical(_unreadIndicator, _unset) ? null : _unreadIndicator;

final Widget? _unreadIndicator;

@override
Widget build(BuildContext context) {
final localizations = MaterialLocalizations.of(context);
Expand All @@ -47,13 +72,43 @@ class StreamBackButton extends StatelessWidget {
},
);

if (showUnreadCount) {
button = switch (channelId) {
final cid? => StreamUnreadIndicator.channels(offset: .zero, cid: cid, child: button),
_ => StreamUnreadIndicator(offset: .zero, child: button),
};
if (_effectiveUnreadIndicator case final indicator?) {
// The indicator is childless here, so it renders only the bare badge
// (or nothing when the count is zero). Overlay it on the top-end corner
// of the button.
button = Stack(
clipBehavior: Clip.none,
children: [
button,
Positioned.fill(
child: FittedBox(
fit: BoxFit.none,
alignment: AlignmentDirectional.topEnd,
child: indicator,
),
),
],
);
}

return button;
}

Widget? get _effectiveUnreadIndicator {
if (!identical(_unreadIndicator, _unset)) return _unreadIndicator;
if (!showUnreadCount) return null;
return switch (channelId) {
final cid? => StreamUnreadIndicator.channels(cid: cid),
_ => const StreamUnreadIndicator(),
};
}
}

class _WidgetSentinel extends Widget {
const _WidgetSentinel();

@override
Element createElement() => throw StateError('_WidgetSentinel must never be built.');
}

const _unset = _WidgetSentinel();
6 changes: 5 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,11 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget

var leading = this.leading;
if (leading == null && automaticallyImplyLeading) {
leading = StreamBackButton(channelId: channel?.cid, showUnreadCount: true);
final unreadIndicator = switch (channel?.cid) {
final cid? => StreamUnreadIndicator.channels(cid: cid),
null => const StreamUnreadIndicator(),
};
leading = StreamBackButton(unreadIndicator: unreadIndicator);
}

Widget? fallbackSubtitle;
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
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading