Repository navigation
feat(ui, samples): two-pane channel list and channel on wide windows and foldables - #3023
Conversation
…lar 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…amThreadPage 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…aves 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…he 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) <noreply@anthropic.com>
…pears 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) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## sahil/flu-823-iphone-duo-polish-viewer-fit-thumbnails-autocomplete-height #3023 +/- ##
============================================================================================================
Coverage ? 79.68%
============================================================================================================
Files ? 453
Lines ? 29491
Branches ? 0
============================================================================================================
Hits ? 23501
Misses ? 5990
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…er 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AP6bV4L7FQYjHoHZeomLxr
…utocomplete-height' into sahil/flu-826-sample-app-two-pane-channel-list-and-channel-on-the-inner
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AP6bV4L7FQYjHoHZeomLxr
renefloor
left a comment
There was a problem hiding this comment.
Reviewed this layer on its own. The SDK changes are small, non-breaking, and each has a test that fails without it 👍. The split view design is nice: a single ShellRoute navigator in both layouts, with only the list moved by a GlobalKey, so a channel keeps its draft and scroll position across fold, unfold and rotation. The divider is also accessible via keyboard and screen reader.
Comments are all on the sample app; one is a real bug (Android back with the drawer open).
| context: context, | ||
| removeLeft: !isLtr, | ||
| removeRight: isLtr, | ||
| child: DrawerController( |
There was a problem hiding this comment.
In the split layout this DrawerController sits above the ShellRoute's nested navigator, so when it opens, its LocalHistoryEntry is registered on the shell page in the root navigator. go_router's popRoute → _findCurrentNavigator picks the deepest navigator that canPop(), which is the nested one whenever a channel is open.
So: tablet in landscape, channel open, tap the avatar to open the drawer, press system back. I'd expect the channel to close to the placeholder while the drawer stays open. (Inferred from reading go_router's source; I haven't reproduced it on a device.) One-pane mode is fine because the Scaffold drawer is inside the nested navigator.
Fix: a PopScope(canPop: false, …) or BackButtonListener that closes the drawer while it's open.
There was a problem hiding this comment.
Good catch, reproduced on a Pixel Fold emulator. go_router sends back to the innermost navigator that can pop, so the channel pane's navigator closed the channel before the drawer's history entry on the shell page was ever asked (still the case in go_router 18). PopScope registers on that same shell page, so it wouldn't help. Fixed in 73d0bd5 with a BackButtonListener around the drawer that closes it first: it hears the back press before the router picks a navigator.
| static const _transitionDuration = Duration(milliseconds: 200); | ||
|
|
||
| // The width people resized [SplitView.primary] to, if they have. | ||
| double? _resizedPrimaryWidth; |
There was a problem hiding this comment.
_resizedPrimaryWidth lives in _SplitViewState, which is disposed when AdaptiveSplitView drops to one pane. So drag the list to 420, rotate to portrait and back, and it's 320 again. Could the width live in _AdaptiveSplitViewState and be passed into SplitView (initial width plus a changed callback)?
There was a problem hiding this comment.
Fixed in 4ac15ab: AdaptiveSplitView now keeps the resized width, since it outlives the one-pane layout, and hands it back to SplitView through resizedPrimaryWidth / onPrimaryWidthChanged. Checked on the Fold by dragging, folding to one pane and unfolding.
| }); | ||
|
|
||
| // The width of the area, centered on the line, that responds to a drag. | ||
| static const hitWidth = 44.0; |
There was a problem hiding this comment.
The 44px hit area is a translucent GestureDetector on top of both panes, so its horizontal drag recognizer wins over the channel tiles' Slidable (swipes starting in the rightmost 22px of the list) and over swipe-to-reply near the channel pane's leading edge. Maybe ~16px, or use the wide hit area only for mouse input?
There was a problem hiding this comment.
Reproduced both cases on a Pixel Fold. Fixed in febffee by following Material 3's pane drag handle: touch now resizes only from a 4×48 handle in the middle of a 24dp space around the line (it grows to 12×52 while held), so row swipes and swipe-to-reply next to the line reach the panes. A mouse or trackpad can still drag anywhere along it.
|
|
||
| /// The widths a [SplitView] pane starts at and can be resized within. | ||
| @immutable | ||
| class SplitPaneConstraints { |
There was a problem hiding this comment.
nit: SplitPaneConstraints supports initialWidth/maxWidth on the secondary pane plus ==/hashCode/toString, but only secondaryConstraints.minWidth is ever used. For a sample, plain primaryMin/primaryInitial/primaryMax + secondaryMin parameters would cut ~50 lines and the foundation import.
There was a problem hiding this comment.
Keeping it. SplitView is written as a reusable widget, and people copy sample widgets into their own apps. The constraints are symmetric, so a caller can size either pane: a fixed-width detail pane with secondaryConstraints.initialWidth, or a capped one with maxWidth. The sample just happens to use only the primary side. Grouping the min, initial and max widths per pane also keeps those values, and the asserts that check them, together.
| 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 |
There was a problem hiding this comment.
The Gradle 8.14 → 9.3 bump (and Crashlytics 2.8 → 3.0 in settings.gradle) is a toolchain change for every contributor and unrelated to the two-pane layout. Could it go in its own PR so it can be reverted independently?
| FocusScope.of(context).unfocus(); | ||
| } | ||
| return true; | ||
| return false; |
There was a problem hiding this comment.
return true → false means scroll notifications now bubble up past this listener. What's the reason? If it's intentional, a short comment would help; otherwise I'd revert it.
There was a problem hiding this comment.
It was intentional, in 1b1fbbc: the listener only dismisses the keyboard, but returning true swallowed every scroll notification, so the Material the selected tile paints on never heard about the scroll and the highlight lagged behind its row. 81baeb5 later added a transparent Material inside the list, which now gets the notifications first, so the highlight no longer depends on this. I've kept false anyway, since the listener only observes the scroll and shouldn't hide it from anything above.
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) <noreply@anthropic.com>
…outs 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…urately Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| // nothing carries over from the last one: neither its state, such | ||
| // as the composer's text, nor the menus open on it. | ||
| return AdaptiveSplitViewPage<void>( | ||
| key: ValueKey((channel.cid, parentMessage?.id)), |
There was a problem hiding this comment.
This fixed key breaks the two call sites that still open the channel with pushNamed. They were not switched to goNamed with the others:
sample_app/lib/app.dart:96, tapping a push notificationsample_app/lib/widgets/all_members_sheet.dart:444, opening a DM from the members sheet
Before, each push got a unique state.pageKey. Now:
- Same channel already open: the shell navigator ends up with two pages keyed
(cid, null), and in debug builds Navigator's duplicate-key assertion fails. - Split view: the pushed page stacks inside the chat pane with
automaticallyImplyLeading: false. There is no back button, so the page underneath stays alive under it and only system back or tapping the list gets you out.
Suggest switching both to goNamed, like channel_list.dart and the other call sites this PR changed.
There was a problem hiding this comment.
Switched both to goNamed in cc1bc92. No pushNamed to the channel page is left.
| ); | ||
|
|
||
| // Read up front: the modal can outlive this message, and with it `context`. | ||
| final configuration = StreamChatConfiguration.of(context); |
There was a problem hiding this comment.
This fixes the modal's builder, but the action still runs on context after the modal closes. Line 990 calls _onActionTap(context, channel, action).ignore() after await showStreamDialog(...), with no context.mounted check, and that is the context this comment says can be gone.
If the message leaves the tree while the modal is open:
- React:
_selectReactioncallsStreamChatConfiguration.of(context)again (line 1089), the lookup this PR moved up front. - Delete / Flag:
_maybeDeleteMessageand_maybeFlagMessagecallshowStreamDialog(context: context).
These throw inside the async _onActionTap, and .ignore() discards the error. The modal closes, nothing happens, and nothing is logged.
Suggested fix:
- Pass the
configurationalready read here into_selectReaction. - Capture the navigator (and translations) before the
await, or bail out or log on!context.mounted. - Extend
stream_message_item_actions_modal_test.dartto tap an action (for example a reaction) after the message is removed.
There was a problem hiding this comment.
Went with bailing out on !context.mounted in 40bcf16, after the actions modal, the bounced-message dialog and the reactions sheet. That matches Flutter's PopupMenuButton, which drops the selection when the button is no longer mounted.
The item only leaves the tree while its modal is open when the message is removed from the channel, so the action is dropped rather than run against a message that's gone. The new test taps Thread Reply after the message is removed and expects onThreadTap not to be called. Thread Reply is one of the actions that never touch context, so the test fails without the check.
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part of FLU-826. Stacked on #3021 (FLU-823); review only the commits on top of it.
On a wide window the sample app now shows the channel list and the open channel side by side, and it keeps one stack on phones.
Sample app
AdaptiveSplitView(inwidgets/split_view.dart) shows two panes when the window is at least expanded wide (840) and medium tall (480), using Material's width and height classes (utils/window_size_class.dart). That covers iPhone Duo's inner display in landscape, the Pixel Fold in either orientation, and tablets in landscape. On narrower windows the chat routes stay one stack.ShellRoutenavigator in both layouts, so an open channel keeps its draft and scroll position across a fold, unfold or rotation. Only the list moves between layouts, by aGlobalKey.MediaQuery.displayFeatures) splits the panes at the fold. Each pane gets its own insets and display features viaremoveDisplayFeatures, and the divider eases into place.SplitPaneConstraints(minWidth:, initialWidth:, maxWidth:), SwiftUI'snavigationSplitViewColumnWidth(min:ideal:max:)under Flutter's names. The list starts at 320 (UIKit's default primary column), within 280 to 420, and the chat keeps at least 360. While a fold divides the window, the divider can't be dragged.UISplitViewControllerdoes when regular. Drill-ins such as chat info still push within the pane.SDK (
stream_chat_flutter)automaticallyImplyLeadingtoStreamChannelPageandStreamThreadPage.selectedparameter andcopyWithtoStreamThreadListTile.StreamThreadListTileoverflowing when narrow; the timestamp now truncates.StreamMessageItem's actions modal throwing when its message left the tree while it was open.Verified
🤖 Generated with Claude Code
Summary by CodeRabbit
copyWithsupport for thread and draft tiles.