From 7452f059d9649643bda9df9f6fc0bafbccd5363f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 23:36:19 +0200 Subject: [PATCH 01/31] feat(samples): show the channel list and channel side by side on regular windows On a window that is regular on both axes, such as an unfolded foldable or a tablet, the channel list now sits in a 320pt leading pane beside the channel. Compact windows keep the single stack. The channel routes live in one shell navigator in both layouts, so an open channel keeps its draft and scroll position across a fold or unfold; only the list moves, by a GlobalKey. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/pages/channel_placeholder_page.dart | 23 +++ sample_app/lib/routes/app_routes.dart | 155 ++++++++++-------- sample_app/lib/utils/window_size_class.dart | 27 +++ sample_app/lib/widgets/split_view.dart | 146 +++++++++++++++++ 4 files changed, 279 insertions(+), 72 deletions(-) create mode 100644 sample_app/lib/pages/channel_placeholder_page.dart create mode 100644 sample_app/lib/utils/window_size_class.dart create mode 100644 sample_app/lib/widgets/split_view.dart diff --git a/sample_app/lib/pages/channel_placeholder_page.dart b/sample_app/lib/pages/channel_placeholder_page.dart new file mode 100644 index 0000000000..533511053a --- /dev/null +++ b/sample_app/lib/pages/channel_placeholder_page.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Fills the channel pane while no channel is open. +class ChannelPlaceholderPage extends StatelessWidget { + const ChannelPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + + return StreamScaffold( + backgroundColor: colorScheme.backgroundApp, + body: Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(icons.messageBubblesLarge), + emptyTitle: const Text('Select a chat'), + ), + ), + ); + } +} diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 47ca1295b1..906fbc6c86 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -5,6 +5,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../pages/advanced_options_page.dart'; import '../pages/channel_list_page.dart'; +import '../pages/channel_placeholder_page.dart'; import '../pages/chat_info_screen.dart'; import '../pages/choose_user_page.dart'; import '../pages/group_chat_details_screen.dart'; @@ -12,93 +13,103 @@ import '../pages/group_info_screen.dart'; import '../pages/new_chat_screen.dart'; import '../pages/new_group_chat_screen.dart'; import '../state/new_group_chat_state.dart'; +import '../widgets/split_view.dart'; import 'routes.dart'; final appRoutes = [ - GoRoute( - name: Routes.CHANNEL_LIST_PAGE.name, - path: Routes.CHANNEL_LIST_PAGE.path, - builder: (BuildContext context, GoRouterState state) => const ChannelListPage(), + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return AdaptiveSplitView(primary: const ChannelListPage(), secondary: child); + }, routes: [ GoRoute( - name: Routes.CHANNEL_PAGE.name, - path: Routes.CHANNEL_PAGE.path, - builder: (context, state) { - final channel = _resolveChannel(context, state); - final messageId = state.uri.queryParameters['mid']; - final parentId = state.uri.queryParameters['pid']; + name: Routes.CHANNEL_LIST_PAGE.name, + path: Routes.CHANNEL_LIST_PAGE.path, + builder: (BuildContext context, GoRouterState state) { + return const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()); + }, + routes: [ + GoRoute( + name: Routes.CHANNEL_PAGE.name, + path: Routes.CHANNEL_PAGE.path, + builder: (context, state) { + final channel = _resolveChannel(context, state); + final messageId = state.uri.queryParameters['mid']; + final parentId = state.uri.queryParameters['pid']; - // Thread deep-links require the parent message to already be in - // channel state. On cold cids (e.g. notification-tap into an - // unwatched channel) the state is null and we fall through to - // ChannelPage; the user lands on the channel rather than the - // thread, which is a degraded but functional UX. - Message? parentMessage; - if (parentId != null) { - parentMessage = channel.state?.messages.firstWhereOrNull((it) => it.id == parentId); - } + // Thread deep-links require the parent message to already be in + // channel state. On cold cids (e.g. notification-tap into an + // unwatched channel) the state is null and we fall through to + // ChannelPage; the user lands on the channel rather than the + // thread, which is a degraded but functional UX. + Message? parentMessage; + if (parentId != null) { + parentMessage = channel.state?.messages.firstWhereOrNull((it) => it.id == parentId); + } - return StreamChannel( - channel: channel, - initialMessageId: messageId, - openAtFirstUnread: false, - child: Builder( - builder: (context) { - return (parentMessage != null) - ? StreamThreadPage(parent: parentMessage) - : StreamChannelPage( - onChannelAvatarPressed: (context, channel) { - final isOneToOne = channel.isOneToOne; - final currentUserId = StreamChat.of(context).currentUser?.id; + return StreamChannel( + channel: channel, + initialMessageId: messageId, + openAtFirstUnread: false, + child: Builder( + builder: (context) { + return (parentMessage != null) + ? StreamThreadPage(parent: parentMessage) + : StreamChannelPage( + onChannelAvatarPressed: (context, channel) { + final isOneToOne = channel.isOneToOne; + final currentUserId = StreamChat.of(context).currentUser?.id; - final channelMembers = channel.state?.members ?? []; - final otherUser = (isOneToOne && currentUserId != null) - ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user - : null; + final channelMembers = channel.state?.members ?? []; + final otherUser = (isOneToOne && currentUserId != null) + ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user + : null; - final router = GoRouter.of(context); + final router = GoRouter.of(context); - if (otherUser != null) { - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), - extra: otherUser, - ); - return; - } + if (otherUser != null) { + router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: otherUser, + ); + return; + } - router.pushNamed( - Routes.GROUP_INFO_SCREEN.name, - pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); + }, ); - }, - ); - }, - ), - ); - }, - routes: [ - GoRoute( - name: Routes.CHAT_INFO_SCREEN.name, - path: Routes.CHAT_INFO_SCREEN.path, - builder: (BuildContext context, GoRouterState state) { - return StreamChannel.value( - channel: _resolveChannel(context, state), - child: ChatInfoScreen( - user: state.extra as User?, + }, ), ); }, - ), - GoRoute( - name: Routes.GROUP_INFO_SCREEN.name, - path: Routes.GROUP_INFO_SCREEN.path, - builder: (BuildContext context, GoRouterState state) { - return StreamChannel.value( - channel: _resolveChannel(context, state), - child: const GroupInfoScreen(), - ); - }, + routes: [ + GoRoute( + name: Routes.CHAT_INFO_SCREEN.name, + path: Routes.CHAT_INFO_SCREEN.path, + builder: (BuildContext context, GoRouterState state) { + return StreamChannel.value( + channel: _resolveChannel(context, state), + child: ChatInfoScreen( + user: state.extra as User?, + ), + ); + }, + ), + GoRoute( + name: Routes.GROUP_INFO_SCREEN.name, + path: Routes.GROUP_INFO_SCREEN.path, + builder: (BuildContext context, GoRouterState state) { + return StreamChannel.value( + channel: _resolveChannel(context, state), + child: const GroupInfoScreen(), + ); + }, + ), + ], ), ], ), diff --git a/sample_app/lib/utils/window_size_class.dart b/sample_app/lib/utils/window_size_class.dart new file mode 100644 index 0000000000..f775d7a5e8 --- /dev/null +++ b/sample_app/lib/utils/window_size_class.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; + +/// How much room a window has for its content, measured on both axes. +enum WindowSizeClass { + /// Room for one pane, such as a phone in either orientation. + compact, + + /// Room for two panes, such as a tablet or an unfolded foldable. + regular; + + /// Returns the size class of a window of the given [size]. + /// + /// A window is regular only when it is at least 600 wide and 600 tall, so a + /// phone in landscape stays compact. + static WindowSizeClass fromSize(Size size) { + return switch (size) { + Size(width: >= _kRegularExtent, height: >= _kRegularExtent) => regular, + _ => compact, + }; + } + + /// Returns the size class of the window that encloses [context]. + static WindowSizeClass of(BuildContext context) => fromSize(MediaQuery.sizeOf(context)); +} + +// The shortest a regular window is on each axis. +const _kRegularExtent = 600.0; diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart new file mode 100644 index 0000000000..63ad88f247 --- /dev/null +++ b/sample_app/lib/widgets/split_view.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../utils/window_size_class.dart'; + +/// Lays out [primary] and [secondary] side by side, separated by a divider. +/// +/// Each pane keeps only the safe-area insets on its outer edges. +class SplitView extends StatelessWidget { + const SplitView({ + super.key, + required this.primary, + required this.secondary, + this.primaryWidth = 320, + }); + + /// The leading pane. + final Widget primary; + + /// The trailing pane, which fills the width [primary] leaves. + final Widget secondary; + + /// The width of [primary]. + final double primaryWidth; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final isLtr = Directionality.of(context) == TextDirection.ltr; + + return Row( + children: [ + SizedBox( + width: primaryWidth, + child: MediaQuery.removePadding( + context: context, + removeLeft: !isLtr, + removeRight: isLtr, + child: primary, + ), + ), + VerticalDivider(width: 1, thickness: 1, color: colorScheme.borderSubtle), + Expanded( + child: MediaQuery.removePadding( + context: context, + removeLeft: isLtr, + removeRight: !isLtr, + child: secondary, + ), + ), + ], + ); + } +} + +/// Shows [primary] beside [secondary] on a regular window, and only +/// [secondary] on a compact one. +/// +/// [secondary] is the navigator for the trailing pane, and its root page is an +/// [AdaptiveSplitViewRoot]. On a compact window that root page shows +/// [primary], so both panes keep their state as the window changes size. +class AdaptiveSplitView extends StatefulWidget { + const AdaptiveSplitView({ + super.key, + required this.primary, + required this.secondary, + }); + + /// The leading pane, such as a list. + final Widget primary; + + /// The navigator for the trailing pane. + final Widget secondary; + + @override + State createState() => _AdaptiveSplitViewState(); +} + +class _AdaptiveSplitViewState extends State { + // Moves the primary pane between the leading pane and the root page. + final _primaryKey = GlobalKey(debugLabel: 'AdaptiveSplitView.primary'); + + @override + Widget build(BuildContext context) { + final isExpanded = WindowSizeClass.of(context) == .regular; + + return _AdaptiveSplitViewScope( + isExpanded: isExpanded, + primaryKey: _primaryKey, + primary: widget.primary, + child: switch (isExpanded) { + true => SplitView( + primary: KeyedSubtree(key: _primaryKey, child: widget.primary), + secondary: widget.secondary, + ), + false => widget.secondary, + }, + ); + } +} + +class _AdaptiveSplitViewScope extends InheritedWidget { + const _AdaptiveSplitViewScope({ + required this.isExpanded, + required this.primaryKey, + required this.primary, + required super.child, + }); + + final bool isExpanded; + final GlobalKey primaryKey; + final Widget primary; + + static _AdaptiveSplitViewScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AdaptiveSplitViewScope>(); + assert(scope != null, 'No AdaptiveSplitView found in context.'); + return scope!; + } + + @override + bool updateShouldNotify(_AdaptiveSplitViewScope oldWidget) { + return isExpanded != oldWidget.isExpanded || primary != oldWidget.primary; + } +} + +/// The root page of an [AdaptiveSplitView]'s navigator. +/// +/// Shows the split view's primary pane on a compact window, and [placeholder] +/// while both panes are shown. +class AdaptiveSplitViewRoot extends StatelessWidget { + const AdaptiveSplitViewRoot({ + super.key, + required this.placeholder, + }); + + /// Fills the trailing pane while nothing is open in it. + final Widget placeholder; + + @override + Widget build(BuildContext context) { + final scope = _AdaptiveSplitViewScope.of(context); + if (scope.isExpanded) return placeholder; + + return KeyedSubtree(key: scope.primaryKey, child: scope.primary); + } +} From b2293785a67b9dcb48a9a57c827d29a68fdb925e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 23:51:16 +0200 Subject: [PATCH 02/31] feat(samples): split the channel list and channel at a fold or hinge When a hinge, or a half-open fold, divides the window into a left and a right side, each pane now fills one side instead of the list keeping its 320pt width. The fold is read from MediaQuery.displayFeatures, so this works on Android foldables today. Each pane gets its own MediaQuery via removeDisplayFeatures, which also replaces the manual padding trimming. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 57 +++++++++++++++++++------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 63ad88f247..3ddf582814 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -1,3 +1,6 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -5,7 +8,9 @@ import '../utils/window_size_class.dart'; /// Lays out [primary] and [secondary] side by side, separated by a divider. /// -/// Each pane keeps only the safe-area insets on its outer edges. +/// When a fold or hinge divides the window into a left and a right side, each +/// pane fills one side. Each pane keeps only the safe-area insets on its outer +/// edges. Assumes it fills the window. class SplitView extends StatelessWidget { const SplitView({ super.key, @@ -20,32 +25,45 @@ class SplitView extends StatelessWidget { /// The trailing pane, which fills the width [primary] leaves. final Widget secondary; - /// The width of [primary]. + /// The width of [primary] while no fold or hinge divides the window. final double primaryWidth; @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; + final mediaQuery = MediaQuery.of(context); + final Size(:width, :height) = mediaQuery.size; final isLtr = Directionality.of(context) == TextDirection.ltr; + // The band between the panes, from the left edge of the window. + final (dividerLeft, dividerRight) = switch (_verticalFold(mediaQuery)) { + Rect(:final left, :final right) => (left, math.max(right, left + _kDividerWidth)), + null when isLtr => (primaryWidth, primaryWidth + _kDividerWidth), + null => (width - primaryWidth - _kDividerWidth, width - primaryWidth), + }; + + final leftPane = Rect.fromLTRB(0, 0, dividerLeft, height); + final rightPane = Rect.fromLTRB(dividerRight, 0, width, height); + return Row( + textDirection: TextDirection.ltr, children: [ SizedBox( - width: primaryWidth, - child: MediaQuery.removePadding( - context: context, - removeLeft: !isLtr, - removeRight: isLtr, - child: primary, + width: leftPane.width, + child: MediaQuery( + data: mediaQuery.removeDisplayFeatures(leftPane), + child: isLtr ? primary : secondary, ), ), - VerticalDivider(width: 1, thickness: 1, color: colorScheme.borderSubtle), + VerticalDivider( + width: dividerRight - dividerLeft, + thickness: _kDividerWidth, + color: colorScheme.borderSubtle, + ), Expanded( - child: MediaQuery.removePadding( - context: context, - removeLeft: isLtr, - removeRight: !isLtr, - child: secondary, + child: MediaQuery( + data: mediaQuery.removeDisplayFeatures(rightPane), + child: isLtr ? secondary : primary, ), ), ], @@ -53,6 +71,17 @@ class SplitView extends StatelessWidget { } } +// The width of the line between the panes. +const _kDividerWidth = 1.0; + +// The fold or hinge that divides the window into a left and a right side, if any. +Rect? _verticalFold(MediaQueryData mediaQuery) { + final Size(:width, :height) = mediaQuery.size; + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).firstWhereOrNull( + (it) => it.top <= 0 && it.bottom >= height && it.left > 0 && it.right < width, + ); +} + /// Shows [primary] beside [secondary] on a regular window, and only /// [secondary] on a compact one. /// From 20c54ef0e6b387f4e37e3b911edcbd726a0c2cd3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 23:51:17 +0200 Subject: [PATCH 03/31] chore(samples): update the Android Gradle wrapper to 9.3.1 Gradle 8.14 does not run on Java 25, which current Android Studio bundles. 9.3.1 is the newest Gradle Flutter 3.47's tooling supports and still works with AGP 8.12. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/android/gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_app/android/gradle/wrapper/gradle-wrapper.properties b/sample_app/android/gradle/wrapper/gradle-wrapper.properties index 828b334284..a8f1d162d5 100644 --- a/sample_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/sample_app/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip From 85112904dc3e5b8b98e22739c4f08954e6ab425d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 00:02:37 +0200 Subject: [PATCH 04/31] feat(ui): add automaticallyImplyLeading to StreamChannelPage and StreamThreadPage Passes through to the header, so a page shown as the detail pane of a split view can hide its back button. Defaults to true, keeping the back button. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../lib/src/pages/channel_page.dart | 7 +++++++ .../lib/src/pages/thread_page.dart | 7 +++++++ .../test/src/channel/channel_page_test.dart | 14 ++++++++++++++ .../test/src/channel/thread_page_test.dart | 14 ++++++++++++++ 5 files changed, 43 insertions(+) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 073207dbe2..8b29f15e18 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -5,6 +5,7 @@ - Added `StreamMessageItem.semanticsLabel`, which replaces the announcement composed for a message row, and `StreamMessageItem.excludeFromSemantics`, which leaves the row unlabeled so the bubble and footer announce their own parts. - Added `StreamQuotedMessage.replyMessage`, the message doing the quoting, which lets a quoted preview announce who replied to whom. - Added `StreamMessageContent.excludeTextFromSemantics` and `StreamMessageFooter.excludeFromSemantics`, which keep the message text and the metadata out of the semantics tree when an enclosing row already announces them. +- Added `automaticallyImplyLeading` to `StreamChannelPage` and `StreamThreadPage`, which hides the header's back button when `false`. - Added support for inserting images and GIFs from the keyboard (e.g. Gboard) in `StreamMessageComposer`, configurable via `contentInsertionConfiguration`. ⚠️ Changed diff --git a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart index 88d713f3bf..7e86780942 100644 --- a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart @@ -60,6 +60,7 @@ class StreamChannelPage extends StatefulWidget { super.key, this.initialScrollIndex, this.initialAlignment, + this.automaticallyImplyLeading = true, this.onBackPressed, this.onChannelAvatarPressed, }); @@ -70,6 +71,11 @@ class StreamChannelPage extends StatefulWidget { /// Initial scroll alignment for the message list. final double? initialAlignment; + /// Whether the header shows a back button. + /// + /// Defaults to `true`. + final bool automaticallyImplyLeading; + /// Called when the header's back button is pressed. /// /// Replaces the default action, which pops the current route. When null the @@ -113,6 +119,7 @@ class _StreamChannelPageState extends State { @override Widget build(BuildContext context) { final appBar = StreamChannelHeader( + automaticallyImplyLeading: widget.automaticallyImplyLeading, // Leaving this null keeps the header's default back button, which pops // the route. onBackPressed: widget.onBackPressed, diff --git a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart index 5a9cd024aa..4bc336de12 100644 --- a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart @@ -31,6 +31,7 @@ class StreamThreadPage extends StatefulWidget { this.initialScrollIndex, this.initialAlignment, this.onViewInChannelTap, + this.automaticallyImplyLeading = true, this.onBackPressed, }); @@ -46,6 +47,11 @@ class StreamThreadPage extends StatefulWidget { /// Called when the user taps "View in channel". final void Function(Message message)? onViewInChannelTap; + /// Whether the header shows a back button. + /// + /// Defaults to `true`. + final bool automaticallyImplyLeading; + /// Called when the header's back button is pressed. /// /// Replaces the default action, which pops the current route. When null the @@ -89,6 +95,7 @@ class _StreamThreadPageState extends State { Widget build(BuildContext context) { final appBar = StreamThreadHeader( parent: widget.parent, + automaticallyImplyLeading: widget.automaticallyImplyLeading, // Leaving this null keeps the header's default back button, which pops // the route. onBackPressed: widget.onBackPressed, diff --git a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart index c593fe15b6..ac4ee2a682 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart @@ -36,6 +36,18 @@ void main() { expect(pressedChannel, isNotNull); }); + testWidgets('shows a back button by default', (tester) async { + await _pumpChannelPage(tester); + + expect(find.byType(StreamBackButton), findsOneWidget); + }); + + testWidgets('hides the back button when automaticallyImplyLeading is false', (tester) async { + await _pumpChannelPage(tester, automaticallyImplyLeading: false); + + expect(find.byType(StreamBackButton), findsNothing); + }); + testWidgets('tapping back invokes onBackPressed', (tester) async { var backPressed = 0; await _pumpChannelPage(tester, onBackPressed: () => backPressed++); @@ -267,6 +279,7 @@ Future _pumpChannelPage( StreamSurfaceStyle? composerSurfaceStyle, StreamSurfaceStyle? headerSurfaceStyle, void Function(BuildContext context, Channel channel)? onChannelAvatarPressed, + bool automaticallyImplyLeading = true, VoidCallback? onBackPressed, bool pushOntoARoute = false, }) async { @@ -317,6 +330,7 @@ Future _pumpChannelPage( final page = StreamChannelPage( onChannelAvatarPressed: onChannelAvatarPressed, + automaticallyImplyLeading: automaticallyImplyLeading, onBackPressed: onBackPressed, ); diff --git a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart index ac563654aa..96048a8227 100644 --- a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart @@ -143,6 +143,18 @@ void main() { expect(_backButtonIsFloating(tester), isFalse); }); + testWidgets('shows a back button by default', (tester) async { + await _pumpThreadPage(tester); + + expect(find.byType(StreamBackButton), findsOneWidget); + }); + + testWidgets('hides the back button when automaticallyImplyLeading is false', (tester) async { + await _pumpThreadPage(tester, automaticallyImplyLeading: false); + + expect(find.byType(StreamBackButton), findsNothing); + }); + testWidgets('tapping back invokes onBackPressed', (tester) async { var backPressed = 0; await _pumpThreadPage(tester, onBackPressed: () => backPressed++); @@ -209,6 +221,7 @@ Future _pumpThreadPage( StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, StreamSurfaceStyle? headerSurfaceStyle, void Function(Message message)? onViewInChannelTap, + bool automaticallyImplyLeading = true, VoidCallback? onBackPressed, bool pushOntoARoute = false, }) async { @@ -275,6 +288,7 @@ Future _pumpThreadPage( final page = StreamThreadPage( parent: parentMessage, onViewInChannelTap: onViewInChannelTap, + automaticallyImplyLeading: automaticallyImplyLeading, onBackPressed: onBackPressed, ); From 78fa203cafc702e36b1371465e45097a78eba248 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 00:02:38 +0200 Subject: [PATCH 05/31] feat(samples): make the channel pane behave like a split view's detail While the list and channel are side by side, the channel has no back button, the open channel is highlighted in the list, and opening a channel from the list replaces the one shown instead of stacking on it. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/routes/app_routes.dart | 6 +++++- sample_app/lib/widgets/channel_list.dart | 17 ++++++++++++----- sample_app/lib/widgets/split_view.dart | 8 ++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 906fbc6c86..2a60337f1f 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -53,9 +53,13 @@ final appRoutes = [ openAtFirstUnread: false, child: Builder( builder: (context) { + // Both panes are visible in a split, so there is nothing to go back to. + final showBackButton = !AdaptiveSplitView.isExpandedOf(context); + return (parentMessage != null) - ? StreamThreadPage(parent: parentMessage) + ? StreamThreadPage(parent: parentMessage, automaticallyImplyLeading: showBackButton) : StreamChannelPage( + automaticallyImplyLeading: showBackButton, onChannelAvatarPressed: (context, channel) { final isOneToOne = channel.isOneToOne; final currentUserId = StreamChat.of(context).currentUser?.id; diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 84d0cccc0d..87dbb2fd82 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -11,6 +11,7 @@ import '../routes/routes.dart'; import '../utils/app_config.dart'; import 'channel_detail_sheet.dart'; import 'search_text_field.dart'; +import 'split_view.dart'; class ChannelList extends StatefulWidget { const ChannelList({super.key}); @@ -137,6 +138,12 @@ class _ChannelListDefault extends StatelessWidget { @override Widget build(BuildContext context) { + // Highlights the open channel while it is shown beside the list. + final selectedCid = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context).pathParameters['cid'], + false => null, + }; + return SlidableAutoCloseBehavior( child: RefreshIndicator( onRefresh: channelListController.refresh, @@ -175,7 +182,7 @@ class _ChannelListDefault extends StatelessWidget { ), ], ), - child: defaultWidget, + child: defaultWidget.copyWith(selected: channel.cid == selectedCid), ); }, onChannelTap: (channel) => _openChannelPage(context, channel), @@ -186,9 +193,9 @@ class _ChannelListDefault extends StatelessWidget { } } -// Pushes the channel page for [channel] via [GoRouter]. -Future _openChannelPage(BuildContext context, Channel channel) { - return GoRouter.of(context).pushNamed( +// Opens the channel page for [channel], replacing any channel already open. +void _openChannelPage(BuildContext context, Channel channel) { + GoRouter.of(context).goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); @@ -342,7 +349,7 @@ class _ChannelListSearch extends StatelessWidget { if (channel.state == null) { await channel.watch(); } - router.pushNamed( + router.goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), queryParameters: Routes.CHANNEL_PAGE.queryParams(message), diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 3ddf582814..b0e20534e9 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -101,6 +101,14 @@ class AdaptiveSplitView extends StatefulWidget { /// The navigator for the trailing pane. final Widget secondary; + /// Whether the nearest [AdaptiveSplitView] shows both panes. + /// + /// Returns `false` when there is no [AdaptiveSplitView] above [context]. + static bool isExpandedOf(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AdaptiveSplitViewScope>(); + return scope?.isExpanded ?? false; + } + @override State createState() => _AdaptiveSplitViewState(); } From 59ed593dda55137d333c400db90181af72717b1d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 00:20:40 +0200 Subject: [PATCH 06/31] fix(samples): stop an open channel's state leaking into the next one go_router keys the channel page by its route pattern, so replacing one open channel with another reused the same page: the composer kept the previous channel's text and saved it as the new channel's draft. The page is now keyed by channel and thread, so each gets a fresh one. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/routes/app_routes.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 2a60337f1f..dcaa384653 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -48,6 +48,9 @@ final appRoutes = [ } return StreamChannel( + // Opening another channel or thread in place builds a fresh + // page, so no state such as the composer's text carries over. + key: ValueKey((channel.cid, parentMessage?.id)), channel: channel, initialMessageId: messageId, openAtFirstUnread: false, From c8fd516ee266de92d6848779331d57134d9790bb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 00:20:46 +0200 Subject: [PATCH 07/31] fix(samples): open drafts in the channel pane The Drafts tab pushed its channel on the nearest navigator, which beside the list is the root one, so the channel covered both panes. It now opens the channel route like the other tabs, passing the thread's parent along for threads that are not loaded in the channel yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/pages/draft_list_page.dart | 26 ++++++++++------------- sample_app/lib/routes/app_routes.dart | 14 +++++++----- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/sample_app/lib/pages/draft_list_page.dart b/sample_app/lib/pages/draft_list_page.dart index b30b888cb8..d1776d679b 100644 --- a/sample_app/lib/pages/draft_list_page.dart +++ b/sample_app/lib/pages/draft_list_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../routes/routes.dart'; import '../widgets/stream_draft_list_view.dart'; class DraftListPage extends StatefulWidget { @@ -63,21 +65,15 @@ class _DraftListPageState extends State { final [channelType, channelId] = draft.channelCid.split(':'); final channel = client.channel(channelType, id: channelId); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: channel, - initialMessageId: draft.parentId, - child: switch (draft.parentMessage) { - final parent? => StreamThreadPage( - parent: parent.copyWith(draft: draft), - ), - _ => const StreamChannelPage(), - }, - ); - }, - ), + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: switch (draft.parentId) { + final parentId? => {'mid': parentId, 'pid': parentId}, + _ => const {}, + }, + // The thread's parent may not be loaded in the channel yet. + extra: draft.parentMessage?.copyWith(draft: draft), ); }, ), diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index dcaa384653..3c1a323591 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -38,13 +38,17 @@ final appRoutes = [ final parentId = state.uri.queryParameters['pid']; // Thread deep-links require the parent message to already be in - // channel state. On cold cids (e.g. notification-tap into an - // unwatched channel) the state is null and we fall through to - // ChannelPage; the user lands on the channel rather than the - // thread, which is a degraded but functional UX. + // channel state, unless the caller passes it as `extra`. On cold + // cids (e.g. notification-tap into an unwatched channel) the + // state is null and we fall through to ChannelPage; the user + // lands on the channel rather than the thread, which is a + // degraded but functional UX. Message? parentMessage; if (parentId != null) { - parentMessage = channel.state?.messages.firstWhereOrNull((it) => it.id == parentId); + parentMessage = switch (state.extra) { + final Message parent when parent.id == parentId => parent, + _ => channel.state?.messages.firstWhereOrNull((it) => it.id == parentId), + }; } return StreamChannel( From ab9f6e4cce5d1abff164f00b1bb1ea65d0b283ee Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 00:24:46 +0200 Subject: [PATCH 08/31] docs(ui): note that onBackPressed is not called without a back button Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/lib/src/pages/channel_page.dart | 2 +- packages/stream_chat_flutter/lib/src/pages/thread_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart index 7e86780942..731b13917b 100644 --- a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart @@ -73,7 +73,7 @@ class StreamChannelPage extends StatefulWidget { /// Whether the header shows a back button. /// - /// Defaults to `true`. + /// When `false`, [onBackPressed] is never called. Defaults to `true`. final bool automaticallyImplyLeading; /// Called when the header's back button is pressed. diff --git a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart index 4bc336de12..00342721c0 100644 --- a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart @@ -49,7 +49,7 @@ class StreamThreadPage extends StatefulWidget { /// Whether the header shows a back button. /// - /// Defaults to `true`. + /// When `false`, [onBackPressed] is never called. Defaults to `true`. final bool automaticallyImplyLeading; /// Called when the header's back button is pressed. From 3216023fa382f742c8cd05a0be31f93574971f80 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 00:24:46 +0200 Subject: [PATCH 09/31] docs(samples): tidy the split view docs and constants Class and member docs start with noun phrases, the split view links the size classes it names and shows how to wire it into a ShellRoute, and the docs say when a lookup rebuilds its caller. Constants used by one type move onto it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/pages/channel_placeholder_page.dart | 2 +- sample_app/lib/utils/window_size_class.dart | 16 ++++--- sample_app/lib/widgets/channel_list.dart | 1 - sample_app/lib/widgets/split_view.dart | 47 ++++++++++++++----- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/sample_app/lib/pages/channel_placeholder_page.dart b/sample_app/lib/pages/channel_placeholder_page.dart index 533511053a..5a649d2ff4 100644 --- a/sample_app/lib/pages/channel_placeholder_page.dart +++ b/sample_app/lib/pages/channel_placeholder_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// Fills the channel pane while no channel is open. +/// A page that fills the channel pane while no channel is open. class ChannelPlaceholderPage extends StatelessWidget { const ChannelPlaceholderPage({super.key}); diff --git a/sample_app/lib/utils/window_size_class.dart b/sample_app/lib/utils/window_size_class.dart index f775d7a5e8..f292725f3c 100644 --- a/sample_app/lib/utils/window_size_class.dart +++ b/sample_app/lib/utils/window_size_class.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; -/// How much room a window has for its content, measured on both axes. +/// The amount of room a window has for its content, measured on both axes. enum WindowSizeClass { /// Room for one pane, such as a phone in either orientation. compact, @@ -8,20 +8,22 @@ enum WindowSizeClass { /// Room for two panes, such as a tablet or an unfolded foldable. regular; - /// Returns the size class of a window of the given [size]. + /// The size class of a window of the given [size]. /// /// A window is regular only when it is at least 600 wide and 600 tall, so a /// phone in landscape stays compact. static WindowSizeClass fromSize(Size size) { return switch (size) { - Size(width: >= _kRegularExtent, height: >= _kRegularExtent) => regular, + Size(width: >= _regularExtent, height: >= _regularExtent) => regular, _ => compact, }; } - /// Returns the size class of the window that encloses [context]. + /// The size class of the window that encloses [context]. + /// + /// The widget that owns [context] rebuilds whenever the window's size changes. static WindowSizeClass of(BuildContext context) => fromSize(MediaQuery.sizeOf(context)); -} -// The shortest a regular window is on each axis. -const _kRegularExtent = 600.0; + // The shortest a regular window is on each axis. + static const _regularExtent = 600.0; +} diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 87dbb2fd82..2374655167 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -138,7 +138,6 @@ class _ChannelListDefault extends StatelessWidget { @override Widget build(BuildContext context) { - // Highlights the open channel while it is shown beside the list. final selectedCid = switch (AdaptiveSplitView.isExpandedOf(context)) { true => GoRouterState.of(context).pathParameters['cid'], false => null, diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index b0e20534e9..0c54707a1b 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -6,7 +6,8 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../utils/window_size_class.dart'; -/// Lays out [primary] and [secondary] side by side, separated by a divider. +/// A layout that places [primary] and [secondary] side by side, separated by a +/// divider. /// /// When a fold or hinge divides the window into a left and a right side, each /// pane fills one side. Each pane keeps only the safe-area insets on its outer @@ -16,7 +17,7 @@ class SplitView extends StatelessWidget { super.key, required this.primary, required this.secondary, - this.primaryWidth = 320, + this.primaryWidth = 320.0, }); /// The leading pane. @@ -28,6 +29,9 @@ class SplitView extends StatelessWidget { /// The width of [primary] while no fold or hinge divides the window. final double primaryWidth; + // The width of the line between the panes. + static const _dividerWidth = 1.0; + @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; @@ -37,9 +41,9 @@ class SplitView extends StatelessWidget { // The band between the panes, from the left edge of the window. final (dividerLeft, dividerRight) = switch (_verticalFold(mediaQuery)) { - Rect(:final left, :final right) => (left, math.max(right, left + _kDividerWidth)), - null when isLtr => (primaryWidth, primaryWidth + _kDividerWidth), - null => (width - primaryWidth - _kDividerWidth, width - primaryWidth), + Rect(:final left, :final right) => (left, math.max(right, left + _dividerWidth)), + null when isLtr => (primaryWidth, primaryWidth + _dividerWidth), + null => (width - primaryWidth - _dividerWidth, width - primaryWidth), }; final leftPane = Rect.fromLTRB(0, 0, dividerLeft, height); @@ -57,7 +61,7 @@ class SplitView extends StatelessWidget { ), VerticalDivider( width: dividerRight - dividerLeft, - thickness: _kDividerWidth, + thickness: _dividerWidth, color: colorScheme.borderSubtle, ), Expanded( @@ -71,9 +75,6 @@ class SplitView extends StatelessWidget { } } -// The width of the line between the panes. -const _kDividerWidth = 1.0; - // The fold or hinge that divides the window into a left and a right side, if any. Rect? _verticalFold(MediaQueryData mediaQuery) { final Size(:width, :height) = mediaQuery.size; @@ -82,12 +83,30 @@ Rect? _verticalFold(MediaQueryData mediaQuery) { ); } -/// Shows [primary] beside [secondary] on a regular window, and only -/// [secondary] on a compact one. +/// A split view that shows [primary] beside [secondary] on a +/// [WindowSizeClass.regular] window, and only [secondary] on a +/// [WindowSizeClass.compact] one. /// /// [secondary] is the navigator for the trailing pane, and its root page is an /// [AdaptiveSplitViewRoot]. On a compact window that root page shows /// [primary], so both panes keep their state as the window changes size. +/// +/// ```dart +/// ShellRoute( +/// builder: (context, state, child) { +/// return AdaptiveSplitView(primary: const ChannelListPage(), secondary: child); +/// }, +/// routes: [ +/// GoRoute( +/// path: '/channels', +/// builder: (context, state) { +/// return const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()); +/// }, +/// routes: [/* The routes that open in the trailing pane. */], +/// ), +/// ], +/// ) +/// ``` class AdaptiveSplitView extends StatefulWidget { const AdaptiveSplitView({ super.key, @@ -103,7 +122,9 @@ class AdaptiveSplitView extends StatefulWidget { /// Whether the nearest [AdaptiveSplitView] shows both panes. /// - /// Returns `false` when there is no [AdaptiveSplitView] above [context]. + /// Returns `false` when there is no [AdaptiveSplitView] above [context]. The + /// widget that owns [context] rebuilds only when the split view expands or + /// collapses. static bool isExpandedOf(BuildContext context) { final scope = context.dependOnInheritedWidgetOfExactType<_AdaptiveSplitViewScope>(); return scope?.isExpanded ?? false; @@ -170,7 +191,7 @@ class AdaptiveSplitViewRoot extends StatelessWidget { required this.placeholder, }); - /// Fills the trailing pane while nothing is open in it. + /// The widget that fills the trailing pane while nothing is open in it. final Widget placeholder; @override From 1b1fbbca2f2d58c1417061cf747c31e04ee2461f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:11:14 +0200 Subject: [PATCH 10/31] fix(samples): keep the channel list's selection in place while scrolling The list's scroll listener, which only hides the keyboard, consumed every scroll notification. The Material the selected tile paints on never heard about the scroll, so the highlight lagged behind its tile. The listener now lets the notifications through. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/channel_list.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 2374655167..2766efd088 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -99,7 +99,7 @@ class _ChannelList extends State { if (_scrollController.position.userScrollDirection == ScrollDirection.reverse) { FocusScope.of(context).unfocus(); } - return true; + return false; }, child: NestedScrollView( controller: _scrollController, From 30c93db742a4bb5eec503fc0e4da3d951c9fa773 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:11:47 +0200 Subject: [PATCH 11/31] fix(ui): stop StreamThreadListTile overflowing when narrow The tile's bottom row held the participants, reply count and timestamp at their natural widths, so a narrow list or large text overflowed it. The timestamp now gives way and truncates. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../thread_scroll_view/stream_thread_list_tile.dart | 10 ++++++---- .../stream_thread_list_tile_test.dart | 8 ++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 8b29f15e18..9d40d022d7 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -29,6 +29,7 @@ 🐞 Fixed +- Fixed `StreamThreadListTile` overflowing when narrow; its timestamp now truncates instead. - Fixed `StreamMessageComposer` and its attachment picker running under the left and right safe-area insets while the picker is open. - Fixed `StreamMessageListView`'s scroll-to-bottom button, floating date divider, unread indicator and empty, loading and error states ignoring the safe-area insets when `enableSafeArea` is on. - Fixed the attachment picker overflowing short screens, such as a phone in landscape. diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 150b21d9a3..d27b120f0b 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -353,10 +353,12 @@ class ThreadFooter extends StatelessWidget { context.translations.threadReplyCountText(replyCount), style: replyCountStyle, ), - StreamTimestamp( - date: latestActivityAt.toLocal(), - style: timestampStyle, - formatter: timestampFormatter ?? formatRecentDateTime, + Flexible( + child: StreamTimestamp( + date: latestActivityAt.toLocal(), + style: timestampStyle, + formatter: timestampFormatter ?? formatRecentDateTime, + ), ), ], ); diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart index 690e21f151..462d7bdde7 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart @@ -74,6 +74,14 @@ void main() { ); } + testWidgets('truncates the timestamp instead of overflowing on a narrow width', (tester) async { + final tile = StreamThreadListTile(thread: thread, currentUser: user2); + + await tester.pumpWidget(_wrapWithMaterialApp(SizedBox(width: 240, child: tile))); + + expect(tester.takeException(), isNull); + }); + group('StreamThreadListTile a11y', () { testWidgets( 'merges children into a single accessible node with the composed label', From 13def83f6a69bea1c670a838568e89c3cd09ac03 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:12:05 +0200 Subject: [PATCH 12/31] feat(ui): add StreamThreadListTile.selected and copyWith A selected tile blends the selection color over its background and reports itself as selected to assistive technologies. copyWith lets an itemBuilder mark the tile without rebuilding it from scratch. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../stream_thread_list_tile.dart | 35 ++++++++++++++- .../stream_thread_list_tile_test.dart | 44 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 9d40d022d7..467b71d4eb 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,6 +6,7 @@ - Added `StreamQuotedMessage.replyMessage`, the message doing the quoting, which lets a quoted preview announce who replied to whom. - Added `StreamMessageContent.excludeTextFromSemantics` and `StreamMessageFooter.excludeFromSemantics`, which keep the message text and the metadata out of the semantics tree when an enclosing row already announces them. - Added `automaticallyImplyLeading` to `StreamChannelPage` and `StreamThreadPage`, which hides the header's back button when `false`. +- Added a `selected` parameter to `StreamThreadListTile`, which highlights the tile, and `StreamThreadListTile.copyWith`. - Added support for inserting images and GIFs from the keyboard (e.g. Gboard) in `StreamMessageComposer`, configurable via `contentInsertionConfiguration`. ⚠️ Changed diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index d27b120f0b..1577cd93fe 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -18,11 +18,13 @@ class StreamThreadListTile extends StatelessWidget { User? currentUser, GestureTapCallback? onTap, GestureLongPressCallback? onLongPress, + bool selected = false, }) : props = StreamThreadListTileProps( thread: thread, currentUser: currentUser, onTap: onTap, onLongPress: onLongPress, + selected: selected, ); /// Creates a thread list tile from pre-built [props]. @@ -34,6 +36,26 @@ class StreamThreadListTile extends StatelessWidget { /// The properties configuring this thread list tile. final StreamThreadListTileProps props; + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamThreadListTile copyWith({ + Key? key, + Thread? thread, + User? currentUser, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + bool? selected, + }) { + return StreamThreadListTile( + key: key ?? this.key, + thread: thread ?? props.thread, + currentUser: currentUser ?? props.currentUser, + onTap: onTap ?? props.onTap, + onLongPress: onLongPress ?? props.onLongPress, + selected: selected ?? props.selected, + ); + } + @override Widget build(BuildContext context) { final builder = context.chatComponentBuilder(); @@ -49,6 +71,7 @@ class StreamThreadListTileProps { this.currentUser, this.onTap, this.onLongPress, + this.selected = false, }); /// The thread displayed by the tile. @@ -62,6 +85,11 @@ class StreamThreadListTileProps { /// Called when the tile is long pressed. final GestureLongPressCallback? onLongPress; + + /// Whether the tile is highlighted as the current selection. + /// + /// Defaults to `false`. + final bool selected; } class _DefaultStreamThreadListTile extends StatelessWidget { @@ -74,6 +102,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget { @override Widget build(BuildContext context) { final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; final theme = StreamThreadListTileTheme.of(context); final defaults = _StreamThreadListTileThemeDefaults(context); @@ -110,11 +139,15 @@ class _DefaultStreamThreadListTile extends StatelessWidget { child: StreamListTileTheme( data: StreamListTileThemeData( contentPadding: effectivePadding, - backgroundColor: .all(effectiveBackgroundColor), + backgroundColor: .resolveWith((states) { + if (!states.contains(WidgetState.selected)) return effectiveBackgroundColor; + return .alphaBlend(colorScheme.backgroundSelected, effectiveBackgroundColor); + }), ), child: StreamListTileContainer( onTap: props.onTap, onLongPress: props.onLongPress, + selected: props.selected, child: Row( spacing: spacing.sm, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart index 462d7bdde7..c5a0a1ba86 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart @@ -82,6 +82,44 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('tints the background when selected', (tester) async { + final tile = StreamThreadListTile(thread: thread, currentUser: user2, selected: true); + + await tester.pumpWidget(_wrapWithMaterialApp(tile)); + + final colorScheme = tester.element(find.byType(StreamThreadListTile)).streamColorScheme; + expect(_tileBackground(tester), Color.alphaBlend(colorScheme.backgroundSelected, colorScheme.backgroundApp)); + }); + + testWidgets('does not tint the background by default', (tester) async { + final tile = StreamThreadListTile(thread: thread, currentUser: user2); + + await tester.pumpWidget(_wrapWithMaterialApp(tile)); + + final colorScheme = tester.element(find.byType(StreamThreadListTile)).streamColorScheme; + expect(_tileBackground(tester), colorScheme.backgroundApp); + }); + + testWidgets('announces itself as selected when selected', (tester) async { + final handle = tester.ensureSemantics(); + final tile = StreamThreadListTile(thread: thread, currentUser: user2, selected: true); + + await tester.pumpWidget(_wrapWithMaterialApp(tile)); + + expect(tester.getSemantics(find.byType(StreamThreadListTile)), isSemantics(isSelected: true)); + handle.dispose(); + }); + + test('copyWith replaces selected and keeps the other fields', () { + final tile = StreamThreadListTile(thread: thread, currentUser: user2); + + final copy = tile.copyWith(selected: true); + + expect(copy.props.selected, isTrue); + expect(copy.props.thread, thread); + expect(copy.props.currentUser, user2); + }); + group('StreamThreadListTile a11y', () { testWidgets( 'merges children into a single accessible node with the composed label', @@ -202,6 +240,12 @@ void main() { }); } +// The background color the tile paints behind its content. +Color? _tileBackground(WidgetTester tester) { + final ink = find.descendant(of: find.byType(StreamThreadListTile), matching: find.byType(Ink)); + return (tester.widget(ink).decoration! as ShapeDecoration).color; +} + Widget _wrapWithMaterialApp( Widget widget, { Brightness brightness = Brightness.light, From 07b69dc0dfa8118e94422c4e552b35abd20a706b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:12:20 +0200 Subject: [PATCH 13/31] feat(samples): open threads, drafts and reminders in the channel pane The Threads tab pushed its thread on the nearest navigator, which beside the list covers both panes. It now opens the channel route like the other tabs. The route's thread branch takes over keeping the parent message current and "View in channel". Threads, drafts and reminders are highlighted while open beside the list. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/pages/draft_list_page.dart | 12 +++- sample_app/lib/pages/reminders_page.dart | 52 +++++++++----- sample_app/lib/pages/thread_list_page.dart | 58 +++++----------- sample_app/lib/routes/app_routes.dart | 69 ++++++++++++------- .../lib/widgets/stream_draft_list_tile.dart | 29 +++++++- 5 files changed, 135 insertions(+), 85 deletions(-) diff --git a/sample_app/lib/pages/draft_list_page.dart b/sample_app/lib/pages/draft_list_page.dart index d1776d679b..b6bc407fdc 100644 --- a/sample_app/lib/pages/draft_list_page.dart +++ b/sample_app/lib/pages/draft_list_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../routes/routes.dart'; +import '../widgets/split_view.dart'; import '../widgets/stream_draft_list_view.dart'; class DraftListPage extends StatefulWidget { @@ -26,6 +27,13 @@ class _DraftListPageState extends State { @override Widget build(BuildContext context) { + final openRoute = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context), + false => null, + }; + final openCid = openRoute?.pathParameters['cid']; + final openThreadId = openRoute?.uri.queryParameters['pid']; + return RefreshIndicator( onRefresh: controller.refresh, child: StreamDraftListView( @@ -56,7 +64,9 @@ class _DraftListPageState extends State { ), ], ), - child: defaultWidget, + child: defaultWidget.copyWith( + selected: draft.channelCid == openCid && draft.parentId == openThreadId, + ), ); }, onDraftTap: (draft) { diff --git a/sample_app/lib/pages/reminders_page.dart b/sample_app/lib/pages/reminders_page.dart index 4df2cc66a7..4d5bbfc26a 100644 --- a/sample_app/lib/pages/reminders_page.dart +++ b/sample_app/lib/pages/reminders_page.dart @@ -7,6 +7,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../routes/routes.dart'; import '../widgets/reminder_dialog.dart'; +import '../widgets/split_view.dart'; class RemindersPage extends StatefulWidget { const RemindersPage({super.key}); @@ -48,6 +49,13 @@ class _RemindersPageState extends State { @override Widget build(BuildContext context) { + final openRoute = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context), + false => null, + }; + final openCid = openRoute?.pathParameters['cid']; + final openMessageId = openRoute?.uri.queryParameters['mid']; + return NestedScrollView( headerSliverBuilder: (_, __) => [ SliverPadding( @@ -118,6 +126,7 @@ class _RemindersPageState extends State { ), child: MessageReminderListTile( reminder: reminder, + selected: reminder.channelCid == openCid && reminder.messageId == openMessageId, onReminderTap: () { final client = StreamChat.of(context).client; @@ -307,34 +316,41 @@ class MessageReminderListTile extends StatelessWidget { super.key, required this.reminder, this.onReminderTap, + this.selected = false, }); final MessageReminder reminder; final VoidCallback? onReminderTap; + /// Whether this tile is highlighted as the open reminder. + final bool selected; + @override Widget build(BuildContext context) { final channel = reminder.channel; final channelName = channel?.formatName(currentUser: reminder.user); - return InkWell( - onTap: onReminderTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - child: Column( - spacing: 4, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MessageReminderChannelName(channelName: channelName), - MessageReminderStatus(remindAt: reminder.remindAt), - ], - ), - MessageReminderMessageText(message: reminder.message), - ], + return Material( + color: selected ? context.streamColorScheme.backgroundSelected : Colors.transparent, + child: InkWell( + onTap: onReminderTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + child: Column( + spacing: 4, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MessageReminderChannelName(channelName: channelName), + MessageReminderStatus(remindAt: reminder.remindAt), + ], + ), + MessageReminderMessageText(message: reminder.message), + ], + ), ), ), ); diff --git a/sample_app/lib/pages/thread_list_page.dart b/sample_app/lib/pages/thread_list_page.dart index 36e78e835c..ee554f6842 100644 --- a/sample_app/lib/pages/thread_list_page.dart +++ b/sample_app/lib/pages/thread_list_page.dart @@ -1,9 +1,9 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../routes/routes.dart'; +import '../widgets/split_view.dart'; class ThreadListPage extends StatefulWidget { const ThreadListPage({super.key}); @@ -25,6 +25,11 @@ class _ThreadListPageState extends State { @override Widget build(BuildContext context) { + final openThreadId = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context).uri.queryParameters['pid'], + false => null, + }; + return ValueListenableBuilder>( valueListenable: controller.unseenThreadIds, builder: (context, unseenThreadIds, child) => StreamUnreadThreadsBanner( @@ -38,46 +43,19 @@ class _ThreadListPageState extends State { ), child: StreamThreadListView( controller: controller, - onThreadTap: (thread) async { - final channelCid = thread.channelCid; - - final channel = StreamChat.of(context).client.channel( - channelCid.split(':')[0], - id: channelCid.split(':')[1], - ); + itemBuilder: (context, threads, index, defaultWidget) { + return defaultWidget.copyWith(selected: threads[index].parentMessageId == openThreadId); + }, + onThreadTap: (thread) { + final [type, id] = thread.channelCid.split(':'); + final channel = StreamChat.of(context).client.channel(type, id: id); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: channel, - initialMessageId: thread.draft?.parentId, - child: BetterStreamBuilder( - initialData: thread.parentMessage, - stream: channel.state?.messagesStream - .map( - (messages) => messages.firstWhereOrNull( - (m) => m.id == thread.parentMessage?.id, - ), - ) - .where((msg) => msg != null) - .cast(), - builder: (_, parentMessage) { - return StreamThreadPage( - parent: parentMessage, - onViewInChannelTap: (message) { - GoRouter.of(context).goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: {'mid': message.id}, - ); - }, - ); - }, - ), - ); - }, - ), + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: {'mid': thread.parentMessageId, 'pid': thread.parentMessageId}, + // The thread's parent may not be loaded in the channel yet. + extra: thread.parentMessage, ); }, ), diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 3c1a323591..4c25459c94 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -63,36 +63,55 @@ final appRoutes = [ // Both panes are visible in a split, so there is nothing to go back to. final showBackButton = !AdaptiveSplitView.isExpandedOf(context); - return (parentMessage != null) - ? StreamThreadPage(parent: parentMessage, automaticallyImplyLeading: showBackButton) - : StreamChannelPage( - automaticallyImplyLeading: showBackButton, - onChannelAvatarPressed: (context, channel) { - final isOneToOne = channel.isOneToOne; - final currentUserId = StreamChat.of(context).currentUser?.id; + if (parentMessage case final parent?) { + return BetterStreamBuilder( + initialData: parent, + // Keeps the last known parent while it is not among the loaded messages. + stream: channel.state?.messagesStream + .map((messages) => messages.firstWhereOrNull((it) => it.id == parent.id)) + .where((it) => it != null), + builder: (context, parent) => StreamThreadPage( + parent: parent, + automaticallyImplyLeading: showBackButton, + onViewInChannelTap: (message) { + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: {'mid': message.id}, + ); + }, + ), + ); + } - final channelMembers = channel.state?.members ?? []; - final otherUser = (isOneToOne && currentUserId != null) - ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user - : null; + return StreamChannelPage( + automaticallyImplyLeading: showBackButton, + onChannelAvatarPressed: (context, channel) { + final isOneToOne = channel.isOneToOne; + final currentUserId = StreamChat.of(context).currentUser?.id; - final router = GoRouter.of(context); + final channelMembers = channel.state?.members ?? []; + final otherUser = (isOneToOne && currentUserId != null) + ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user + : null; - if (otherUser != null) { - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), - extra: otherUser, - ); - return; - } + final router = GoRouter.of(context); - router.pushNamed( - Routes.GROUP_INFO_SCREEN.name, - pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), - ); - }, + if (otherUser != null) { + router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: otherUser, ); + return; + } + + router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); + }, + ); }, ), ); diff --git a/sample_app/lib/widgets/stream_draft_list_tile.dart b/sample_app/lib/widgets/stream_draft_list_tile.dart index d782c61bf1..5918acdedd 100644 --- a/sample_app/lib/widgets/stream_draft_list_tile.dart +++ b/sample_app/lib/widgets/stream_draft_list_tile.dart @@ -13,6 +13,7 @@ class StreamDraftListTile extends StatelessWidget { this.currentUser, this.onTap, this.onLongPress, + this.selected = false, }); /// The draft to display. @@ -27,10 +28,36 @@ class StreamDraftListTile extends StatelessWidget { /// Called when the user long-presses on this list tile. final GestureLongPressCallback? onLongPress; + /// Whether this tile is highlighted as the open draft. + final bool selected; + + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamDraftListTile copyWith({ + Key? key, + Draft? draft, + User? currentUser, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + bool? selected, + }) { + return StreamDraftListTile( + key: key ?? this.key, + draft: draft ?? this.draft, + currentUser: currentUser ?? this.currentUser, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + selected: selected ?? this.selected, + ); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final background = colorScheme.backgroundElevation1; + return Material( - color: context.streamColorScheme.backgroundElevation1, + color: selected ? Color.alphaBlend(colorScheme.backgroundSelected, background) : background, child: InkWell( onTap: onTap, onLongPress: onLongPress, From 7de3804a5cb1b57c9bec910315d6b7d3f02a4d13 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:12:30 +0200 Subject: [PATCH 14/31] feat(samples): open the drawer over both panes A modal drawer blocks the rest of the app behind a scrim, but beside the channel it only covered the list pane. The split view now hosts the drawer over both panes, keeping only the inset of the edge it slides in from. On a phone the list page keeps its own drawer. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/pages/channel_list_page.dart | 16 ++++--- sample_app/lib/routes/app_routes.dart | 6 ++- sample_app/lib/widgets/split_view.dart | 53 +++++++++++++++++++-- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index aa6ff4980d..d8b1d668c8 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -14,6 +14,7 @@ import '../utils/location_provider.dart'; import '../utils/platform_support.dart'; import '../utils/shared_location_service.dart'; import '../widgets/channel_list.dart'; +import '../widgets/split_view.dart'; import 'draft_list_page.dart'; import 'reminders_page.dart'; import 'thread_list_page.dart'; @@ -45,6 +46,7 @@ class _ChannelListPageState extends State { final colorScheme = context.streamColorScheme; final config = context.sampleAppConfig; + final isExpanded = AdaptiveSplitView.isExpandedOf(context); final allTabs = <_TabDef>[ _TabDef( @@ -89,8 +91,10 @@ class _ChannelListPageState extends State { backgroundColor: colorScheme.backgroundApp, appBar: StreamChannelListHeader( title: Text(enabledTabs[_currentIndex].navItem.label, style: textTheme.headingSm), + // Beside the channel, the drawer slides in over both panes. + onUserAvatarPressed: isExpanded ? (_) => AdaptiveSplitView.openDrawerOf(context) : null, ), - drawer: LeftDrawer(user: user), + drawer: isExpanded ? null : const LeftDrawer(), // Keep the scaffold's bottom-slot layout in sync with the nav bar's own // surface style, so a nav-bar-only override is applied fully instead of // just partly (the nav bar rendering vs. the scaffold inset). @@ -147,15 +151,13 @@ class _TabDef { } class LeftDrawer extends StatelessWidget { - const LeftDrawer({ - super.key, - required this.user, - }); - - final User user; + const LeftDrawer({super.key}); @override Widget build(BuildContext context) { + final user = StreamChat.of(context).currentUser; + if (user == null) return const SizedBox.shrink(); + final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; final icons = context.streamIcons; diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 4c25459c94..7dd0bb4073 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -19,7 +19,11 @@ import 'routes.dart'; final appRoutes = [ ShellRoute( builder: (BuildContext context, GoRouterState state, Widget child) { - return AdaptiveSplitView(primary: const ChannelListPage(), secondary: child); + return AdaptiveSplitView( + primary: const ChannelListPage(), + secondary: child, + drawer: const LeftDrawer(), + ); }, routes: [ GoRoute( diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 0c54707a1b..f968418f53 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -112,6 +112,7 @@ class AdaptiveSplitView extends StatefulWidget { super.key, required this.primary, required this.secondary, + this.drawer, }); /// The leading pane, such as a list. @@ -120,6 +121,12 @@ class AdaptiveSplitView extends StatefulWidget { /// The navigator for the trailing pane. final Widget secondary; + /// A panel that slides in over both panes while they are shown side by side. + /// + /// Typically a [Drawer], opened with [openDrawerOf]. A compact window shows + /// only the navigator, so the pages in it can host a drawer of their own. + final Widget? drawer; + /// Whether the nearest [AdaptiveSplitView] shows both panes. /// /// Returns `false` when there is no [AdaptiveSplitView] above [context]. The @@ -130,6 +137,14 @@ class AdaptiveSplitView extends StatefulWidget { return scope?.isExpanded ?? false; } + /// Opens the [drawer] of the nearest [AdaptiveSplitView] above [context]. + /// + /// Does nothing when that split view shows one pane or has no [drawer]. + static void openDrawerOf(BuildContext context) { + final state = context.findAncestorStateOfType<_AdaptiveSplitViewState>(); + state?._drawerKey.currentState?.open(); + } + @override State createState() => _AdaptiveSplitViewState(); } @@ -138,6 +153,9 @@ class _AdaptiveSplitViewState extends State { // Moves the primary pane between the leading pane and the root page. final _primaryKey = GlobalKey(debugLabel: 'AdaptiveSplitView.primary'); + // Opens and closes the drawer that spans both panes. + final _drawerKey = GlobalKey(debugLabel: 'AdaptiveSplitView.drawer'); + @override Widget build(BuildContext context) { final isExpanded = WindowSizeClass.of(context) == .regular; @@ -147,14 +165,41 @@ class _AdaptiveSplitViewState extends State { primaryKey: _primaryKey, primary: widget.primary, child: switch (isExpanded) { - true => SplitView( - primary: KeyedSubtree(key: _primaryKey, child: widget.primary), - secondary: widget.secondary, - ), + true => _buildSplit(), false => widget.secondary, }, ); } + + Widget _buildSplit() { + final split = SplitView( + primary: KeyedSubtree(key: _primaryKey, child: widget.primary), + secondary: widget.secondary, + ); + + final drawer = widget.drawer; + if (drawer == null) return split; + + final isLtr = Directionality.of(context) == TextDirection.ltr; + + return Stack( + fit: .expand, + children: [ + split, + // The drawer only reaches the leading edge, so it keeps only that edge's inset. + MediaQuery.removePadding( + context: context, + removeLeft: !isLtr, + removeRight: isLtr, + child: DrawerController( + key: _drawerKey, + alignment: .start, + child: drawer, + ), + ), + ], + ); + } } class _AdaptiveSplitViewScope extends InheritedWidget { From f07067f733a83b846903e71e4a2e4321d430a578 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:47:32 +0200 Subject: [PATCH 15/31] fix(ui): stop the message actions modal throwing after its message leaves the screen The modal's builder read the chat configuration from the message's context. When the message left the tree while the modal was open, for example when another channel opened beside the list, the next rebuild of the modal looked up a deactivated context and threw. The configuration is now read before the modal opens. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../message_widget/stream_message_item.dart | 4 +- ...tream_message_item_actions_modal_test.dart | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 467b71d4eb..3d6ef16124 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -31,6 +31,7 @@ 🐞 Fixed - Fixed `StreamThreadListTile` overflowing when narrow; its timestamp now truncates instead. +- Fixed `StreamMessageItem`'s actions modal throwing when the message leaves the screen while the modal is open. - Fixed `StreamMessageComposer` and its attachment picker running under the left and right safe-area insets while the picker is open. - Fixed `StreamMessageListView`'s scroll-to-bottom button, floating date divider, unread indicator and empty, loading and error states ignoring the safe-area insets when `enableSafeArea` is on. - Fixed the attachment picker overflowing short screens, such as a phone in landscape. diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 06d0ff07f1..a4c54ea0d0 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -901,6 +901,8 @@ class DefaultStreamMessageItem extends StatelessWidget { currentUser: currentUser, ); + // Read up front: the modal can outlive this message, and with it `context`. + final configuration = StreamChatConfiguration.of(context); final layout = StreamMessageLayout.of(context); final theme = core.StreamMessageItemTheme.of(context); final defaults = _StreamMessageItemDefaults( @@ -931,7 +933,7 @@ class DefaultStreamMessageItem extends StatelessWidget { context: context, useRootNavigator: false, builder: (_) => StreamChatConfiguration( - data: StreamChatConfiguration.of(context), + data: configuration, child: StreamMessageLayout( // The message is re-rendered on top of the modal scrim, so its // metadata and annotations switch to their on-scrim colors. diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart new file mode 100644 index 0000000000..01d4cc62e4 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart @@ -0,0 +1,67 @@ +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 '../mocks.dart'; + +void main() { + testWidgets('keeps the actions modal working after its message leaves the tree', (tester) async { + final client = MockClient(); + final channel = _mockChannel(client); + + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: true)); + await tester.pumpAndSettle(); + await _longPress(tester, find.byType(StreamMessageItem)); + + // Rebuilding the app rebuilds the navigator's routes, the open modal among them. + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: false)); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('MessageItem')), findsOneWidget); + }); +} + +MockChannel _mockChannel(MockClient client) { + final currentUser = OwnUser(id: 'current-user'); + final clientState = MockClientState(); + final channel = MockChannel(ownCapabilities: const [ChannelCapability.sendMessage, ChannelCapability.sendReply]); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + + return channel; +} + +Widget _app({required MockClient client, required MockChannel channel, required bool showMessage}) { + final message = Message( + id: 'test-message', + text: 'Long press me', + createdAt: DateTime(2026), + user: User(id: 'other-user'), + state: MessageState.sent, + ); + + return MaterialApp( + builder: (context, child) => StreamChat(client: client, child: child), + home: StreamChannel( + channel: channel, + child: Scaffold( + body: showMessage ? StreamMessageItem(message: message) : const SizedBox.shrink(), + ), + ), + ); +} + +// Holds past kLongPressTimeout, which a plain tester.longPress does not +// reliably cross. +Future _longPress(WidgetTester tester, Finder finder) async { + final gesture = await tester.startGesture(tester.getCenter(finder)); + await tester.pump(const Duration(milliseconds: 700)); + await gesture.up(); + await tester.pumpAndSettle(); +} From 6b963b43bc0a706765fbac61f42f0124d57f56eb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 01:47:32 +0200 Subject: [PATCH 16/31] fix(samples): swap channels in place beside the list and close their menus The channel page is now keyed by channel and thread, so opening another one replaces the page and closes the menus open on it. Pages in the channel pane swap in without a transition while the list is beside them, and keep the platform's page transition on a phone. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/routes/app_routes.dart | 24 +++++++---- sample_app/lib/widgets/split_view.dart | 57 ++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 7dd0bb4073..f9f8f7342d 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -29,14 +29,18 @@ final appRoutes = [ GoRoute( name: Routes.CHANNEL_LIST_PAGE.name, path: Routes.CHANNEL_LIST_PAGE.path, - builder: (BuildContext context, GoRouterState state) { - return const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()); + pageBuilder: (BuildContext context, GoRouterState state) { + return AdaptiveSplitViewPage( + key: state.pageKey, + name: state.name, + child: const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()), + ); }, routes: [ GoRoute( name: Routes.CHANNEL_PAGE.name, path: Routes.CHANNEL_PAGE.path, - builder: (context, state) { + pageBuilder: (context, state) { final channel = _resolveChannel(context, state); final messageId = state.uri.queryParameters['mid']; final parentId = state.uri.queryParameters['pid']; @@ -55,10 +59,7 @@ final appRoutes = [ }; } - return StreamChannel( - // Opening another channel or thread in place builds a fresh - // page, so no state such as the composer's text carries over. - key: ValueKey((channel.cid, parentMessage?.id)), + final page = StreamChannel( channel: channel, initialMessageId: messageId, openAtFirstUnread: false, @@ -119,6 +120,15 @@ final appRoutes = [ }, ), ); + + // Opening another channel or thread in place replaces the page, so + // nothing carries over from the last one: neither its state, such + // as the composer's text, nor the menus open on it. + return AdaptiveSplitViewPage( + key: ValueKey((channel.cid, parentMessage?.id)), + name: state.name, + child: page, + ); }, routes: [ GoRoute( diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index f968418f53..b16c7844f1 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -89,7 +89,8 @@ Rect? _verticalFold(MediaQueryData mediaQuery) { /// /// [secondary] is the navigator for the trailing pane, and its root page is an /// [AdaptiveSplitViewRoot]. On a compact window that root page shows -/// [primary], so both panes keep their state as the window changes size. +/// [primary], so both panes keep their state as the window changes size. Its +/// pages are [AdaptiveSplitViewPage]s, which swap in place beside [primary]. /// /// ```dart /// ShellRoute( @@ -99,8 +100,11 @@ Rect? _verticalFold(MediaQueryData mediaQuery) { /// routes: [ /// GoRoute( /// path: '/channels', -/// builder: (context, state) { -/// return const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()); +/// pageBuilder: (context, state) { +/// return AdaptiveSplitViewPage( +/// key: state.pageKey, +/// child: const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()), +/// ); /// }, /// routes: [/* The routes that open in the trailing pane. */], /// ), @@ -247,3 +251,50 @@ class AdaptiveSplitViewRoot extends StatelessWidget { return KeyedSubtree(key: scope.primaryKey, child: scope.primary); } } + +/// A page for the navigator of an [AdaptiveSplitView]'s trailing pane. +/// +/// Swaps in without a transition while both panes are shown, and uses the +/// platform's page transition when only the navigator is. +class AdaptiveSplitViewPage extends Page { + const AdaptiveSplitViewPage({ + super.key, + super.name, + required this.child, + }); + + /// The content of the page. + final Widget child; + + @override + Route createRoute(BuildContext context) => _AdaptiveSplitViewPageRoute(page: this); +} + +class _AdaptiveSplitViewPageRoute extends PageRoute with MaterialRouteTransitionMixin { + _AdaptiveSplitViewPageRoute({required AdaptiveSplitViewPage page}) : super(settings: page); + + AdaptiveSplitViewPage get _page => settings as AdaptiveSplitViewPage; + + @override + Widget buildContent(BuildContext context) => _page.child; + + @override + bool get maintainState => true; + + @override + bool get fullscreenDialog => false; + + @override + String get debugLabel => '${super.debugLabel}(${_page.name})'; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (AdaptiveSplitView.isExpandedOf(context)) return child; + return super.buildTransitions(context, animation, secondaryAnimation, child); + } +} From c8a2564b5afcd16665d5e2e190a866b7d57e2688 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 02:10:02 +0200 Subject: [PATCH 17/31] feat(samples): show one pane on regular windows narrower than 840 A regular window now needs to be at least 840 wide before the channel list and channel sit side by side, so an iPhone Duo's inner display in portrait or a tablet in portrait shows one pane at a time, while wider windows keep the split. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index b16c7844f1..eee380e3e2 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -83,13 +83,15 @@ Rect? _verticalFold(MediaQueryData mediaQuery) { ); } -/// A split view that shows [primary] beside [secondary] on a -/// [WindowSizeClass.regular] window, and only [secondary] on a -/// [WindowSizeClass.compact] one. +/// A split view that shows [primary] beside [secondary] on a wide +/// [WindowSizeClass.regular] window, and only [secondary] otherwise. +/// +/// Both panes need a regular window at least 840 wide, so a narrower regular +/// window, such as a tablet in portrait, shows one pane at a time. /// /// [secondary] is the navigator for the trailing pane, and its root page is an -/// [AdaptiveSplitViewRoot]. On a compact window that root page shows -/// [primary], so both panes keep their state as the window changes size. Its +/// [AdaptiveSplitViewRoot]. While only [secondary] is shown, that root page +/// shows [primary], so both panes keep their state as the window changes size. Its /// pages are [AdaptiveSplitViewPage]s, which swap in place beside [primary]. /// /// ```dart @@ -127,8 +129,8 @@ class AdaptiveSplitView extends StatefulWidget { /// A panel that slides in over both panes while they are shown side by side. /// - /// Typically a [Drawer], opened with [openDrawerOf]. A compact window shows - /// only the navigator, so the pages in it can host a drawer of their own. + /// Typically a [Drawer], opened with [openDrawerOf]. While only the navigator + /// is shown, the pages in it can host a drawer of their own. final Widget? drawer; /// Whether the nearest [AdaptiveSplitView] shows both panes. @@ -160,9 +162,13 @@ class _AdaptiveSplitViewState extends State { // Opens and closes the drawer that spans both panes. final _drawerKey = GlobalKey(debugLabel: 'AdaptiveSplitView.drawer'); + // The narrowest window that shows both panes. + static const _minimumSplitWidth = 840.0; + @override Widget build(BuildContext context) { - final isExpanded = WindowSizeClass.of(context) == .regular; + final size = MediaQuery.sizeOf(context); + final isExpanded = WindowSizeClass.fromSize(size) == .regular && size.width >= _minimumSplitWidth; return _AdaptiveSplitViewScope( isExpanded: isExpanded, @@ -232,8 +238,8 @@ class _AdaptiveSplitViewScope extends InheritedWidget { /// The root page of an [AdaptiveSplitView]'s navigator. /// -/// Shows the split view's primary pane on a compact window, and [placeholder] -/// while both panes are shown. +/// Shows the split view's primary pane while only the navigator is shown, and +/// [placeholder] while both panes are shown. class AdaptiveSplitViewRoot extends StatelessWidget { const AdaptiveSplitViewRoot({ super.key, From 81baeb53e11abed9bfe2bae67968e7fd0ef12dad Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 02:10:02 +0200 Subject: [PATCH 18/31] fix(samples): keep the channel list's selection from showing behind the search field The selected tile paints its background on the nearest Material, which was the page's, so it stayed visible behind the search field once the tile scrolled under it. A transparent Material around the list keeps it inside the list. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/channel_list.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 2766efd088..5cb32eb94d 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -120,10 +120,15 @@ class _ChannelList extends State { body: MediaQuery.removePadding( context: context, removeTop: true, - child: switch (_isSearchActive) { - true => _ChannelListSearch(_messageSearchListController), - false => _ChannelListDefault(_channelListController), - }, + // Tiles paint their selection on the nearest Material; this one + // keeps it inside the list instead of behind the search field. + child: Material( + type: MaterialType.transparency, + child: switch (_isSearchActive) { + true => _ChannelListSearch(_messageSearchListController), + false => _ChannelListDefault(_channelListController), + }, + ), ), ), ), From 96d4ed7e464febdeb1dedc79854d3438e4ad2198 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 02:19:47 +0200 Subject: [PATCH 19/31] feat(samples): ease the split into place when a fold appears or disappears The list pane used to jump between its own width and the fold. It now eases there over 200ms, or instantly when animations are disabled. Only its width animates, so rotating or resizing the window still lays out at once. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 61 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index eee380e3e2..72a56f5074 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -10,8 +10,9 @@ import '../utils/window_size_class.dart'; /// divider. /// /// When a fold or hinge divides the window into a left and a right side, each -/// pane fills one side. Each pane keeps only the safe-area insets on its outer -/// edges. Assumes it fills the window. +/// pane fills one side, easing into place as the fold appears or disappears. +/// Each pane keeps only the safe-area insets on its outer edges. Assumes it +/// fills the window. class SplitView extends StatelessWidget { const SplitView({ super.key, @@ -32,42 +33,66 @@ class SplitView extends StatelessWidget { // The width of the line between the panes. static const _dividerWidth = 1.0; + // How long the panes take to settle when a fold starts or stops dividing + // the window. + static const _foldTransitionDuration = Duration(milliseconds: 200); + @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; final mediaQuery = MediaQuery.of(context); - final Size(:width, :height) = mediaQuery.size; final isLtr = Directionality.of(context) == TextDirection.ltr; - // The band between the panes, from the left edge of the window. - final (dividerLeft, dividerRight) = switch (_verticalFold(mediaQuery)) { - Rect(:final left, :final right) => (left, math.max(right, left + _dividerWidth)), - null when isLtr => (primaryWidth, primaryWidth + _dividerWidth), - null => (width - primaryWidth - _dividerWidth, width - primaryWidth), + final (targetPrimaryWidth, dividerWidth) = switch (_verticalFold(mediaQuery)) { + final fold? => (isLtr ? fold.left : mediaQuery.size.width - fold.right, math.max(fold.width, _dividerWidth)), + null => (primaryWidth, _dividerWidth), }; - final leftPane = Rect.fromLTRB(0, 0, dividerLeft, height); - final rightPane = Rect.fromLTRB(dividerRight, 0, width, height); + return TweenAnimationBuilder( + tween: Tween(end: targetPrimaryWidth), + duration: mediaQuery.disableAnimations ? Duration.zero : _foldTransitionDuration, + curve: Curves.easeInOut, + builder: (context, primaryPaneWidth, _) => _buildPanes( + context, + mediaQuery: mediaQuery, + primaryPaneWidth: primaryPaneWidth, + dividerWidth: dividerWidth, + ), + ); + } + + Widget _buildPanes( + BuildContext context, { + required MediaQueryData mediaQuery, + required double primaryPaneWidth, + required double dividerWidth, + }) { + final colorScheme = context.streamColorScheme; + final Size(:width, :height) = mediaQuery.size; + final isLtr = Directionality.of(context) == TextDirection.ltr; + + // Each pane's area on the window, which the insets and folds are relative to. + final secondaryPaneWidth = width - primaryPaneWidth - dividerWidth; + final primaryPane = Rect.fromLTWH(isLtr ? 0 : width - primaryPaneWidth, 0, primaryPaneWidth, height); + final secondaryPane = Rect.fromLTWH(isLtr ? width - secondaryPaneWidth : 0, 0, secondaryPaneWidth, height); return Row( - textDirection: TextDirection.ltr, children: [ SizedBox( - width: leftPane.width, + width: primaryPaneWidth, child: MediaQuery( - data: mediaQuery.removeDisplayFeatures(leftPane), - child: isLtr ? primary : secondary, + data: mediaQuery.removeDisplayFeatures(primaryPane), + child: primary, ), ), VerticalDivider( - width: dividerRight - dividerLeft, + width: dividerWidth, thickness: _dividerWidth, color: colorScheme.borderSubtle, ), Expanded( child: MediaQuery( - data: mediaQuery.removeDisplayFeatures(rightPane), - child: isLtr ? secondary : primary, + data: mediaQuery.removeDisplayFeatures(secondaryPane), + child: secondary, ), ), ], From ac627b8b4d0f556eb48242adf5c5cb0fe4d1cf4b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 13:02:42 +0200 Subject: [PATCH 20/31] fix(samples): close the login progress dialog without asking the router to pop Logging in pops the progress dialog through the router once connect returns. By then the redirect has moved the router onto the channel shell, whose navigator isn't built yet, and the router's pop throws. The dialog is now removed through the navigator it was pushed on, and only if the redirect hasn't already removed it with the login page. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/pages/choose_user_page.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sample_app/lib/pages/choose_user_page.dart b/sample_app/lib/pages/choose_user_page.dart index 244e3b7265..9e86085971 100644 --- a/sample_app/lib/pages/choose_user_page.dart +++ b/sample_app/lib/pages/choose_user_page.dart @@ -66,10 +66,12 @@ class ChooseUserPage extends StatelessWidget { return ListTile( visualDensity: VisualDensity.compact, onTap: () async { - showDialog( + final navigator = Navigator.of(context, rootNavigator: true); + final progress = DialogRoute( barrierDismissible: false, context: context, barrierColor: context.streamColorScheme.backgroundOverlayLight, + themes: InheritedTheme.capture(from: context, to: navigator.context), builder: (context) => Center( child: Container( decoration: BoxDecoration( @@ -84,6 +86,7 @@ class ChooseUserPage extends StatelessWidget { ), ), ); + navigator.push(progress); final router = GoRouter.of(context); @@ -95,8 +98,9 @@ class ChooseUserPage extends StatelessWidget { baseUrl: kStreamBaseUrl.isEmpty ? null : kStreamBaseUrl, ); } finally { - // Pop the progress dialog regardless of outcome. - router.pop(); + // Remove the progress dialog regardless of outcome, unless the + // redirect to the channel list already took it with this page. + if (progress.isActive) navigator.removeRoute(progress); } // The router's redirect will forward an From ae99d38db1c6e92b543e6248b5ddab0086a31091 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 30 Sep 2026 13:02:42 +0200 Subject: [PATCH 21/31] fix(samples): update the Crashlytics Gradle plugin to 3.0.8 for Gradle 9 Crashlytics 2.8.1 reads the app id with groovy.util.XmlSlurper, which Gradle 9 no longer ships, so release builds failed while uploading the mapping file. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/android/settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_app/android/settings.gradle b/sample_app/android/settings.gradle index 1217943547..30423e2804 100644 --- a/sample_app/android/settings.gradle +++ b/sample_app/android/settings.gradle @@ -21,7 +21,7 @@ plugins { id "com.android.application" version "8.12.2" apply false // START: FlutterFire Configuration id "com.google.gms.google-services" version "4.4.3" apply false - id "com.google.firebase.crashlytics" version "2.8.1" apply false + id "com.google.firebase.crashlytics" version "3.0.8" apply false // END: FlutterFire Configuration id "org.jetbrains.kotlin.android" version "2.2.20" apply false } From d50bf7faee514ae9a86ba3cb9b19b16a52e2f406 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 1 Oct 2026 11:58:19 +0200 Subject: [PATCH 22/31] refactor(samples): classify the window by width and height separately WindowSizeClass was compact or regular on both axes, and the split view added its own 840 check on top. It now pairs a WindowWidthClass (compact, medium, expanded, large, extraLarge) with a WindowHeightClass (compact, medium, expanded), at the Material breakpoints. The split view shows both panes when the window is at least expanded wide and medium tall. The iPhone Duo and Android foldables split as before; a window at least 840 wide and 480 to 599 tall now splits too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AP6bV4L7FQYjHoHZeomLxr --- sample_app/lib/utils/window_size_class.dart | 83 ++++++++++++++++----- sample_app/lib/widgets/split_view.dart | 15 ++-- 2 files changed, 71 insertions(+), 27 deletions(-) diff --git a/sample_app/lib/utils/window_size_class.dart b/sample_app/lib/utils/window_size_class.dart index f292725f3c..609f5c0807 100644 --- a/sample_app/lib/utils/window_size_class.dart +++ b/sample_app/lib/utils/window_size_class.dart @@ -1,29 +1,76 @@ import 'package:flutter/widgets.dart'; -/// The amount of room a window has for its content, measured on both axes. -enum WindowSizeClass { - /// Room for one pane, such as a phone in either orientation. - compact, +/// How much room a window has across, from the narrowest class to the widest. +enum WindowWidthClass { + /// Narrower than 600, such as a phone in portrait. + compact(0), - /// Room for two panes, such as a tablet or an unfolded foldable. - regular; + /// At least 600 wide, such as a foldable or a tablet in portrait. + medium(600), + + /// At least 840 wide, such as a foldable or a tablet in landscape. + expanded(840), + + /// At least 1200 wide, such as a desktop window. + large(1200), + + /// At least 1600 wide, such as a large desktop window. + extraLarge(1600); + + const WindowWidthClass(this.minWidth); + + /// The narrowest a window of this class is. + final double minWidth; + + /// The class of a window that is [width] wide. + static WindowWidthClass fromWidth(double width) => values.lastWhere((it) => width >= it.minWidth); + + /// Whether this class is [other] or a wider one. + bool isAtLeast(WindowWidthClass other) => index >= other.index; +} + +/// How much room a window has from top to bottom, from the shortest class to +/// the tallest. +enum WindowHeightClass { + /// Shorter than 480, such as a phone in landscape. + compact(0), + + /// At least 480 tall, such as a phone in portrait or a tablet in landscape. + medium(480), + + /// At least 900 tall, such as a tablet in portrait. + expanded(900); + + const WindowHeightClass(this.minHeight); + + /// The shortest a window of this class is. + final double minHeight; + + /// The class of a window that is [height] tall. + static WindowHeightClass fromHeight(double height) => values.lastWhere((it) => height >= it.minHeight); + + /// Whether this class is [other] or a taller one. + bool isAtLeast(WindowHeightClass other) => index >= other.index; +} + +/// The width and height classes of a window. +@immutable +class WindowSizeClass { + /// Creates the size class of a window with the given [width] and [height] + /// classes. + const WindowSizeClass({required this.width, required this.height}); /// The size class of a window of the given [size]. - /// - /// A window is regular only when it is at least 600 wide and 600 tall, so a - /// phone in landscape stays compact. - static WindowSizeClass fromSize(Size size) { - return switch (size) { - Size(width: >= _regularExtent, height: >= _regularExtent) => regular, - _ => compact, - }; - } + WindowSizeClass.fromSize(Size size) : width = .fromWidth(size.width), height = .fromHeight(size.height); /// The size class of the window that encloses [context]. /// /// The widget that owns [context] rebuilds whenever the window's size changes. - static WindowSizeClass of(BuildContext context) => fromSize(MediaQuery.sizeOf(context)); + static WindowSizeClass of(BuildContext context) => .fromSize(MediaQuery.sizeOf(context)); + + /// How much room the window has across. + final WindowWidthClass width; - // The shortest a regular window is on each axis. - static const _regularExtent = 600.0; + /// How much room the window has from top to bottom. + final WindowHeightClass height; } diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 72a56f5074..1715ca8c22 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -108,11 +108,11 @@ Rect? _verticalFold(MediaQueryData mediaQuery) { ); } -/// A split view that shows [primary] beside [secondary] on a wide -/// [WindowSizeClass.regular] window, and only [secondary] otherwise. +/// A split view that shows [primary] beside [secondary] on a +/// [WindowWidthClass.expanded] window, and only [secondary] otherwise. /// -/// Both panes need a regular window at least 840 wide, so a narrower regular -/// window, such as a tablet in portrait, shows one pane at a time. +/// Both panes also need a window that is at least [WindowHeightClass.medium], +/// so a phone in landscape shows one pane at a time. /// /// [secondary] is the navigator for the trailing pane, and its root page is an /// [AdaptiveSplitViewRoot]. While only [secondary] is shown, that root page @@ -187,13 +187,10 @@ class _AdaptiveSplitViewState extends State { // Opens and closes the drawer that spans both panes. final _drawerKey = GlobalKey(debugLabel: 'AdaptiveSplitView.drawer'); - // The narrowest window that shows both panes. - static const _minimumSplitWidth = 840.0; - @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - final isExpanded = WindowSizeClass.fromSize(size) == .regular && size.width >= _minimumSplitWidth; + final sizeClass = WindowSizeClass.of(context); + final isExpanded = sizeClass.width.isAtLeast(.expanded) && sizeClass.height.isAtLeast(.medium); return _AdaptiveSplitViewScope( isExpanded: isExpanded, From c590111e82eb20dec577a991beb550edacc655a6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 2 Oct 2026 13:35:31 +0200 Subject: [PATCH 23/31] feat(samples): let people resize the split view's panes Drag the divider, use the arrow keys, or adjust it with a screen reader to resize the channel list between 280 and 420 while the chat keeps at least 360. While a fold divides the window the panes stay on either side of it and the divider can't be dragged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AP6bV4L7FQYjHoHZeomLxr --- sample_app/lib/widgets/split_view.dart | 265 +++++++++++++++++++++++-- 1 file changed, 248 insertions(+), 17 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 1715ca8c22..b0625d4529 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -1,24 +1,27 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' show objectRuntimeType; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../utils/window_size_class.dart'; /// A layout that places [primary] and [secondary] side by side, separated by a -/// divider. +/// divider that people can drag to resize the panes. /// /// When a fold or hinge divides the window into a left and a right side, each -/// pane fills one side, easing into place as the fold appears or disappears. -/// Each pane keeps only the safe-area insets on its outer edges. Assumes it -/// fills the window. -class SplitView extends StatelessWidget { +/// pane fills one side, easing into place as the fold appears or disappears, +/// and the divider can't be dragged. Each pane keeps only the safe-area insets +/// on its outer edges. Assumes it fills the window. +class SplitView extends StatefulWidget { const SplitView({ super.key, required this.primary, required this.secondary, - this.primaryWidth = 320.0, + this.primaryConstraints = const SplitPaneConstraints(minWidth: 280, initialWidth: 320, maxWidth: 420), + this.secondaryConstraints = const SplitPaneConstraints(minWidth: 360), }); /// The leading pane. @@ -27,29 +30,154 @@ class SplitView extends StatelessWidget { /// The trailing pane, which fills the width [primary] leaves. final Widget secondary; - /// The width of [primary] while no fold or hinge divides the window. - final double primaryWidth; + /// The widths [primary] starts at and can be resized within. + final SplitPaneConstraints primaryConstraints; + + /// The widths [secondary] starts at and can be resized within. + /// + /// Its [SplitPaneConstraints.minWidth] wins over [primaryConstraints] on a + /// window too narrow for both. + final SplitPaneConstraints secondaryConstraints; + + @override + State createState() => _SplitViewState(); +} + +/// The widths a [SplitView] pane starts at and can be resized within. +@immutable +class SplitPaneConstraints { + const SplitPaneConstraints({ + this.minWidth = 0.0, + this.initialWidth, + this.maxWidth = double.infinity, + }) : assert(0 <= minWidth && minWidth <= maxWidth, 'minWidth must be between 0 and maxWidth.'), + assert( + initialWidth == null || (minWidth <= initialWidth && initialWidth <= maxWidth), + 'initialWidth must be between minWidth and maxWidth.', + ); + + /// The narrowest people can make the pane. + final double minWidth; + + /// The width of the pane until people resize it. + /// + /// When both panes have one, the leading pane's wins. When neither does, + /// the leading pane starts at its narrowest. + final double? initialWidth; + + /// The widest people can make the pane. + final double maxWidth; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is SplitPaneConstraints && + other.minWidth == minWidth && + other.initialWidth == initialWidth && + other.maxWidth == maxWidth; + } + + @override + int get hashCode => Object.hash(minWidth, initialWidth, maxWidth); + + @override + String toString() { + return '${objectRuntimeType(this, 'SplitPaneConstraints')}' + '(minWidth: $minWidth, initialWidth: $initialWidth, maxWidth: $maxWidth)'; + } +} +class _SplitViewState extends State { // The width of the line between the panes. static const _dividerWidth = 1.0; + // How far one keyboard or screen reader step resizes the panes. + static const _resizeStep = 20.0; + // How long the panes take to settle when a fold starts or stops dividing - // the window. - static const _foldTransitionDuration = Duration(milliseconds: 200); + // the window, or a keyboard or screen reader step resizes them. + static const _transitionDuration = Duration(milliseconds: 200); + + // The width people resized [SplitView.primary] to, if they have. + double? _resizedPrimaryWidth; + + // Whether people are dragging the divider, which the panes follow without easing. + var _isDragging = false; + + // The range people can resize [SplitView.primary] within on a window [width] + // wide, where both panes' constraints hold. + ({double min, double max}) _primaryWidthRange(double width) { + final SplitView(:primaryConstraints, :secondaryConstraints) = widget; + final available = width - _dividerWidth; + final max = math.min(primaryConstraints.maxWidth, available - secondaryConstraints.minWidth); + final min = math.max(primaryConstraints.minWidth, available - secondaryConstraints.maxWidth); + return (min: math.min(min, max), max: max); + } + + // The width of [SplitView.primary] on a window [width] wide. + double _primaryWidth(double width) { + final SplitView(:primaryConstraints, :secondaryConstraints) = widget; + final range = _primaryWidthRange(width); + final initialWidth = switch ((primaryConstraints.initialWidth, secondaryConstraints.initialWidth)) { + (final primary?, _) => primary, + (null, final secondary?) => width - _dividerWidth - secondary, + (null, null) => range.min, + }; + return (_resizedPrimaryWidth ?? initialWidth).clamp(range.min, range.max); + } + + void _resizeBy(double delta, double width) { + final range = _primaryWidthRange(width); + setState(() => _resizedPrimaryWidth = (_primaryWidth(width) + delta).clamp(range.min, range.max)); + } @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); + final width = mediaQuery.size.width; final isLtr = Directionality.of(context) == TextDirection.ltr; - final (targetPrimaryWidth, dividerWidth) = switch (_verticalFold(mediaQuery)) { - final fold? => (isLtr ? fold.left : mediaQuery.size.width - fold.right, math.max(fold.width, _dividerWidth)), - null => (primaryWidth, _dividerWidth), + final fold = _verticalFold(mediaQuery); + final range = _primaryWidthRange(width); + final primaryWidth = switch (fold) { + final fold? => isLtr ? fold.left : width - fold.right, + null => _primaryWidth(width), }; + final dividerWidth = math.max(fold?.width ?? 0, _dividerWidth); + + // Builds the same tree with or without a fold, so the panes ease between the two. + return Stack( + fit: .expand, + children: [ + _buildAnimatedPanes(mediaQuery, primaryWidth, dividerWidth), + if (fold == null) + PositionedDirectional( + top: 0, + bottom: 0, + start: primaryWidth + (_dividerWidth - _SplitViewDivider.hitWidth) / 2, + width: _SplitViewDivider.hitWidth, + child: _SplitViewDivider( + primaryWidth: primaryWidth, + minPrimaryWidth: range.min, + maxPrimaryWidth: range.max, + windowWidth: width, + step: _resizeStep, + onResizeStart: () => setState(() => _isDragging = true), + onResize: (delta) => _resizeBy(delta, width), + onResizeEnd: () => setState(() => _isDragging = false), + onStep: (delta) => _resizeBy(delta, width), + ), + ), + ], + ); + } + + Widget _buildAnimatedPanes(MediaQueryData mediaQuery, double primaryWidth, double dividerWidth) { + final animate = !_isDragging && !mediaQuery.disableAnimations; return TweenAnimationBuilder( - tween: Tween(end: targetPrimaryWidth), - duration: mediaQuery.disableAnimations ? Duration.zero : _foldTransitionDuration, + tween: Tween(end: primaryWidth), + duration: animate ? _transitionDuration : Duration.zero, curve: Curves.easeInOut, builder: (context, primaryPaneWidth, _) => _buildPanes( context, @@ -81,7 +209,7 @@ class SplitView extends StatelessWidget { width: primaryPaneWidth, child: MediaQuery( data: mediaQuery.removeDisplayFeatures(primaryPane), - child: primary, + child: widget.primary, ), ), VerticalDivider( @@ -92,7 +220,7 @@ class SplitView extends StatelessWidget { Expanded( child: MediaQuery( data: mediaQuery.removeDisplayFeatures(secondaryPane), - child: secondary, + child: widget.secondary, ), ), ], @@ -100,6 +228,109 @@ class SplitView extends StatelessWidget { } } +// The area around the line between a split view's panes that people drag, or +// step with the arrow keys or a screen reader, to resize them. +class _SplitViewDivider extends StatefulWidget { + const _SplitViewDivider({ + required this.primaryWidth, + required this.minPrimaryWidth, + required this.maxPrimaryWidth, + required this.windowWidth, + required this.step, + required this.onResizeStart, + required this.onResize, + required this.onResizeEnd, + required this.onStep, + }); + + // The width of the area, centered on the line, that responds to a drag. + static const hitWidth = 44.0; + + final double primaryWidth; + final double minPrimaryWidth; + final double maxPrimaryWidth; + final double windowWidth; + final double step; + + final VoidCallback onResizeStart; + + // Called with how far the primary pane grows, negative when it shrinks. + final ValueChanged onResize; + final VoidCallback onResizeEnd; + + // Called with how far one step grows the primary pane, negative when it shrinks. + final ValueChanged onStep; + + @override + State<_SplitViewDivider> createState() => _SplitViewDividerState(); +} + +class _SplitViewDividerState extends State<_SplitViewDivider> { + var _isFocused = false; + + bool get _canGrow => widget.primaryWidth < widget.maxPrimaryWidth; + bool get _canShrink => widget.primaryWidth > widget.minPrimaryWidth; + + String _describe(double primaryWidth) => '${(primaryWidth / widget.windowWidth * 100).round()}%'; + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event, bool isLtr) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + + final direction = switch (event.logicalKey) { + LogicalKeyboardKey.arrowRight => isLtr ? 1 : -1, + LogicalKeyboardKey.arrowLeft => isLtr ? -1 : 1, + _ => null, + }; + if (direction == null) return KeyEventResult.ignored; + + widget.onStep(direction * widget.step); + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final isLtr = Directionality.of(context) == TextDirection.ltr; + final direction = isLtr ? 1.0 : -1.0; + + final grown = math.min(widget.primaryWidth + widget.step, widget.maxPrimaryWidth); + final shrunk = math.max(widget.primaryWidth - widget.step, widget.minPrimaryWidth); + + return Semantics( + slider: true, + label: 'Channel list width', + value: _describe(widget.primaryWidth), + increasedValue: _canGrow ? _describe(grown) : null, + decreasedValue: _canShrink ? _describe(shrunk) : null, + onIncrease: _canGrow ? () => widget.onStep(widget.step) : null, + onDecrease: _canShrink ? () => widget.onStep(-widget.step) : null, + child: Focus( + onFocusChange: (focused) => setState(() => _isFocused = focused), + onKeyEvent: (node, event) => _handleKeyEvent(node, event, isLtr), + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + // Leaves taps near the line to the panes beneath it. + hitTestBehavior: HitTestBehavior.translucent, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => widget.onResizeStart(), + onHorizontalDragUpdate: (details) => widget.onResize(details.primaryDelta! * direction), + onHorizontalDragEnd: (_) => widget.onResizeEnd(), + onHorizontalDragCancel: widget.onResizeEnd, + // Marks the line while it has keyboard focus. + child: Center( + child: SizedBox( + width: 2, + child: ColoredBox(color: _isFocused ? colorScheme.borderFocus : Colors.transparent), + ), + ), + ), + ), + ), + ); + } +} + // The fold or hinge that divides the window into a left and a right side, if any. Rect? _verticalFold(MediaQueryData mediaQuery) { final Size(:width, :height) = mediaQuery.size; From 73d0bd565f655029c71537f6039bd698a24764c6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 01:48:35 +0200 Subject: [PATCH 24/31] fix(samples): close the split view's drawer on back before the channel In the two-pane layout the drawer sits above the channel pane's navigator, which takes the back press first and closed the channel while the drawer stayed open. A BackButtonListener now closes an open drawer first. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index b0625d4529..945a63b93f 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -418,6 +418,9 @@ class _AdaptiveSplitViewState extends State { // Opens and closes the drawer that spans both panes. final _drawerKey = GlobalKey(debugLabel: 'AdaptiveSplitView.drawer'); + // Whether the drawer is open. + var _isDrawerOpen = false; + @override Widget build(BuildContext context) { final sizeClass = WindowSizeClass.of(context); @@ -454,10 +457,19 @@ class _AdaptiveSplitViewState extends State { context: context, removeLeft: !isLtr, removeRight: isLtr, - child: DrawerController( - key: _drawerKey, - alignment: .start, - child: drawer, + // Back closes the drawer here, ahead of the pane navigators below it. + child: BackButtonListener( + onBackButtonPressed: () async { + if (!_isDrawerOpen) return false; + _drawerKey.currentState?.close(); + return true; + }, + child: DrawerController( + key: _drawerKey, + alignment: .start, + drawerCallback: (isOpen) => _isDrawerOpen = isOpen, + child: drawer, + ), ), ), ], From 4ac15ab8112766b92a30da0a8997386a5a2f78dc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 02:36:17 +0200 Subject: [PATCH 25/31] fix(samples): keep the split view's resized width across one-pane layouts The width people dragged the channel list to lived in the split view, which is removed while the window shows one pane, so rotating or folding reset it. AdaptiveSplitView now keeps it and hands it back when the split returns. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 945a63b93f..17a620822a 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -22,6 +22,8 @@ class SplitView extends StatefulWidget { required this.secondary, this.primaryConstraints = const SplitPaneConstraints(minWidth: 280, initialWidth: 320, maxWidth: 420), this.secondaryConstraints = const SplitPaneConstraints(minWidth: 360), + this.resizedPrimaryWidth, + this.onPrimaryWidthChanged, }); /// The leading pane. @@ -39,6 +41,16 @@ class SplitView extends StatefulWidget { /// window too narrow for both. final SplitPaneConstraints secondaryConstraints; + /// The width people already resized [primary] to, which this split view + /// starts at instead of [primaryConstraints]' initial width. + /// + /// Lets the width outlive this split view, for example while the window is + /// too narrow for two panes. + final double? resizedPrimaryWidth; + + /// Called with the new width of [primary] each time people resize it. + final ValueChanged? onPrimaryWidthChanged; + @override State createState() => _SplitViewState(); } @@ -99,7 +111,7 @@ class _SplitViewState extends State { static const _transitionDuration = Duration(milliseconds: 200); // The width people resized [SplitView.primary] to, if they have. - double? _resizedPrimaryWidth; + late double? _resizedPrimaryWidth = widget.resizedPrimaryWidth; // Whether people are dragging the divider, which the panes follow without easing. var _isDragging = false; @@ -128,7 +140,9 @@ class _SplitViewState extends State { void _resizeBy(double delta, double width) { final range = _primaryWidthRange(width); - setState(() => _resizedPrimaryWidth = (_primaryWidth(width) + delta).clamp(range.min, range.max)); + final resized = (_primaryWidth(width) + delta).clamp(range.min, range.max); + setState(() => _resizedPrimaryWidth = resized); + widget.onPrimaryWidthChanged?.call(resized); } @override @@ -418,6 +432,10 @@ class _AdaptiveSplitViewState extends State { // Opens and closes the drawer that spans both panes. final _drawerKey = GlobalKey(debugLabel: 'AdaptiveSplitView.drawer'); + // The width people resized the primary pane to, kept here so it outlives the + // split view while the window shows one pane. + double? _primaryWidth; + // Whether the drawer is open. var _isDrawerOpen = false; @@ -441,6 +459,8 @@ class _AdaptiveSplitViewState extends State { final split = SplitView( primary: KeyedSubtree(key: _primaryKey, child: widget.primary), secondary: widget.secondary, + resizedPrimaryWidth: _primaryWidth, + onPrimaryWidthChanged: (width) => _primaryWidth = width, ); final drawer = widget.drawer; From febffee90d5e71dae951f9dd6fe075088a073286 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 02:36:20 +0200 Subject: [PATCH 26/31] fix(samples): resize the split view by touch only from a handle The divider's 44px drag area sat over both panes and took horizontal swipes from the channel rows and the messages near it. Touch now resizes from a handle at the middle of a 24dp space around the line, which grows while held; a mouse or trackpad can still drag anywhere along it. The panes' background also paints behind the split, so their edges leave no seam. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 135 ++++++++++++++++++------- 1 file changed, 100 insertions(+), 35 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 17a620822a..864a134afc 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart' show objectRuntimeType; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -100,8 +101,12 @@ class SplitPaneConstraints { } class _SplitViewState extends State { - // The width of the line between the panes. - static const _dividerWidth = 1.0; + // The width of the line between the panes where a fold divides them. + static const _lineWidth = 1.0; + + // The width of the space around the line between the panes, which holds the + // resize handle clear of their content. + static const _spacerWidth = 24.0; // How far one keyboard or screen reader step resizes the panes. static const _resizeStep = 20.0; @@ -120,7 +125,7 @@ class _SplitViewState extends State { // wide, where both panes' constraints hold. ({double min, double max}) _primaryWidthRange(double width) { final SplitView(:primaryConstraints, :secondaryConstraints) = widget; - final available = width - _dividerWidth; + final available = width - _spacerWidth; final max = math.min(primaryConstraints.maxWidth, available - secondaryConstraints.minWidth); final min = math.max(primaryConstraints.minWidth, available - secondaryConstraints.maxWidth); return (min: math.min(min, max), max: max); @@ -132,7 +137,7 @@ class _SplitViewState extends State { final range = _primaryWidthRange(width); final initialWidth = switch ((primaryConstraints.initialWidth, secondaryConstraints.initialWidth)) { (final primary?, _) => primary, - (null, final secondary?) => width - _dividerWidth - secondary, + (null, final secondary?) => width - _spacerWidth - secondary, (null, null) => range.min, }; return (_resizedPrimaryWidth ?? initialWidth).clamp(range.min, range.max); @@ -157,7 +162,10 @@ class _SplitViewState extends State { final fold? => isLtr ? fold.left : width - fold.right, null => _primaryWidth(width), }; - final dividerWidth = math.max(fold?.width ?? 0, _dividerWidth); + final dividerWidth = switch (fold) { + final fold? => math.max(fold.width, _lineWidth), + null => _spacerWidth, + }; // Builds the same tree with or without a fold, so the panes ease between the two. return Stack( @@ -168,7 +176,7 @@ class _SplitViewState extends State { PositionedDirectional( top: 0, bottom: 0, - start: primaryWidth + (_dividerWidth - _SplitViewDivider.hitWidth) / 2, + start: primaryWidth + (_spacerWidth - _SplitViewDivider.hitWidth) / 2, width: _SplitViewDivider.hitWidth, child: _SplitViewDivider( primaryWidth: primaryWidth, @@ -217,27 +225,32 @@ class _SplitViewState extends State { final primaryPane = Rect.fromLTWH(isLtr ? 0 : width - primaryPaneWidth, 0, primaryPaneWidth, height); final secondaryPane = Rect.fromLTWH(isLtr ? width - secondaryPaneWidth : 0, 0, secondaryPaneWidth, height); - return Row( - children: [ - SizedBox( - width: primaryPaneWidth, - child: MediaQuery( - data: mediaQuery.removeDisplayFeatures(primaryPane), - child: widget.primary, + // Paints behind the panes too, so no gap shows where their edges round to + // different pixels. + return ColoredBox( + color: colorScheme.backgroundApp, + child: Row( + children: [ + SizedBox( + width: primaryPaneWidth, + child: MediaQuery( + data: mediaQuery.removeDisplayFeatures(primaryPane), + child: widget.primary, + ), ), - ), - VerticalDivider( - width: dividerWidth, - thickness: _dividerWidth, - color: colorScheme.borderSubtle, - ), - Expanded( - child: MediaQuery( - data: mediaQuery.removeDisplayFeatures(secondaryPane), - child: widget.secondary, + VerticalDivider( + width: dividerWidth, + thickness: _lineWidth, + color: colorScheme.borderSubtle, ), - ), - ], + Expanded( + child: MediaQuery( + data: mediaQuery.removeDisplayFeatures(secondaryPane), + child: widget.secondary, + ), + ), + ], + ), ); } } @@ -257,9 +270,16 @@ class _SplitViewDivider extends StatefulWidget { required this.onStep, }); - // The width of the area, centered on the line, that responds to a drag. + // The width of the area, centered on the gap, that responds to a mouse or + // trackpad drag along its full height. static const hitWidth = 44.0; + // The handle at the middle of the gap, the only part that responds to touch, + // so swipes near the gap reach the panes beneath it. It grows while held. + static const _handleSize = Size(4, 48); + static const _heldHandleSize = Size(12, 52); + static const _handleHitSize = Size(hitWidth, 52); + final double primaryWidth; final double minPrimaryWidth; final double maxPrimaryWidth; @@ -281,6 +301,11 @@ class _SplitViewDivider extends StatefulWidget { class _SplitViewDividerState extends State<_SplitViewDivider> { var _isFocused = false; + var _isHeld = false; + + void _setHeld(bool isHeld) { + if (_isHeld != isHeld) setState(() => _isHeld = isHeld); + } bool get _canGrow => widget.primaryWidth < widget.maxPrimaryWidth; bool get _canShrink => widget.primaryWidth > widget.minPrimaryWidth; @@ -307,6 +332,36 @@ class _SplitViewDividerState extends State<_SplitViewDivider> { final isLtr = Directionality.of(context) == TextDirection.ltr; final direction = isLtr ? 1.0 : -1.0; + Widget dragToResize({ + required HitTestBehavior behavior, + Set? supportedDevices, + required Widget child, + }) { + return GestureDetector( + behavior: behavior, + supportedDevices: supportedDevices, + onTapDown: (_) => _setHeld(true), + onTapUp: (_) => _setHeld(false), + onTapCancel: () => _setHeld(false), + onHorizontalDragStart: (_) { + _setHeld(true); + widget.onResizeStart(); + }, + onHorizontalDragUpdate: (details) => widget.onResize(details.primaryDelta! * direction), + onHorizontalDragEnd: (_) { + _setHeld(false); + widget.onResizeEnd(); + }, + onHorizontalDragCancel: () { + _setHeld(false); + widget.onResizeEnd(); + }, + child: child, + ); + } + + final handleSize = _isHeld ? _SplitViewDivider._heldHandleSize : _SplitViewDivider._handleSize; + final grown = math.min(widget.primaryWidth + widget.step, widget.maxPrimaryWidth); final shrunk = math.max(widget.primaryWidth - widget.step, widget.minPrimaryWidth); @@ -325,17 +380,27 @@ class _SplitViewDividerState extends State<_SplitViewDivider> { cursor: SystemMouseCursors.resizeColumn, // Leaves taps near the line to the panes beneath it. hitTestBehavior: HitTestBehavior.translucent, - child: GestureDetector( + child: dragToResize( behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => widget.onResizeStart(), - onHorizontalDragUpdate: (details) => widget.onResize(details.primaryDelta! * direction), - onHorizontalDragEnd: (_) => widget.onResizeEnd(), - onHorizontalDragCancel: widget.onResizeEnd, - // Marks the line while it has keyboard focus. + supportedDevices: const {PointerDeviceKind.mouse, PointerDeviceKind.trackpad}, child: Center( - child: SizedBox( - width: 2, - child: ColoredBox(color: _isFocused ? colorScheme.borderFocus : Colors.transparent), + child: dragToResize( + behavior: HitTestBehavior.opaque, + child: SizedBox.fromSize( + size: _SplitViewDivider._handleHitSize, + child: Center( + child: AnimatedContainer( + duration: kThemeChangeDuration, + width: handleSize.width, + height: handleSize.height, + decoration: ShapeDecoration( + // Marks the handle while it has keyboard focus. + color: _isFocused ? colorScheme.borderFocus : colorScheme.accentNeutral, + shape: RoundedSuperellipseBorder(borderRadius: .all(context.streamRadius.max)), + ), + ), + ), + ), ), ), ), From dec3f8ea8904d1446304f92d626e5d0a68cffa9e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 02:41:26 +0200 Subject: [PATCH 27/31] docs(samples): describe the split view's handle and resized width accurately Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/widgets/split_view.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart index 864a134afc..f12db86938 100644 --- a/sample_app/lib/widgets/split_view.dart +++ b/sample_app/lib/widgets/split_view.dart @@ -10,7 +10,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../utils/window_size_class.dart'; /// A layout that places [primary] and [secondary] side by side, separated by a -/// divider that people can drag to resize the panes. +/// divider with a handle that people drag to resize the panes. /// /// When a fold or hinge divides the window into a left and a right side, each /// pane fills one side, easing into place as the fold appears or disappears, @@ -42,11 +42,11 @@ class SplitView extends StatefulWidget { /// window too narrow for both. final SplitPaneConstraints secondaryConstraints; - /// The width people already resized [primary] to, which this split view - /// starts at instead of [primaryConstraints]' initial width. + /// The width people already resized [primary] to. /// - /// Lets the width outlive this split view, for example while the window is - /// too narrow for two panes. + /// When set, the split view starts at this width instead of either pane's + /// [SplitPaneConstraints.initialWidth], so the width can outlive this split + /// view, for example while the window is too narrow for two panes. final double? resizedPrimaryWidth; /// Called with the new width of [primary] each time people resize it. From d9d02dbf0ee53c07e9df51d060c62c422fe9c078 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 02:58:45 +0200 Subject: [PATCH 28/31] Revert "fix(samples): update the Crashlytics Gradle plugin to 3.0.8 for Gradle 9" This reverts commit ae99d38db1c6e92b543e6248b5ddab0086a31091. --- sample_app/android/settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_app/android/settings.gradle b/sample_app/android/settings.gradle index 30423e2804..1217943547 100644 --- a/sample_app/android/settings.gradle +++ b/sample_app/android/settings.gradle @@ -21,7 +21,7 @@ plugins { id "com.android.application" version "8.12.2" apply false // START: FlutterFire Configuration id "com.google.gms.google-services" version "4.4.3" apply false - id "com.google.firebase.crashlytics" version "3.0.8" apply false + id "com.google.firebase.crashlytics" version "2.8.1" apply false // END: FlutterFire Configuration id "org.jetbrains.kotlin.android" version "2.2.20" apply false } From 8ff68d056016c2443f4b6f19e3d675db6358510b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 02:58:45 +0200 Subject: [PATCH 29/31] Revert "chore(samples): update the Android Gradle wrapper to 9.3.1" This reverts commit 20c54ef0e6b387f4e37e3b911edcbd726a0c2cd3. --- sample_app/android/gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_app/android/gradle/wrapper/gradle-wrapper.properties b/sample_app/android/gradle/wrapper/gradle-wrapper.properties index a8f1d162d5..828b334284 100644 --- a/sample_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/sample_app/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip From cc1bc923263b9e8f6031c5e63fa92697066b8815 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 13:08:25 +0200 Subject: [PATCH 30/31] fix(samples): open a channel from a notification or DM with goNamed The channel page has a fixed key, so pushing it could stack a duplicate page or leave one under the chat pane with no way back. Co-Authored-By: Claude Opus 5.5 (1M context) --- sample_app/lib/app.dart | 2 +- sample_app/lib/widgets/all_members_sheet.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index 92ba5664c8..c098b5173c 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -93,7 +93,7 @@ class _StreamChatSampleAppState extends State return; } debugPrint('[notif-tap] navigating to channel=$cid'); - GoRouter.of(ctx).pushNamed( + GoRouter.of(ctx).goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart index 65694af838..81b5e68589 100644 --- a/sample_app/lib/widgets/all_members_sheet.dart +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -441,7 +441,7 @@ Future _openDirectChannel(BuildContext context, User user) async { if (StreamSheetRoute.hasParentSheet(context)) { StreamSheetRoute.popSheet(context); } - router.pushNamed( + router.goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); From 40bcf16b1d2f3e1d31ebbebd9606997f53001728 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 13:08:32 +0200 Subject: [PATCH 31/31] fix(ui): drop a message action picked after its message leaves the tree Co-Authored-By: Claude Opus 5.5 (1M context) --- .../message_widget/stream_message_item.dart | 6 ++--- ...tream_message_item_actions_modal_test.dart | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 1fd3fb70f5..5c6a5d18b3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -836,7 +836,7 @@ class DefaultStreamMessageItem extends StatelessWidget { initialReactionType: initialReaction?.type, ); - if (action is! MessageAction) return; + if (action is! MessageAction || !context.mounted) return; return _onActionTap(context, channel, action).ignore(); } @@ -903,7 +903,7 @@ class DefaultStreamMessageItem extends StatelessWidget { ), ); - if (action is! MessageAction) return; + if (action is! MessageAction || !context.mounted) return; return _onActionTap(context, channel, action).ignore(); } @@ -986,7 +986,7 @@ class DefaultStreamMessageItem extends StatelessWidget { ), ); - if (action is! MessageAction) return; + if (action is! MessageAction || !context.mounted) return; return _onActionTap(context, channel, action).ignore(); } diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart index 01d4cc62e4..1785b03c24 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart @@ -20,6 +20,23 @@ void main() { expect(tester.takeException(), isNull); expect(find.byKey(const Key('MessageItem')), findsOneWidget); }); + + testWidgets('StreamMessageItem drops a modal action picked after its message leaves the tree', (tester) async { + final client = MockClient(); + final channel = _mockChannel(client); + final threadTaps = []; + void onThreadTap(Message parent, Message? _) => threadTaps.add(parent); + + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: true, onThreadTap: onThreadTap)); + await tester.pumpAndSettle(); + await _longPress(tester, find.byType(StreamMessageItem)); + + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: false, onThreadTap: onThreadTap)); + await tester.tap(find.text('Thread Reply')); + await tester.pumpAndSettle(); + + expect(threadTaps, isEmpty); + }); } MockChannel _mockChannel(MockClient client) { @@ -37,7 +54,12 @@ MockChannel _mockChannel(MockClient client) { return channel; } -Widget _app({required MockClient client, required MockChannel channel, required bool showMessage}) { +Widget _app({ + required MockClient client, + required MockChannel channel, + required bool showMessage, + void Function(Message, Message?)? onThreadTap, +}) { final message = Message( id: 'test-message', text: 'Long press me', @@ -51,7 +73,7 @@ Widget _app({required MockClient client, required MockChannel channel, required home: StreamChannel( channel: channel, child: Scaffold( - body: showMessage ? StreamMessageItem(message: message) : const SizedBox.shrink(), + body: showMessage ? StreamMessageItem(message: message, onThreadTap: onThreadTap) : const SizedBox.shrink(), ), ), );