diff --git a/CHANGELOG.md b/CHANGELOG.md index ce42100..146891d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,9 +64,20 @@ CONTENT RULES (never change) First release — adaptive layout widgets that morph between phone and desktop forms. -- **Widgets:** `ListDetailLayout` (list + selected detail) and `AdaptiveSplit` (two always-present panes) switch between slide-over (compact) and side-by-side (expanded) at a configurable breakpoint. +- **Widgets:** `ListDetailLayout` (list + selected detail) and `SplitLayout` (two always-present panes) switch between slide-over (compact) and side-by-side (expanded) at a configurable breakpoint. - **State preservation:** pane widget instances survive the compact ↔ expanded morph — drafts, scroll positions, and in-flight animations carry across a window resize. +- **Adaptive modal:** `showAdaptiveModal` presents a real Material dialog on expanded widths and a real Material bottom sheet on compact — and swaps between the two real routes on resize with a container transform that carries the live content, preserving state and the awaited result. - **Overlay mode:** the compact detail can render in the Navigator's overlay, covering bottom navs and tab bars; inactive kept-alive tabs suppress their overlays automatically. -- **Divider:** draggable with min/max clamps, optional anchor snap points with a settle animation, and ratio or fixed-pixel resize modes; `HandleDivider` and `MaterialDivider` ship as ready-made visuals. +- **Route mode:** `CompactDetailMode.route` hosts the compact detail in a real page route — the app's `PageTransitionsTheme` (platform transitions, predictive back, edge swipes) applies natively, and the detail element still reparents into the side-by-side pane on resize. +- **Accessibility:** in inline and overlay modes the open detail scopes as a route for screen readers, covered content leaves the semantics tree, and `DismissIntent` (Escape) dismisses when focus is inside the detail — route parity without a route. +- **Crossing motion:** breakpoint crossings animate in both directions — including the empty placeholder pane, which reveals and retreats at the end edge. With an open detail — into compact the detail grows out of its pane (inline/overlay) or plays the route's real entrance (route mode); into expanded the list slides in beside the full-width detail, laid out at its final width so content never reflows (`ExpandedEntryStyle.resize` opts into live reflow). Pane geometry keeps tracking window drags without motion. +- **Empty-pane behaviors:** `ExpandedEmptyBehavior.listOnly` gives the list the full expanded width until a selection reveals the detail pane from the end edge (the Material "supporting pane" shape); the default keeps the persistent placeholder pane. The auto-select-first school is documented as an app-side controller recipe. +- **Divider memory:** a dragged divider position survives compact spells and rebuilds — `PaneConfig` and `PaneAnchor` compare by value, so configs constructed inline in `build` never reset the width model. `PaneWidthMemory.resetOnReentry` opts into a fresh divider on every return to expanded. +- **Native passthroughs:** `ModalConfig` forwards Flutter's route parameters under Flutter's own names (barrier label, sheet constraints, max-height ratio, anchor points, focus/traversal behavior, entrance `AnimationStyle`s); visual styling stays on `DialogThemeData`/`BottomSheetThemeData`. Pane snap-settle duration/curve and the divider hit-zone width are `PaneConfig` knobs. +- **Divider:** draggable with min/max clamps, optional anchor snap points with a settle animation, and ratio or fixed-pixel resize modes; `HandleDivider` and `MaterialDivider` ship as ready-made visuals. The full interaction contract rides on `DividerState` (dragging / settling / at-limit / collapsed / focused). +- **Snap-collapse:** force the divider past a pane's minimum and it snaps shut (VS Code mechanics: half-minimum threshold, remembered width, spring-back short of it). `PaneCollapsible` opts in per side; `collapsedSize` keeps an icon rail or hides fully; collapsed content stays laid out at its minimum inside a clip. `HandleDivider` parks as a pull tab. Directional API holds for `SplitLayout`'s end-positioned primary. +- **Divider keyboard + screen readers:** the WAI-ARIA window-splitter pattern — Tab focus, arrows resize, Enter collapses/restores, Home/End jump to the limits, double-click resets to the default width; screen readers see an adjustable element announcing the pane's share. +- **Icon-rail slots:** `collapsedListBuilder` / `collapsedDetailBuilder` (and `SplitLayout`'s primary/secondary equivalents) give a collapsed pane purpose-built content laid out at the real `collapsedSize` — the VS Code activity-bar shape — while the pane parks offstage with its state alive. Both panes reparent via GlobalKey, so collapse, restore, and mode switches move the live element instead of rebuilding it. +- **PaneScope:** descendants of either pane read the collapse state (including `collapsedSize`, so a pane can tell a fully-hidden neighbor from a visible rail) and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. - **Platforms:** pure Flutter, no platform code — every platform. diff --git a/CHANGELOG.pre.md b/CHANGELOG.pre.md index 95368d9..32f9f38 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -64,9 +64,15 @@ CONTENT RULES (never change) First prerelease — adaptive layout widgets that morph between phone and desktop forms. -- **Widgets:** `ListDetailLayout` (list + selected detail) and `AdaptiveSplit` (two always-present panes) switch between slide-over (compact) and side-by-side (expanded) at a configurable breakpoint. +- **Widgets:** `ListDetailLayout` (list + selected detail) and `SplitLayout` (two always-present panes) switch between slide-over (compact) and side-by-side (expanded) at a configurable breakpoint. - **State preservation:** pane widget instances survive the compact ↔ expanded morph — drafts, scroll positions, and in-flight animations carry across a window resize. +- **Adaptive modal:** `showAdaptiveModal` presents a real Material dialog on expanded widths and a real Material bottom sheet on compact — and swaps between the two real routes on resize with a container transform that carries the live content, preserving state and the awaited result. - **Overlay mode:** the compact detail can render in the Navigator's overlay, covering bottom navs and tab bars; inactive kept-alive tabs suppress their overlays automatically. -- **Divider:** draggable with min/max clamps, optional anchor snap points with a settle animation, and ratio or fixed-pixel resize modes; `HandleDivider` and `MaterialDivider` ship as ready-made visuals. +- **Route mode:** `CompactDetailMode.route` hosts the compact detail in a real page route — the app's `PageTransitionsTheme` (platform transitions, predictive back, edge swipes) applies natively, and the detail element still reparents into the side-by-side pane on resize. +- **Divider:** draggable with min/max clamps, optional anchor snap points with a settle animation, and ratio or fixed-pixel resize modes; `HandleDivider` and `MaterialDivider` ship as ready-made visuals. The full interaction contract rides on `DividerState` (dragging / settling / at-limit / collapsed / focused). +- **Snap-collapse:** force the divider past a pane's minimum and it snaps shut (VS Code mechanics: half-minimum threshold, remembered width, spring-back short of it). `PaneCollapsible` opts in per side; `collapsedSize` keeps an icon rail or hides fully; collapsed content stays laid out at its minimum inside a clip. `HandleDivider` parks as a pull tab. Directional API holds for `SplitLayout`'s end-positioned primary. +- **Divider keyboard + screen readers:** the WAI-ARIA window-splitter pattern — Tab focus, arrows resize, Enter collapses/restores, Home/End jump to the limits, double-click resets to the default width; screen readers see an adjustable element announcing the pane's share. +- **Icon-rail slots:** `collapsedListBuilder` / `collapsedDetailBuilder` (and `SplitLayout`'s primary/secondary equivalents) give a collapsed pane purpose-built content laid out at the real `collapsedSize` — the VS Code activity-bar shape — while the pane parks offstage with its state alive. Both panes reparent via GlobalKey, so collapse, restore, and mode switches move the live element instead of rebuilding it. +- **PaneScope:** descendants of either pane read the collapse state (including `collapsedSize`, so a pane can tell a fully-hidden neighbor from a visible rail) and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. - **Platforms:** pure Flutter, no platform code — every platform. diff --git a/README.md b/README.md index d4ecf51..f91724f 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,11 @@ license: MIT

-Layout widgets that morph between phone and tablet / desktop forms. On a phone, the detail pane slides over the list and covers the bottom nav; on a wide window, the two panes sit side by side with a draggable divider. When the user resizes a desktop window across the breakpoint, the panes rearrange — and the widgets inside them keep their state. A half-typed message survives the resize, because the detail is moved in the tree, not rebuilt. +Layout widgets that morph between phone and desktop forms. On a phone, the detail slides over the list; on a wide window, the panes sit side by side with a draggable divider. Resize across the breakpoint and the panes rearrange — while the widgets inside them **keep their state**: a half-typed message survives the resize, because the pane is moved in the tree, not rebuilt. Router-agnostic and state-management-agnostic; the integration surface is a plain `ChangeNotifier` controller, or just an awaited future. -Two widgets carry the package: `ListDetailLayout` (list + selected detail, the messaging-app shape) and `AdaptiveSplit` (two always-present panes, the player shape). Both are router-agnostic and state-management-agnostic — a plain `ChangeNotifier` controller is the whole integration surface. +> **Why a package? Isn't this just a `LayoutBuilder` with two branches?** A branch REBUILDS: the moment the window crosses the breakpoint, your detail widget is torn down and recreated — cursor position, scroll offset, and running animations reset. The standard adaptive components (Compose's `ListDetailPaneScaffold`, the typical push-a-detail-page flow) share the same weakness. This package moves the live widget *instances* between arrangements instead, in every mode — including a real-route mode where the detail reparents through genuine page navigation. -> **The guarantee that makes this package exist:** pane widget *instances* survive the compact ↔ expanded morph. The standard adaptive components (Compose's `ListDetailPaneScaffold`, route-based detail pages) rebuild the detail from saved state instead — cursor position, scroll offset, and in-flight animations reset. Here they don't. - -> **Status:** 0.x. The API can change between minor versions until `1.0.0` — pre-1.0, the minor is the breaking axis, so pin `^0.N.0` and read the changelog on minor bumps. +> **Status:** 0.x. Pre-1.0 the minor version is the breaking axis — pin `^0.N.0` and read the changelog on minor bumps. > like it? a [⭐ star](https://github.com/whuppi/adaptive_layouts) or [👍 like](https://pub.dev/packages/adaptive_layouts) is the entire marketing budget. [Bugs & features →](https://github.com/whuppi/adaptive_layouts/issues) @@ -25,18 +23,20 @@ Two widgets carry the package: `ListDetailLayout` (list + selected detail, the m - [Install](#install) - [Quick start](#quick-start) +- [The morph](#the-morph) - [Usage](#usage) - [The controller](#the-controller) - - [Covering the bottom nav (overlay mode)](#covering-the-bottom-nav-overlay-mode) + - [Compact detail modes](#compact-detail-modes) + - [The empty detail pane](#the-empty-detail-pane) + - [Two peer panes — SplitLayout](#two-peer-panes--splitlayout) - [Sizing the panes](#sizing-the-panes) - - [Two panes without a selection](#two-panes-without-a-selection) + - [Snap-collapse and icon rails](#snap-collapse-and-icon-rails) + - [The divider's keyboard and screen readers](#the-dividers-keyboard-and-screen-readers) + - [A modal that swaps between dialog and sheet](#a-modal-that-swaps-between-dialog-and-sheet) - [One breakpoint for the whole app](#one-breakpoint-for-the-whole-app) -- [Why widget-level morphing](#why-widget-level-morphing) -- [The example app](#the-example-app) - [Platform support](#platform-support) - [Not in the box](#not-in-the-box) - [Docs](#docs) -- [License](#license) @@ -49,32 +49,71 @@ dependencies: adaptive_layouts: ``` -Pure Flutter — no native code, no assets, no setup, any platform. +Nothing else to do, on any platform. Pure Flutter: no native code, no assets, no setup. --- ## Quick start -Two builders, and the layout handles the rest — breakpoint switching, slide animation, swipe-to-dismiss, back-gesture handling, state preservation: +Two builders, and the layout handles the rest — breakpoint switching, slide animation, swipe-to-dismiss, back gestures, state preservation: ```dart import 'package:adaptive_layouts/adaptive_layouts.dart'; ListDetailLayout( listBuilder: (context, selectedId, onSelect) => ChatList( - selectedId: selectedId, // highlight the open row on wide layouts + selectedId: selectedId, // highlight the open row on wide windows onTap: onSelect, // tapping a row opens its detail ), detailBuilder: (context, id, mode, onDismiss) => ChatScreen( id: id, - showBackButton: mode == DetailLayoutMode.stacked, // phone: back arrow + showBackButton: mode == DetailLayoutMode.stacked, // narrow: back arrow showCloseButton: mode == DetailLayoutMode.sideBySide, // wide: close X onBack: onDismiss, ), ) ``` -Below 720px the detail slides over the list (`DetailLayoutMode.stacked`); at 720px and above the panes share the width (`DetailLayoutMode.sideBySide`). The `mode` argument tells your detail which affordance to show — everything else is the same widget. +Below the breakpoint (default 720, [configurable app-wide](#one-breakpoint-for-the-whole-app)) the detail slides over the list; at or above it, the panes share the width. Narrow is called **compact** in this package; wide is **expanded**. + +Three widgets, one per situation: + +```dart +// 1. A list that DRIVES a detail: chats, tickets, inbox. +ListDetailLayout(listBuilder: ..., detailBuilder: ...) + +// 2. Two panes that are PEERS — nobody selects anybody: +// player + queue, editor + preview. +SplitLayout(primaryBuilder: ..., secondaryBuilder: ...) + +// 3. A one-off surface the user summons: forms, pickers, confirmations. +// Real dialog when expanded, real bottom sheet when compact. +await showAdaptiveModal(context: context, builder: ...) +``` + +--- + +## The morph + +Every widget here makes the same promise when the window crosses the breakpoint: the panes rearrange, the *content* carries over. Four rules hold everywhere: + +- **State survives.** Pane and modal content mounts under stable keys; a breakpoint crossing reparents the live element instead of rebuilding it. Text fields, scroll positions, and running animations carry across. +- **Crossings animate; drags track.** The arrangement flip (fold/unfold, rotation, window crossing the threshold) animates in both directions — including the empty placeholder pane — with one exception, two bullets down. Pane *geometry* tracks a window drag without added motion, because a lagging pane would fight your hand. +- **Arriving panes don't reflow.** Entering expanded, the list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer live reflow? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. +- **Parked panes don't dance.** A collapsed pane arrives already collapsed: its rail docks at the parked width from the first expanded frame. The collapse animation belongs to the moment the user collapsed — a window resize isn't that moment. + +
+🧩 why the foundation is widget-level morphing, not routes + +
+ +The obvious way to build an adaptive list-detail is route-based: on compact, push the detail as a page. It gets platform behaviors free — but a pushed page rebuilds its content from state, so the naive version loses cursor position, scroll offset, and running animations on every breakpoint crossing. + +This package's foundation is one widget owning both arrangements instead. The detail mounts under a stable `GlobalKey`, and the morph reparents the same element — Flutter's documented mechanism for moving a widget without losing its state. In `inline` and `overlay` modes the cost of that choice is owned directly: the slide animation, swipe-to-dismiss, and back handling are implemented here rather than inherited from the Navigator. + +`CompactDetailMode.route` then earns the platform behaviors back **on top of** that foundation: the pushed page hosts the same keyed element, and the layout choreographs the handoff so the key has exactly one owner per frame. A route-first design can't do this dance, because nobody outside the routes owns the element's lifecycle; a widget-first design can, because the layout always does. That layering — widget-level state engine underneath, real navigation opted in on top — is the package's identity. + +
--- @@ -87,40 +126,92 @@ Below 720px the detail slides over the list (`DetailLayoutMode.stacked`); at 720 ```dart final controller = ListDetailController(); -ListDetailLayout( - controller: controller, - listBuilder: ..., - detailBuilder: ..., -) - controller.select('chat-42'); // open programmatically (deep link, router) controller.dismiss(); // close with the exit animation -``` -Two reads with different jobs: - -```dart -controller.hasSelection; // data state — flips the instant dismiss() runs -controller.isDetailVisible; // visual state — stays true until the exit - // animation finishes +controller.hasSelection; // data state — flips the instant dismiss() runs +controller.isDetailVisible; // visual state — stays true until the exit + // animation finishes (app-shell timing) ``` -`isDetailVisible` is what an app shell wants for timing UI around the detail — it answers "is the pane still on screen", not "is something selected". URL sync sits on top of these three members; the [example app](example/) ships a complete `ListDetailRouter` for auto_route as reference wiring. +URL sync sits on top of these members; the [example app](example/) ships a complete `ListDetailRouter` for auto_route as reference wiring. -### Covering the bottom nav (overlay mode) +### Compact detail modes -By default the compact detail renders inline, inside the layout's own bounds — it cannot cover a bottom nav or tab bar that sits outside it. Overlay mode can: +`compactDetailMode` decides what the open detail is on compact widths: ```dart ListDetailLayout( - compactDetailMode: CompactDetailMode.overlay, + compactDetailMode: CompactDetailMode.overlay, // or .inline / .route ... ) ``` -The detail now renders in the Navigator's overlay, sliding over everything the Navigator covers — bottom nav, tab bars — while staying in the widget tree with its state intact. `CompactConfig(useRootOverlay: true)` targets the root overlay instead, covering ancestors above the Navigator too. +- **`inline`** (default) — the detail renders inside the layout's own bounds. Surrounding chrome (bottom nav, tab bar) stays visible. +- **`overlay`** — the detail renders in the Navigator's overlay and covers everything the Navigator covers: bottom nav, tab bars. Built for kept-alive tab navigation — inactive tabs suppress their overlays automatically. `CompactConfig(useRootOverlay: true)` covers ancestors above the Navigator too. +- **`route`** — selecting pushes a genuine page route holding the detail. Platform transitions, **predictive back**, Cupertino edge swipes — all from your app's `PageTransitionsTheme`, evolving with Flutter. Resize across the breakpoint and the same detail element reparents between the route and the side-by-side pane. Hidden kept-alive tabs remove their route (keeping the selection and the detail's state) and restore it instantly when shown again. + +Rule of thumb: `inline` when the chrome should stay, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page. -Overlay mode is built for kept-alive tab navigation: when several overlay-mode layouts are mounted at once (one per tab) and only one tab is painted, the inactive tabs' overlays suppress themselves automatically — hiding is immediate, reappearing takes one frame. Works with `IndexedStack`, `Offstage`, tab routers, or any parent that stops painting inactive children. +
+🧰 the full mode comparison table + +
+ +| | `inline` | `overlay` | `route` | +|---|---|---|---| +| Covers bottom nav / tab bars | no | yes | yes — it's a page | +| Dismiss gesture | swipe anywhere on the detail | swipe anywhere on the detail | the platform's own (predictive back on Android, edge swipe on iOS/macOS) | +| Back animation | package slide | package slide | your `PageTransitionsTheme` | +| Android predictive-back preview | lost (back is intercepted) | lost | full | +| Page's snackbars / FABs while open | visible | hidden behind the detail | re-home into the detail's `Scaffold`, like normal navigation | +| Screen readers see covered content | no — excluded while open | no — blocked while open | no — it's a page | +| Escape dismisses (desktop) | yes, when focus is in the detail | yes, when focus is in the detail | yes — the route's own `DismissIntent` | +| Crossing into compact, detail open | detail grows out of its pane | detail grows out of its pane | the route's real entrance plays | +| Crossing into expanded, detail open | list slides in beside the detail | list slides in beside the detail | list slides in beside the detail | +| Hero discipline (`heroTag`s) | not needed | not needed | needed — explicit `heroTag` per FAB when several coexist under one page | + +The per-value doc comments on `CompactDetailMode` carry the full contracts. + +
+ +### The empty detail pane + +When nothing is selected at expanded width, list-detail apps follow one of three established patterns; the package supports all three: + +1. **Placeholder** (default) — the pane stays reserved and shows `emptyStateBuilder`. The Apple Mail reading-pane pattern. + + ```dart + emptyStateBuilder: IconMessageEmpty.of( + icon: Icons.chat_bubble_outline, + message: 'Select a conversation', + ) + ``` + +2. **Auto-select** — never show emptiness: when the list loads with no selection, call `controller.select(items.first.id)`. The Notes / Slack pattern. This is a data decision, so it stays app-side — the layout doesn't know your data, and auto-selecting can be wrong (empty lists, destructive contexts, deep links). The example ships the recipe behind a ⚙ toggle. + +3. **On-demand pane** — the list owns the full width until a selection reveals the pane from the end edge; dismissing hands the width back. Material's "supporting pane" pattern: + + ```dart + expandedEmptyBehavior: ExpandedEmptyBehavior.listOnly + ``` + + Side effect worth knowing: with `listOnly`, compact and expanded look identical when nothing is selected (a full-width list), so breakpoint crossings without a selection stop being a visible event. + +### Two peer panes — SplitLayout + +Both panes always exist, neither drives the other: + +```dart +SplitLayout( + primaryBuilder: (context, isExpanded) => PlayerHero(), + secondaryBuilder: (context, isExpanded) => QueueList(), + dividerBuilder: HandleDivider.builder, + compactBehavior: SplitCompactBehavior.stack, // or .hidden +) +``` + +Expanded: side by side with the same draggable divider (primary at the start or end via `primaryPosition`). Compact: a vertical stack, or the primary alone. Everything below about sizing, collapse, rails, and the divider's keyboard applies to `SplitLayout` identically (`collapsedPrimaryBuilder` / `collapsedSecondaryBuilder` are its rail slots). ### Sizing the panes @@ -138,9 +229,12 @@ ListDetailLayout( ) ``` -Ships with two dividers — `HandleDivider` (resize cursor, three-dot handle, settle tint) and `MaterialDivider` (thin line) — or pass your own `DividerBuilder`. Null means an invisible drag zone: resizing still works, nothing is drawn. +Ships with two dividers — `HandleDivider` (resize cursor, three-dot handle, settle tint, pull tab when collapsed) and `MaterialDivider` (thin line) — or pass your own `DividerBuilder`. Null means an invisible drag zone: resizing still works, nothing is drawn. + +
+🧰 anchors, resize modes, width memory -Two extras for desktop-grade feel: +
```dart // Snap points: on release, the divider animates to the nearest anchor. @@ -152,73 +246,114 @@ PaneConfig( // Fixed width: the pane keeps its pixel width when the window resizes // (default is ratio — the pane scales with the window). PaneConfig(resizeMode: PaneResizeMode.pixels) + +// Fresh divider on every return to expanded +// (default is persist — a dragged position survives compact spells). +PaneConfig(widthMemory: PaneWidthMemory.resetOnReentry) ``` -For the wide layout's "nothing selected" area, pass any builder — or the shipped one: +The divider remembers by default: a dragged position survives compact spells, window resizes, and rebuilds. `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. + +
+ +### Snap-collapse and icon rails + +Expanded-only: force the divider past a pane's minimum and the pane snaps shut, the way desktop split views do. Opt in per side: ```dart -emptyStateBuilder: IconMessageEmpty.of( - icon: Icons.chat_bubble_outline, - message: 'Select a conversation', +PaneConfig( + collapsible: PaneCollapsible.start, // none / start / end / both + collapsedSize: 56, // 0 hides fully; 56 keeps an icon rail ) ``` -### Two panes without a selection +Dragging past the limit by half the pane's minimum snaps it to `collapsedSize`, with the pre-collapse width remembered; releasing short of that springs back. The parked divider stays grabbable — pull it back out to restore. -`AdaptiveSplit` is the sibling for screens where both panes always exist — a player with its queue, an editor with its preview: +**The rail.** With a non-zero `collapsedSize`, give the collapsed pane purpose-built content — it lays out at the actual collapsed width, and the real pane parks offstage with its state alive until restored: ```dart -AdaptiveSplit( - primaryBuilder: (context, isExpanded) => PlayerHero(), - secondaryBuilder: (context, isExpanded) => QueueList(), - dividerBuilder: HandleDivider.builder, - compactBehavior: SplitCompactBehavior.stack, // or .hidden +ListDetailLayout( + collapsedListBuilder: (context) => MyIconRail( + onExpand: PaneScope.of(context).restore, + ), + ... ) ``` -Wide: side by side with the same draggable divider (primary at the start or end via `primaryPosition`). Narrow: a vertical stack, or primary only. Both panes keep their state across the morph, same as the detail pane does. - -### One breakpoint for the whole app +Without a rail builder, the collapsed pane shows its normal content clipped at its minimum width. -Set it once above `MaterialApp`; every layout in the subtree inherits it. A widget's own `expandedBreakpoint` parameter still wins when you need a local exception; with neither, the default is 720. +**The scope.** Any widget inside either pane can read the collapse state and act on it through `PaneScope` — the show-sidebar recipe: ```dart -AdaptiveLayoutConfig( - expandedBreakpoint: 800, - child: MaterialApp(...), -) +// Inside a detail pane. Gate on collapsedSize == 0: a visible icon +// rail already carries the expand control, so only a FULLY hidden +// pane needs its own affordance. +final scope = PaneScope.maybeOf(context); +if (scope?.collapsed == PaneSide.start && scope!.collapsedSize == 0) + IconButton( + icon: const Icon(Icons.view_sidebar_outlined), + onPressed: scope.restore, + ) ``` ---- +`PaneScope` also exposes `collapse(PaneSide)` for app-driven collapse buttons. Programmatic collapse and restore snap instantly; the drag path is the animated one. -## Why widget-level morphing +### The divider's keyboard and screen readers -
-🧩 Why not push the detail as a route on phones? +The divider follows the WAI-ARIA window-splitter pattern out of the box: -A route-based compact detail gets platform behaviors free (predictive back, edge swipe), but the detail then lives inside a page — and pages rebuild their content from state. Carrying one widget instance between "pane 2 of a wide layout" and "a pushed route" means either lifting every piece of ephemeral state out of the widgets, or a fragile zero-transition page dance around duplicate `GlobalKey`s mid-animation. +| Input | Effect | +|---|---| +| Tab | focuses the divider (`DividerState.isFocused` for your visual) | +| Arrow left / right | resize by 24px | +| Enter | collapse the allowed side / restore | +| Home / End | animate to the minimum / maximum | +| Double click | reset to the default width (restore first if collapsed) | +| Screen reader | adjustable element announcing the pane's share ("36%") | -This package keeps both layouts inside one widget instead. The detail mounts under a stable `GlobalKey`, so the morph reparents the same element — Flutter's documented mechanism for moving a widget without losing its state. The cost is owned in exchange: the slide animation, swipe-to-dismiss, and back handling are implemented here rather than inherited from the Navigator. That trade — a stronger state guarantee for a self-implemented navigation feel — is the package's identity. +Localize the announcement via `PaneConfig(dividerSemanticsLabel: ...)`. -
+### A modal that swaps between dialog and sheet + +A real Material dialog on expanded, a real Material bottom sheet on compact — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply: + +```dart +final choice = await showAdaptiveModal( + context: context, + config: const ModalConfig(showDragHandle: true), + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 420 : double.infinity, + child: NewTicketForm(), + ), +); +``` + +Resize across the breakpoint while it is open and the modal plays a container transform: the surface glides and reshapes from one form to the other **with the live content inside** — a half-typed form field survives the trip. The returned future completes with the pop result no matter how many swaps happened. `ModalConfig(morph: false)` swaps instantly instead. + +One contract: return the same root widget type for both modes (like the `SizedBox` above) — the content moves between the two routes under a stable key, and a changed root type would defeat the move.
-🧩 How overlay mode survives tab navigation +🧰 what ModalConfig forwards, and what it deliberately doesn't -An `OverlayPortal` paints in the Overlay, outside its parent — so a parent that stops painting (an inactive `IndexedStack` child) cannot take its overlay down with it. The layout closes that hole by probing paint itself: a render object reports "I was painted this frame", and the layout checks the flag during the next layout pass. Not painted last frame means the tab is inactive, and the overlay child collapses to nothing. The portal itself is shown once and never toggled, which sidesteps `OverlayPortalController`'s restriction against show/hide during layout. The full contract, including the invariants to keep when editing this machinery, is in [`docs/UPDATING.md`](docs/UPDATING.md). +
-
+`ModalConfig` forwards Flutter's own route parameters under Flutter's own names — barrier label and dismissibility, safe area, sheet constraints and drag, anchor points, focus and traversal behavior, entrance `AnimationStyle`s — with null always meaning "Flutter's default", so new platform behavior reaches you without package releases. ---- +Visual styling (shape, elevation, drag-handle look) is deliberately NOT duplicated here: it flows through `DialogThemeData` / `BottomSheetThemeData` as usual. Each form keeps its own Material surface tone (`surfaceContainerHigh` for dialogs, `surfaceContainerLow` for sheets); `ModalConfig(backgroundColor: ...)` pins one color across both forms. -## The example app +Two params are withheld on purpose: the sheet's `clipBehavior` (the container transform's landing is pixel-matched against `Clip.antiAlias`) and `transitionAnimationController` (the swap machinery owns the routes' lifecycles). -[`example/`](example/) is a full app in one file, not a snippet gallery: three domains behind an adaptive shell (bottom nav ↔ rail), nested tab routers, URL-synced list-details in overlay mode inside every tab, adaptive modals, and a persistent strip above the router. It exists so package changes are tested against the hardest real topology — its `flutter test` journeys cover resize state preservation, overlay suppression across tabs, deep links, and auto-dismiss on deletion. + + +### One breakpoint for the whole app -```sh -cd example -flutter run -d chrome # the URL bar shows the deep-link sync live -flutter test # the journey suite +Set it once above `MaterialApp`; every layout and modal in the subtree inherits it. A widget's own `expandedBreakpoint` parameter still wins for a local exception; with neither, the default is 720. + +```dart +AdaptiveLayoutConfig( + expandedBreakpoint: 800, + child: MaterialApp(...), +) ``` --- @@ -231,13 +366,34 @@ Pure Flutter, no conditional imports, no platform code: |:---:|:---:|:---:|:---:|:---:|:---:| | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +
+🧩 how overlay mode survives tab navigation + +
+ +An `OverlayPortal` paints in the Overlay, outside its parent — so a parent that stops painting (an inactive `IndexedStack` child) cannot take its overlay down with it. The layout closes that hole by probing paint itself: a render object reports "I was painted this frame", and the layout checks the flag during the next layout pass. Not painted last frame means the tab is inactive, and the overlay child collapses to nothing. The portal itself is shown once and never toggled, which sidesteps `OverlayPortalController`'s restriction against show/hide during layout. The full contract is in [`docs/UPDATING.md`](docs/UPDATING.md). + +
+ +
+🧩 why the modal uses real Material routes + +
+ +The pane layouts own their navigation feel in exchange for the state guarantee. The modal gets both: each form is Flutter's own route, so theming, drag physics, and back handling are Material's — and framework improvements arrive with Flutter upgrades. On a breakpoint crossing the active route is atomically replaced in a single frame, and since every route of a Navigator lives in one Overlay — one element tree — the keyed content reparents into the new route instead of rebuilding. A session object proxies the pop result across swaps, so the caller's awaited future never notices. The swap's flight is Material's container-transform pattern carrying the *live element*, where `Hero` and `OpenContainer` would rebuild it; the full mechanics are in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +
+ --- ## Not in the box +What the shipped package doesn't do, and where that lives instead. Full per-capability status in the [capability roadmap](docs/CAPABILITY_ROADMAP.md). + - **Router / URL integration** — deliberately. The controller is the seam; wire it to any router. The example ships complete auto_route reference wiring (`ListDetailRouter`, `MultiTypeListDetailRouter`) to copy from. -- **Selection validation** — the layout does not know whether `chat-42` still exists. When an entity is deleted, the app clears the selection (the example's `selectedIdExists` pattern shows how, including why the dismissal must be deferred out of the build phase). +- **Selection validation** — the layout does not know whether `chat-42` still exists. When an entity is deleted, the app clears the selection (the example's `selectedIdExists` pattern shows how). - **Navigation bars, rails, tab bars** — this package lays out panes; the shell around them is your app's. +- **Three-pane scaffolds** — compose the shipped widgets, or build the shape app-side; a dedicated widget joins only when real consumers prove the generic design. --- @@ -248,9 +404,9 @@ The README covers the everyday stuff. wanna go deeper? | Doc | What's inside | |---|---| | [Architecture](docs/ARCHITECTURE.md) | How it's built: the two-layer split, the overlay machinery, the width model, design decisions | -| [Capabilities](docs/CAPABILITY_ROADMAP.md) | Every capability with status, plus the explicit non-goals | +| [Capabilities](docs/CAPABILITY_ROADMAP.md) | What's shipped, what's planned, what won't happen | | [Updating](docs/UPDATING.md) | Maintenance recipes and the invariants that must not break | -| [Example](example/) | The full-app integration reference, journeys checked by test | +| [Example app](example/) | A full app, not a snippet gallery: three domains behind an adaptive shell, nested tab routers, URL sync, every compact mode, collapsible panes with rails, adaptive modals — with `flutter test` journeys covering resize state, overlay suppression, and deep links | --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ba0018b..cc1ee13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,12 +1,13 @@ # adaptive_layouts — Architecture -> **Type:** architecture · **Scope:** adaptive_layouts · **Status:** SHIPPED · **Last verified:** 2026-07-18 +> **Type:** architecture · **Scope:** adaptive_layouts · **Status:** SHIPPED · **Last verified:** 2026-07-19 > **Companion docs:** [`CAPABILITY_ROADMAP.md`](CAPABILITY_ROADMAP.md) · [`UPDATING.md`](UPDATING.md) Adaptive layout widgets that morph between compact (mobile) and expanded (tablet / desktop) form factors — preserving pane widget state across the -morph. Router-agnostic, state-management-agnostic. Consumed by apps via -`path:` dependency; the runnable integration reference is +morph. Router-agnostic, state-management-agnostic. The package is a growing +family — each layout lives in its own `src/core/` subtree on shared +vocabulary (`src/core/shared/`). The runnable integration reference is [`example/`](../example/) (a full multi-domain app with nested tab routers, URL sync, and modals). @@ -29,19 +30,31 @@ lib/ │ ├── compact_config.dart # compact-mode data (animation, gestures, overlay) │ ├── compact_detail_overlay.dart # OverlayPortal wrapper (internal) │ ├── detail_layout_mode.dart # stacked / sideBySide + inline / overlay + │ ├── detail_page_route.dart # route mode's real PageRoute (internal) + │ ├── expanded_empty_behavior.dart # placeholder / listOnly empty-pane schools │ ├── list_detail_controller.dart # selection + visibility controller │ ├── list_detail_layout.dart # widget + state (lifecycle, animation, gestures) │ ├── list_detail_layout_builders.dart # part: build methods │ └── paint_visibility_detector.dart # overlay suppression for inactive tabs + ├── modal/ + │ ├── adaptive_modal.dart # showAdaptiveModal + route-swap session + │ ├── modal_config.dart # forwards to the Material routes + │ ├── modal_layout_mode.dart # dialog / sheet + │ └── modal_morph.dart # container-transform flight (internal) ├── shared/ │ ├── adaptive_layout_config.dart # inherited breakpoint config - │ ├── divider_builder.dart # shared DividerBuilder typedef + │ ├── divider_builder.dart # DividerBuilder typedef + DividerState + │ ├── expanded_entry_style.dart # reveal vs resize expand entry │ ├── pane_anchor.dart # divider snap points - │ ├── pane_config.dart # expanded-pane data (widths, anchors, mode) + │ ├── pane_collapse.dart # PaneSide + PaneCollapsible + │ ├── pane_config.dart # expanded-pane data (widths, anchors, collapse, settle) + │ ├── pane_divider_region.dart # divider interactivity: drag, keyboard, semantics │ ├── pane_resize_mode.dart # ratio vs pixels - │ └── pane_width_model.dart # width/drag/clamp/snap logic (internal) + │ ├── pane_scope.dart # collapse state + actions for pane descendants + │ ├── pane_width_memory.dart # persist vs resetOnReentry + │ └── pane_width_model.dart # width/drag/clamp/snap/collapse logic (internal) └── split/ - └── adaptive_split.dart # generic two-pane split widget + └── split_layout.dart # generic two-pane split widget ``` **Dependency rule (load-bearing): core never imports components.** Components @@ -71,13 +84,47 @@ Builders receive everything they need — `listBuilder(context, selectedId, onSelect)` and `detailBuilder(context, id, mode, onDismiss)` — so app code never touches layout internals. -### `AdaptiveSplit` +### `SplitLayout` The generic sibling: two panes (primary / secondary) with no selection concept. Expanded = side-by-side with the same draggable divider; compact = vertical stack or hidden secondary (`SplitCompactBehavior`). Used for player-style screens where both panes always exist. +### `showAdaptiveModal` + +The modal member of the family — a function, not a widget, because modals +are routes. Expanded widths get a real `DialogRoute`; compact widths a real +`ModalBottomSheetRoute` — Material's own chrome, theming, drag physics, and +accessibility. On a resize across the breakpoint the active route is +atomically replaced (`removeRoute` + zero-entrance `push` in one frame) and +the content element reparents into the new route under a stable `GlobalKey`. +A session object proxies the pop result across swaps, so the caller's +awaited future completes with the dismissal value no matter how many form +changes happened. `ModalConfig` mostly forwards parameters to the two routes. + +By default the swap plays a **container transform** (Material's own name for +the pattern): during the swap the live content mounts in a flight — an +overlay entry above the routes — whose surface lerps rect, shape, color, +elevation, and the drag-handle band from the outgoing form's geometry to the +destination's. The destination is pushed as a **ghost**: real barrier, real +layout machinery, zero visible chrome (transparent sheet without handle or +drag; the dialog form's chrome is zeroed reactively). Nothing of the +destination shows before the flight has become it — at landing, a +same-frame swap restores the real chrome exactly under the flight's +identical final frame. The ghost lays out a same-size placeholder (plus a +`kMinInteractiveDimension` spacer standing in for the drag-handle band) +whose LIVE rect is the flight's landing target, tracked every frame. The +content is laid out TIGHT at the container's lerped width so it reflows +with the morph; the placeholder's width is the flight's one-time +natural-width sample (slot-decided for full-bleed content, self-decided +otherwise) and only the height flows from the live measurement — so the +target is the true final geometry and the handoff is pixel-clean. Both resting forms clip (`Clip.antiAlias`) +to match the flight's clipped surface. Dismissal mid-flight lands the content +into the exiting route immediately; a second breakpoint crossing retargets +the flight from its current visual state. `ModalConfig(morph: false)` +restores the instant cut. + ### `ListDetailController` `ChangeNotifier`, `ScrollController`-style: omit it and the widget creates @@ -107,6 +154,21 @@ the same slide animation, swipe gesture, and back handling. navs, tab bars — while the widget stays in the tree (state preserved). `CompactConfig.useRootOverlay` picks the nearest vs root overlay, same convention as `Overlay.of` / `Navigator.of`. +- **`route`**: the detail is pushed as a REAL page route + (`MaterialRouteTransitionMixin` on a non-opaque `PageRoute`), so the + app's `PageTransitionsTheme` — platform transitions, predictive back, + Cupertino edge swipes — applies natively; back belongs to the route + (no `PopScope`, no swipe machinery). One post-frame reconciler owns all + navigation: selection pushes, dismissal pops with the real exit (the + content rides the route out and dies with it — dismissal IS + deselection, so there is no pop-handoff and predictive-back cancel + needs nothing), resize swaps remove/push instantly while the keyed + detail reparents between route and pane (one key holder per frame; a + one-frame inline bridge covers the push gap). The paint probe + suppresses hidden tabs: a build pass that ends unpainted removes the + route (selection kept), and the next paint re-pushes instantly — the + route is non-opaque precisely so the layout below keeps painting for + this check. ### The always-showing portal @@ -143,8 +205,8 @@ machinery is in [`UPDATING.md`](UPDATING.md). ## 4. State preservation across the morph -Detail (and both `AdaptiveSplit` panes) are mounted under stable -`GlobalKey`s. When the layout morphs compact ↔ expanded, Flutter reparents +The list, the detail, and both `SplitLayout` panes are mounted under +stable `GlobalKey`s. When the layout morphs compact ↔ expanded, Flutter reparents the same element instead of rebuilding it — text drafts, scroll positions, and in-flight animations survive a window resize. This is the package's strongest guarantee and the reason the morph is widget-level rather than @@ -157,10 +219,20 @@ external `controller.dismiss()` calls, via a last-seen-id fallback). --- +The modal extends the same mechanism across routes: all routes of a +Navigator share one Overlay — one element tree — so the keyed content +reparents between the outgoing and incoming route in the swap frame exactly +as pane content does between compact and expanded builds. + ## 5. Pane width: config, model, anchors -`PaneConfig` is pure data: `defaultListWidth`, `minListWidth`, -`maxListRatio`, `anchors`, `initialAnchorIndex`, `resizeMode`. +`PaneConfig` is pure data — widths and clamps (`defaultListWidth`, +`minListWidth`, `maxListRatio`), anchors (`anchors`, +`initialAnchorIndex`), modes and memory (`resizeMode`, `entryStyle`, +`widthMemory`), settle knobs (`settleDuration`, `settleCurve`), the +divider hit zone (`dividerHitWidth`, `dividerSemanticsLabel`), and +collapse (`collapsible`, `collapsedSize`). It compares by value, so +inline-constructed configs never reset a dragged divider. `PaneWidthModel` (internal, shared by both widgets) owns the math: @@ -170,14 +242,34 @@ external `controller.dismiss()` calls, via a last-seen-id fallback). - **Pixels mode:** the pane keeps a fixed pixel width across window resizes. - **Clamps:** every read clamps to `[minListWidth, maxListRatio × width]`; when a narrow window pushes the max below the min, min wins. -- **Anchors:** on drag end the divider animates (220ms, easeOutCubic) to the - nearest anchor; the divider builder receives `isSettling: true` during the - animation. Anchor positions are clamped like everything else. Empty - `anchors` = free dragging, no snap. - -The divider itself is a 24px invisible hit zone centered on the pane border; -`dividerBuilder` (nullable) draws the visual inside it. Divider drag is -RTL-aware, and inverted for an end-positioned `AdaptiveSplit` primary. +- **Anchors:** on drag end the divider animates (the settle knobs; 220ms + easeOutCubic by default) to the nearest anchor; the divider builder + receives `isSettling: true` during the animation. Anchor positions are + clamped like everything else. Empty `anchors` = free dragging, no snap. +- **Collapse:** desktop split-view mechanics in model state. Forcing the + drag past a limit by half the pane's minimum snaps the pane to + `collapsedSize` with the pre-collapse width cached; releasing short of + the threshold springs back. Programmatic `collapse`/`restore` snap + instantly. `PaneSide`/`PaneCollapsible` stay DIRECTIONAL in every + public surface; `SplitLayout` with an end-positioned primary flips + them at its boundary into the model's primary-measured space. + +`PaneDividerRegion` (shared) owns every divider interaction: the drag +gestures, double-click reset to the default width, Tab focus with +arrow-key resizing, Enter collapse/restore, Home/End jumps, and the +WAI-ARIA adjustable semantics (share value plus stepped values). Both +widgets supply callbacks; neither builds its own divider gestures. The +hit zone (`dividerHitWidth`, default 24) is centered on the pane +border; `dividerBuilder` (nullable) draws the visual inside it. Drag is +RTL-aware, and inverted for an end-positioned `SplitLayout` primary. + +`PaneScope` (an `InheritedWidget` both layouts plant) gives pane +descendants the collapse state (`collapsed`, `collapsedSize`, +`isExpanded`) and the `collapse`/`restore` actions. Collapsed panes +render either their content clipped at its floor width or, when the +widget's `collapsed*Builder` is provided, an app-built rail laid out at +the real `collapsedSize` while the pane parks offstage +(`TickerMode`-paused) with its state alive. --- @@ -197,7 +289,8 @@ slide direction, swipe-dismiss direction, and divider drag all flip. | Two-layer core + components | Core is pure layout; components are replaceable defaults. Nullable builder params keep the dependency arrow one-way. | | Controller pattern | One code path; simple users get an auto-controller, advanced users bring their own. Same as `ScrollController`. | | `isDetailVisible` animation-aware | App shells need visual state (nav-bar timing), not data state. | -| Widget-level morph, not routes | Instance preservation across resize is the hard guarantee; a route-based compact detail would need a no-transition page dance to keep it. | +| Widget-level foundation; routes opted in on top | Instance preservation across resize is the hard guarantee, so the layout owns the element's lifecycle; `CompactDetailMode.route` then hosts the same keyed element in a real page, with the layout choreographing the one-owner-per-frame handoff a route-first design could not. | +| Real Material routes for the modal | Dialogs and sheets should inherit app theme, drag physics, and future framework behavior; the atomic route swap + keyed reparent keeps the instance guarantee without re-implementing chrome. | | Always-showing portal + paint probe | The only found shape that both dodges `OverlayPortalController`'s layout-phase assertion and suppresses inactive tabs' overlays. | | `select()` no-op on same id | No guessed intent; toggle is the caller's logic. | | Selection validation NOT in the package | Layout doesn't know about data existence; the app clears selection when an entity dies (see the example's `selectedIdExists` pattern). | diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index c8d6f6c..12173bd 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -1,6 +1,6 @@ # adaptive_layouts — Capability Roadmap -> **Type:** roadmap · **Scope:** adaptive_layouts · **Last verified:** 2026-07-18 +> **Type:** roadmap · **Scope:** adaptive_layouts · **Last verified:** 2026-07-19 > **Companion docs:** [`ARCHITECTURE.md`](ARCHITECTURE.md) · [`UPDATING.md`](UPDATING.md) Every capability the package offers or plans, with status. Nothing ships @@ -15,7 +15,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · |---|---|---| | Compact ↔ expanded morph at breakpoint | DONE | `LayoutBuilder`-driven; per-widget / inherited / 720 resolution | | Detail state preservation across the morph | DONE | GlobalKey reparenting; covered by resize tests + example journeys | -| Side-by-side panes with draggable divider | DONE | 24px hit zone; nullable visual builder | +| Side-by-side panes with draggable divider | DONE | Configurable hit zone (`dividerHitWidth`, default 24); nullable visual builder | | Compact slide-over with swipe-to-dismiss | DONE | Threshold + fling velocity, both configurable | | Back-gesture interception on compact | DONE | `PopScope`; opt-out via `CompactConfig.handleBackGesture` | | Exit animation with outgoing-detail retention | DONE | AnimatedSwitcher pattern; internal + external dismiss paths | @@ -23,11 +23,17 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | Inline compact mode | DONE | Default; detail stays inside the layout's bounds | | Overlay compact mode (covers bottom nav / tabs) | DONE | Always-showing `OverlayPortal`; nearest or root overlay | | Overlay suppression for inactive kept-alive tabs | DONE | Paint-visibility probe; zero-frame hide, one-frame re-show | +| True-route compact mode (`CompactDetailMode.route`) | DONE | Real `PageRoute` with the app's `PageTransitionsTheme` (predictive back, edge swipes); atomic resize swaps reparent the detail between route and pane; paint-probe suppression for hidden tabs; the feared pop-handoff never existed — dismissal kills the detail by design, so predictive back is free | | Empty state builder (expanded, no selection) | DONE | Nullable; `IconMessageEmpty` shipped as a convenience | | RTL support | DONE | Slide, swipe, and divider drag are direction-aware | +| A11y route parity for inline/overlay details | DONE | Open detail scopes as a route; covered content leaves the semantics tree (`ExcludeSemantics` / `BlockSemantics`); `DismissIntent` (Escape) dismisses with focus inside | +| Breakpoint-crossing motion (both directions) | DONE | Into compact: detail grows out of its pane (inline/overlay) or the route's real entrance plays. Into expanded: the list slides in beside the full-width detail. Pane geometry still tracks drags without motion. Exception: parked (collapsed) panes arrive docked — no collapse replay on crossings | +| Expand-entry list style (reveal / resize) | DONE | `PaneConfig.entryStyle`: reveal (default — final-width, clipped, no reflow) or resize (lays out live, grows into the pane) | +| Divider position memory | DONE | Survives compact spells + rebuilds; `PaneConfig`/`PaneAnchor` value equality (incl. NaN-sentinel anchors) — inline-constructed configs never reset the model. `PaneWidthMemory.resetOnReentry` opts into a fresh divider per expanded spell (ListDetailLayout + SplitLayout) | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | +| Empty detail slot behaviors | DONE | `ExpandedEmptyBehavior`: placeholder (default, `emptyStateBuilder`) or listOnly (full-width list; the pane reveals from the end edge on selection, retreats on dismiss). Auto-select-first stays an app-side recipe (documented; example ⚙ toggle) | -## AdaptiveSplit +## SplitLayout | Capability | Status | Notes | |---|---|---| @@ -36,6 +42,18 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | Compact: vertical stack or hidden secondary | DONE | `SplitCompactBehavior` | | Pane state preservation across the morph | DONE | GlobalKeys on both panes | +## Adaptive modal (`showAdaptiveModal`) + +| Capability | Status | Notes | +|---|---|---| +| Real Material routes per form | DONE | `DialogRoute` on expanded, `ModalBottomSheetRoute` on compact — chrome, theming, a11y all Material's | +| Live dialog ↔ sheet swap on resize | DONE | Atomic `removeRoute` + zero-entrance `push`; one-frame handoff | +| Content state preserved across swaps | DONE | `GlobalKey` reparent through the shared Navigator overlay; unit tests + example journey | +| Result future survives swaps | DONE | Session completer with route-identity guard | +| Breakpoint resolution | DONE | param > inherited `AdaptiveLayoutConfig` > 720, resolved at call time | +| Config forwarding | DONE | Native passthroughs under Flutter's names: barrier (color/dismissible/label), safe area, scroll control + max-height ratio, drag + handle, sheet constraints, anchorPoint, traversalEdgeBehavior, requestFocus, entrance AnimationStyles, routeSettings, useRootNavigator. Withheld by design: sheet clipBehavior (morph landing parity), transitionAnimationController (swap machinery owns route lifecycles). Styling stays on Dialog/BottomSheet themes | +| Animated cross-form morph (container transform) | DONE | Flight overlay carries the LIVE content between the real routes; placeholder-tracked landing; retarget + mid-flight-dismiss handled; `morph: false` restores the cut | + ## Pane system (shared) | Capability | Status | Notes | @@ -43,7 +61,11 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | Ratio resize mode (pane scales with window) | DONE | Default; `defaultListWidth` referenced to the breakpoint | | Pixels resize mode (pane fixed across resizes) | DONE | `PaneResizeMode.pixels` | | Min-width / max-ratio clamping | DONE | Min wins when the window is too narrow for the ratio cap | -| Anchor snap points with settle animation | DONE | Nearest anchor on drag end; `isSettling` fed to divider builders | +| Snap-collapse panes (VS Code spec) | DONE | `PaneCollapsible` per side + `collapsedSize` icon-rail; half-minimum threshold, cached-width restore, pull-tab `HandleDivider`; directional API preserved for `SplitLayout` end-positioned primary | +| Collapsed icon-rail slots | DONE | `collapsedListBuilder`/`collapsedDetailBuilder` (+ split equivalents): rail lays out at the real `collapsedSize`; the pane parks offstage (tickers paused) with its state alive; list pane gained its own reparenting GlobalKey | +| Divider keyboard + screen-reader support | DONE | WAI-ARIA window splitter: Tab-focusable, arrows resize, Enter toggles collapse, Home/End jump, double-click resets; semantics increase/decrease with pane-share value | +| PaneScope (pane state for descendants) | DONE | `collapsed`/`collapsedSize`/`isExpanded` + `collapse`/`restore` actions; the show-sidebar recipe gated on fully-hidden | +| Anchor snap points with settle animation | DONE | Nearest anchor on drag end; `isSettling` fed to divider builders; duration/curve via `PaneConfig.settleDuration`/`settleCurve` | | Initial width from anchor index | DONE | `PaneConfig.initialAnchorIndex`, anchors non-empty | ## Components @@ -58,8 +80,8 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | Capability | Status | Notes | |---|---|---| -| Package test suite mirroring `src/` | DONE | 65 tests: unit (controller, anchors, width model) + widget (layouts, overlay, paint probe, components) | -| Full-app example with journey tests | DONE | `example/` — nested tab routers, URL sync, modals; 8 journeys | +| Package test suite mirroring `src/` | DONE | 174 tests: unit (controller, anchors, width model, collapse) + widget (layouts, modes, crossings, rails, divider keyboard/semantics, components) | +| Full-app example with journey tests | DONE | `example/` — nested tab routers, URL sync, modals, collapse rails; 12 journeys | | Three canonical docs | DONE | This set | ## Explicit non-goals diff --git a/docs/UPDATING.md b/docs/UPDATING.md index d0f0a36..5ec5173 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -1,6 +1,6 @@ # adaptive_layouts — Updating -> **Type:** maintenance · **Scope:** adaptive_layouts · **Last verified:** 2026-07-18 +> **Type:** maintenance · **Scope:** adaptive_layouts · **Last verified:** 2026-07-19 > **Companion docs:** [`ARCHITECTURE.md`](ARCHITECTURE.md) · [`CAPABILITY_ROADMAP.md`](CAPABILITY_ROADMAP.md) Maintenance recipes and the invariants that must not break. When code and @@ -52,7 +52,8 @@ JUMPS to 1.0 (no re-animation) — the detail was already visible. ## §2 — The morph invariants -- **Detail and split panes mount under stable `GlobalKey`s.** Anything that +- **The list, the detail, and both split panes mount under stable + `GlobalKey`s.** Anything that changes a pane's position in the tree between compact and expanded must keep the same key wrapping the same builder output, or state preservation (the package's core guarantee) silently dies. The resize tests fail loudly @@ -64,7 +65,248 @@ JUMPS to 1.0 (no re-animation) — the detail was already visible. --- -## §3 — Adding a component (divider / empty state) +## §3 — The modal swap invariants (DO NOT BREAK) + +`showAdaptiveModal` presents real Material routes (`DialogRoute`, +`ModalBottomSheetRoute`) and replaces one with the other when the window +crosses the breakpoint. The swap machinery in +`src/core/modal/adaptive_modal.dart` holds three invariants: + +1. **The swap is one synchronous block: point `_active` at the new route → + `removeRoute(old)` → `push(new)`.** All three land in one frame — that is + what lets the `GlobalKey`'d content reparent instead of unmount, and what + keeps old and new content from coexisting (duplicate-key crash). +2. **The identity guard decides what a route completion means.** A removed + route also completes its `popped` future (with null); only the completion + of the route that is still `_active` is the user dismissing the modal. + Reorder the swap so `_active` still points at the old route during + `removeRoute` and the caller's future completes with null mid-swap. +3. **Swaps navigate post-frame, from live width.** The crossing is detected + during build (navigation is illegal there); by the time the callback runs + the width may have crossed back, so the target mode is re-derived — never + captured at schedule time. + +With the container transform (`ModalConfig.morph`, default on), three more: + +4. **Exactly one holder of the content key per frame.** While a flight is + in the air the routes build a placeholder instead of the keyed content + (`_morphing` drives the branch). Flight start and flight end each move + the key in one synchronous block. Two holders in one frame is a + duplicate-key crash; zero is a silent state reset. +5. **The subtree from the keyed node down is identical in every host.** + The flight's `Builder` (for the captured-themes context) sits ABOVE the + `KeyedSubtree` — insert anything between the key and the user's content + in one host but not the others and the reparent degrades into a rebuild. +6. **A dismissal mid-flight lands the content before the result completes.** + The `popped` handler calls `_endFlight()` first, so the exiting route + shows the content during its exit animation and the flight's ticker is + disposed. Completing the result while a flight is airborne leaks the + ticker and pops an empty route. +7. **Content lays TIGHT at the container's lerped width; the placeholder's + width comes from the one-time natural-width sample.** Tight layout is + what makes the content shrink and grow WITH the morph — loose layout + lets self-sized content jump to its destination width at takeoff, and + destination-width layout makes the narrower container visibly crop it. + The width circularity that tight-following would otherwise cause + (placeholder width from content width from container width) is broken + by sampling: the measurer lays the child LOOSE as a pre-pass inside + the same layout (never painted — a painted loose frame is a visible + narrow-flash at takeoff), records the natural width and whether the + content is full-bleed, then lays tight for real. The placeholder's + width is that frozen sample (full-bleed → the slot decides via + `double.infinity`; self-sized → the sampled width), delivered through + `sampleRevision` — the size channel alone can't carry it, since the + tight-laid size often doesn't change when the sample does. A retarget + switches the target form, so it marks the sample stale and the next + layout resamples — a stale sample is the sheet landing at the + dialog's width and snapping wide. Only the HEIGHT flows from the live + measurement. Related: both + resting forms pass `Clip.antiAlias` so corner rendering matches the + flight's clipped surface; reverting to Material's default `Clip.none` + makes content near the corners pop square at handoff. +8. **The destination stays a ghost until the flight becomes it.** During a + morph the sheet is pushed chrome-less (transparent surface, no handle, + no drag — but the REAL barrier, for scrim continuity) and the dialog + form zeroes its chrome reactively. Landing on a ghost sheet does a + same-frame swap to the normally-chromed route; the reveal is invisible + because the flight's final frame is pixel-identical to the real chrome. + Pushing a visible destination brings back the "fully-formed empty sheet + waiting for its content" artifact. +9. **The handle band is `kMinInteractiveDimension`, imported — plus one + structural replica.** The ghost's placeholder inset and the flight's + surface inset both use the SAME constant `BottomSheet` uses for its + content padding, so the metric cannot drift — and both apply it as + `Padding` INSIDE the bounded slot, never a spacer stacked above: + constraint-filling content must get height − band, or the slot + overflows by exactly the band (found live with a scrollable panel; + the flight mirrors the math via `maxHeight − inset`). The handle's look + (`dragHandleSize ?? theme ?? Size(32, 4)`, radius h/2, color + `dragHandleColor ?? theme ?? onSurfaceVariant`) is replicated from the + SDK source and pinned by the landing-parity test — if a Flutter upgrade + moves it, that test fails loudly. +10. **The flight's Material must not out-paint the flight.** Three paint + parities, each found as a real landing pop: the flight's `Material` + sets `animationDuration: Duration.zero` (Material implicitly tweens + shape/elevation over ~200ms, lagging the lerp — the widget tree + claims the lerped shape while the pixels stay behind); its + `shadowColor` resolves from the theme with the M3 default of + transparent (both real forms cast no shadow — a default black shadow + on the flight vanishes at handoff); and the ghost → real `Dialog` + flips a `ValueKey` so its Material mounts fresh instead of implicitly + tweening elevation after landing. + +## §4 — The route-mode invariants (DO NOT BREAK) + +`CompactDetailMode.route` hosts the compact detail in a real page route. +The machinery in `list_detail_layout.dart` holds: + +1. **All navigation flows through `_syncDetailRoute`, post-frame.** Build + detects, the reconciler acts. Navigating during build asserts. +2. **Exactly one holder of the detail key per frame** — the route (while + `_detailRouted`), the expanded pane, or the one-frame bridge. The pane + leaves its slot empty while a route holds the key; the route still + covers the screen that frame, so the gap is invisible. +3. **Dismissal is deselection — there is NO pop handoff.** The content + rides the popped route's real exit animation and dies with it, exactly + like the inline outgoing pattern. This is why predictive back (and its + cancel) needs no special handling. An exiting route keeps the key until + its animation is dismissed; new pushes wait for it. +4. **The identity guard on `popped`.** A removed route also completes; + only the still-active route's completion is a user dismissal. +5. **The route stays non-opaque.** The paint probe below detects "tab + hidden under the route"; an opaque route blinds the probe AND + un-paints the very layout that owns the route — a suppression loop. +6. **Suppression is a build-armed one-shot, and it corrects the stale + probe.** `evaluate()` (layout) resets the paint flag; the post-frame + check PEEKS `paintedThisFrame` only after a frame whose build armed + it — clean idle frames (where paint legitimately skips undirtied + layers) can never false-trigger. The check never resets the flag; + `evaluate` owns the reset. It runs on EVERY route-mode build (not + only routed ones): paint can stop without any build running + `evaluate()` (keep-alive tab buckets — TabBarView), leaving the + notifier stale-TRUE. A built-but-unpainted frame flips it false — + which is also what re-arms `_onPaint`'s deferred "paint resumed" + signal; a stuck-true notifier never fires it and the re-show would + never wake. +7. **A push needs same-frame paint evidence; a suppressed detail is + re-homed in the bridge.** The sync reads `paintedThisFrame` when the + frame's build ran `evaluate()` (fresh both ways: pushes in the very + flush where paint resumed, AND vetoes the stale-true notifier that + once pushed a hidden tab's route over the visible screen), falling + back to the notifier only for listener-woken syncs in buildless + frames. `_scheduleRouteSync` calls `ensureVisualUpdate()` — the + listener fires inside another frame's post-frame flush, where a + queued callback otherwise waits for a frame an idle device never + schedules. And whenever the route is removed while the selection is + kept, `_bridgeDetail` is set in the same block: the bridge claims + the detail key the frame the route dies — a keyless frame unmounts + the element and destroys its state. (Repro for all three: open the + detail expanded, hide the tab, resize to compact, return.) +8. **Route pushes run Hero scans over the whole shell.** Flutter scans + the from-route subtree — including kept-alive tab children — for + `Hero` tags on every push. Two `FloatingActionButton`s with default + hero tags anywhere under the shell assert and wreck the transition. + App-side rule (documented in the README): give FABs explicit + `heroTag`s (or `null`) when several coexist under one page. +9. **Crossings animate the ARRANGEMENT, never the tracking — and the + offstage bridge is the route's handoff.** Pane geometry follows a + window drag with zero motion (a lagging pane fights the hand); the + arrangement flip at the breakpoint animates on EVERY crossing, drag + or jump alike — the Compose-canonical pane motion. Into compact: + inline/overlay reuse the slide controller (start at the old divider + fraction, settle to full); route mode drops `instantEntrance` and + lets the REAL entrance play over the LIST — the bridge goes + `Offstage` for the handoff frame: still the detail key's holder (a + keyless frame unmounts the element) but invisible, so the entrance + never slides over a copy of itself. Into expanded (every mode): the + detail starts full width — for route mode this makes the instant + route removal seamless, the pane's first frame matches the route's + last — and `_expandEntryController` slides the list in, scaling only + the RENDERED list width (the width model keeps real geometry; the + rebuild rides an AnimatedBuilder, never a setState listener, which + would fire during build when a crossing frame seeds the value). + Hidden layouts (paint-probe false) always take the instant path — an + entrance nobody watches is wasted, and the re-show contract expects + instant. A first build is never a crossing (`_hasBuiltOnce`) — deep + links render settled. The EMPTY placeholder pane crosses too: it + reveals via `_detailPaneController` on expand, and on shrink build() + keeps the expanded geometry alive at the compact width + (`_emptyPaneRetreating`) until the retreat lands — the steady visuals + on both ends are a full-width list, so the tree swap is invisible. + During those builds `_lastExpandedWidth` must NOT update (it would + record the compact width and corrupt the crossing math). +10. **`didUpdateWidget` mirrors initState's per-mode wiring.** Mode flips + happen on LIVE layouts (settings screens exist). Entering route mode + must attach the paint-probe listener — it IS the re-show chain; a + layout flipped into route mode without it looks fine until a tab + hide + return leaves the detail resting inline forever. Anything a + mode's initState wires, the flip must wire; anything it wires, the + flip away must unwind. The regression test pins this with a + paint-only re-show (children reused across the tab switch), because + a harness that rebuilds on tab switch masks a dead listener. + +## §4b — The divider interaction invariants (DO NOT BREAK) + +1. **All divider interactivity lives in `PaneDividerRegion`** — drag, + double-click reset, keyboard shortcuts, semantics. The layouts supply + callbacks; neither layout builds its own `GestureDetector` for the + divider. A new interaction goes into the region once and both widgets + get it. +2. **Collapse mechanics are model state** (`PaneWidthModel.collapsed`, + VS Code spec: half-minimum threshold on the raw drag position, + `_cachedWidth` restore, spring-back short of the threshold). Widgets + never track their own collapsed flag. +3. **`PaneSide` / `PaneCollapsible` are DIRECTIONAL in every public + surface** (config, `DividerState.collapsed`, `PaneScope`). The width + model works in model space (start = the measured pane). + `SplitLayout` with an end-positioned primary translates at its + boundary: flipped `collapsible` into the model, flipped `collapsed` / + at-limit flags / Home-End targets out of it. `ListDetailLayout`'s + model space IS directional, no translation. Breaking this makes the + pull tab point the wrong way and `PaneScope` lie. +4. **Collapsed pane content reflows down to its floor width, never + below it.** The clip slot (`heldAtFloor`) lays content at + `max(floor, slot width)` — live reflow while the slot is above the + floor, rigid-and-clipped below it (start pane's floor is + `minListWidth`, end pane's is `available * (1 - maxListRatio)`). + No jump at an animation's first frame, no squish below the floor + the app designed for. +5. **Programmatic collapse/restore snap instantly**; only the drag path + animates. `_settleToWidth` is the single settle primitive — anchors, + Home/End, and double-click reset all route through it. +6. **Keyboard steps are micro-drags** through `_handleDividerDragUpdate`, + so RTL (and split's primary-position inversion) apply identically to + pointer and keyboard. Never add a parallel resize path. +7. **Semantics carry `value` + `increasedValue` + `decreasedValue` + together** — Flutter asserts if increase/decrease actions exist with a + `value` but no stepped values. The share strings come from the + layout's `_paneSharePercent`. +8. **Parked panes don't dance.** A collapsed pane's crossing arrives at + the final arrangement directly: the rail docks at its parked width + from frame one (fixed end slot + background spacer while the list's + slide-in plays; divider anchored to the DOCKED boundary), and both + the parked-list entry slide and the empty-pane reveal/retreat are + skipped when parked. The collapse animation belongs to the moment + the user collapsed — a window resize isn't that moment. A rail + entrance flourish is the app's own business inside its rail builder; + never stretch a fixed-width rail across an animating slot. +8b. **Rail slots replace the clip only when the app opts in.** A + `collapsed*Builder` lays its content at the REAL slot width + (`StackFit.expand` — the offstage child is zero-size and must not + set the stack's size) while the pane parks in `Offstage` + + `TickerMode(false)`, state alive. Both panes carry reparenting + GlobalKeys, so the wrap/unwrap reparents instead of remounting. +9. **Never `clamp` pane bounds without the min-wins guard.** Expanded + geometry renders at COMPACT widths during the empty-pane retreat, so + `availableWidth * maxListRatio` can drop below `minListWidth` — + `double.clamp` THROWS on inverted bounds. `PaneWidthModel._clamp` + and every `_paneSharePercent` guard with "ceiling <= floor → floor"; + any new bound computation must too. +10. **The divider stays grabbable when parked.** The region's position is + clamped fully on-screen at `collapsedSize`; a collapsed pane must + always be recoverable by drag alone. + +## §5 — Adding a component (divider / empty state) 1. Create the file under `src/components/{dividers|empty_states}/`. 2. Match the shared contract: dividers implement the `DividerBuilder` @@ -74,24 +316,32 @@ JUMPS to 1.0 (no re-animation) — the detail was already visible. internals. Core must not gain an import of the new component. 4. Export from the barrel's components section. 5. Mirror a test under `test/components/...` asserting the visual states - (idle / dragging / settling for dividers). + (idle / dragging / settling / focused / collapsed pull-tab for + dividers). 6. Row in `CAPABILITY_ROADMAP.md`. -## §4 — Adding a config field +## §6 — Adding a config field 1. Add the field to the right pure-data class (`PaneConfig`, `CompactConfig`) with a default that preserves current behavior. -2. Wire it where it acts. If it affects pane width, it goes through +2. **Add the field to `==` and `hashCode` when the class has them** + (`PaneConfig` does). A field missing from equality makes two different + configs compare equal — the layout then ignores the change at runtime. + Never compare configs by `identical`: apps construct them inline in + `build`, and identity comparison resets user state (the dragged + divider) on every rebuild. Watch `double.nan` sentinel fields — + NaN == NaN is false (`PaneAnchor` carries the fix). +3. Wire it where it acts. If it affects pane width, it goes through `PaneWidthModel` — never inline width math in a widget (the two widgets must stay identical in resize behavior). -3. If a widget must react to the field changing at runtime, handle it in +4. If a widget must react to the field changing at runtime, handle it in `didUpdateWidget` (see the `paneConfig` reset there). -4. Unit-test the model change; widget-test the visible effect. -5. **No decorative fields.** A config field with no behavior behind it is a +5. Unit-test the model change; widget-test the visible effect. +6. **No decorative fields.** A config field with no behavior behind it is a lie — this package once shipped `anchors` / `resizeMode` / `isSettling` unimplemented; they're real now. Don't regress the standard. -## §5 — Adding a layout widget +## §7 — Adding a layout widget 1. New folder under `src/core//` (peer of `list_detail/`, `split/`). 2. Reuse the shared vocabulary: `AdaptiveLayoutConfig.resolveBreakpoint`, @@ -102,10 +352,12 @@ JUMPS to 1.0 (no re-animation) — the detail was already visible. 4. Barrel export, mirrored test folder, roadmap rows, architecture tree update. -## §6 — Changing the API surface +## §8 — Changing the API surface -Consumers use `path:` dependencies, so breaks surface instantly at their -next analyze. After any signature change: run the check sequence here, then +Pre-1.0, the minor version is the breaking axis — bump it for any breaking +change and say so in the changelog. Workspace consumers use `path:` +dependencies, so breaks surface at their next analyze. After any signature +change: run the check sequence here, then `fvm flutter analyze` in each consuming app (the workspace grep for `adaptive_layouts` finds them). Update the example's usage in the same session — it is the reference consumers copy from. @@ -120,5 +372,5 @@ session — it is the reference consumers copy from. | Assertion: OverlayPortalController show/hide during layout | Someone reintroduced portal toggling — restore the always-showing shape (§1.1) | | Duplicate GlobalKey crash on resize | A pane is built in both layouts within one frame — check the mode branches only ever mount one copy | | Detail state resets on window resize | GlobalKey chain broken (§2) | -| Divider ignores drags | The 24px hit zone is positioned at `paneWidth - 12`; check the width actually read from `PaneWidthModel` | +| Divider ignores drags | The hit zone (`dividerHitWidth`, default 24) is centered on the pane border; check the width actually read from `PaneWidthModel`, and whether a collapsed pane parked it at the window edge | | Snap lands at the wrong place | Anchor positions clamp by min/max — verify against `pane_width_model_test.dart` expectations | diff --git a/example/lib/main.dart b/example/lib/main.dart index 3f421c1..0b88760 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,7 +8,7 @@ // └── RootShellScreen persistent status bar ABOVE the router // └── AppShellScreen domain tabs (bottom nav ↔ rail at 720px) // ├── Work domain secondary tab bar (Tickets / Directory / Prefs) -// │ ├── Tickets ListDetailRouter → overlay detail + modals +// │ ├── Tickets ListDetailRouter → overlay detail + adaptive modals // │ ├── Directory ANOTHER nested tab router, 4 segments, // │ │ each segment its own overlay list-detail // │ └── Prefs plain settings tab @@ -19,12 +19,13 @@ // └── Admin domain (Workspace / Integrations / Appearance / Advanced) // └── 4 tabs each a multi-type list-detail // -// Every list-detail runs CompactDetailMode.overlay, so on phone widths the +// List-details default to CompactDetailMode.overlay (switchable live from +// the ⚙ package-settings panel in the status strip), so on phone widths the // detail pane covers the tab bars and bottom nav — while inactive domains // stay mounted (tab routers keep state), which is exactly the case the // package's paint-visibility suppression exists for. // -// Also exercised: deep links straight into modals and details (type a URL on +// Also exercised: deep links straight into details (type a URL on // web), swipe-to-dismiss, back-gesture interception, divider drag, window // resize across the breakpoint with widget-state preservation (type a comment // draft in a ticket, resize, the draft survives). @@ -33,7 +34,6 @@ import 'package:adaptive_layouts/adaptive_layouts.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide ModalRoute; -import 'package:flutter/rendering.dart' show RenderProxyBox; import 'package:flutter/scheduler.dart'; part 'main.gr.dart'; @@ -54,19 +54,51 @@ class _ExampleAppState extends State { @override Widget build(BuildContext context) { - return MaterialApp.router( - title: 'adaptive_layouts example', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.teal, - brightness: Brightness.dark, + return ListenableBuilder( + listenable: PackageSettings.instance, + builder: (context, _) => AdaptiveLayoutConfig( + expandedBreakpoint: PackageSettings.instance.expandedBreakpoint, + child: MaterialApp.router( + title: 'adaptive_layouts example', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.teal, + brightness: Brightness.dark, + ), + ), + routerConfig: _router.config(), + // App chrome that must outlive EVERY route lives here, above + // the Navigator — a page can never do this: any push (a + // route-mode detail, a modal) covers page content. The chrome + // level hosts its OWN Overlay: floating pieces up here + // (tooltips) can't reach the app's overlay, which lives below, + // inside `child`. The strip's actions run through the + // navigator's own context, since the builder context sits + // above it. + builder: (context, child) => Overlay( + initialEntries: [ + OverlayEntry( + builder: (context) => Material( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + StatusStrip( + navigatorContext: () => + _router.navigatorKey.currentContext!, + ), + Expanded(child: child!), + ], + ), + ), + ), + ], + ), ), ), - routerConfig: _router.config(deepLinkBuilder: handleDeepLink), ); } } @@ -76,16 +108,312 @@ class _ExampleAppState extends State { // // One breakpoint for the whole app: below it the shell uses a bottom nav and // detail panes slide over; at or above it the shell uses a rail and panes go -// side-by-side. Matches the package's default (720). +// side-by-side. Driven by the package settings panel (⚙ in the status strip) +// through an app-level AdaptiveLayoutConfig. // ============================================================================= -const double kExpandedBreakpoint = 720; - extension BreakpointX on BuildContext { - bool get isExpanded => MediaQuery.sizeOf(this).width >= kExpandedBreakpoint; + bool get isExpanded => + MediaQuery.sizeOf(this).width >= + (AdaptiveLayoutConfig.maybeOf(this)?.expandedBreakpoint ?? + AdaptiveLayoutConfig.defaultExpandedBreakpoint); bool get isCompact => !isExpanded; } +// ============================================================================= +// PACKAGE SETTINGS +// +// Live-tweakable package configuration, so every knob the package offers can +// be exercised while playing with the app. Opened from the ⚙ in the status +// strip — as an adaptive modal, so the settings panel itself demos the modal. +// ============================================================================= + +enum DividerStyle { handle, line, none } + +class PackageSettings extends ChangeNotifier { + PackageSettings._(); + static final PackageSettings instance = PackageSettings._(); + + // List-detail + CompactDetailMode compactDetailMode = CompactDetailMode.overlay; + DividerStyle divider = DividerStyle.handle; + PaneResizeMode resizeMode = PaneResizeMode.ratio; + bool anchorsEnabled = false; + ExpandedEntryStyle entryStyle = ExpandedEntryStyle.reveal; + PaneWidthMemory widthMemory = PaneWidthMemory.persist; + ExpandedEmptyBehavior emptyBehavior = ExpandedEmptyBehavior.placeholder; + PaneCollapsible collapsible = PaneCollapsible.none; + bool collapseToIconRail = false; + bool autoSelectFirst = false; + double expandedBreakpoint = 720; + bool handleBackGesture = true; + int slideDurationMs = 300; + + // Adaptive modal + bool modalMorph = true; + int modalMorphDurationMs = 350; + bool modalDragHandle = true; + bool modalPinnedColor = false; + bool modalBarrierDismissible = true; + + DividerBuilder? get dividerBuilder => switch (divider) { + DividerStyle.handle => HandleDivider.builder, + DividerStyle.line => MaterialDivider.builder, + DividerStyle.none => null, + }; + + PaneConfig get paneConfig => PaneConfig( + resizeMode: resizeMode, + entryStyle: entryStyle, + widthMemory: widthMemory, + anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], + collapsible: collapsible, + collapsedSize: collapseToIconRail ? 56 : 0, + ); + + CompactConfig get compactConfig => CompactConfig( + duration: Duration(milliseconds: slideDurationMs), + handleBackGesture: handleBackGesture, + ); + + ModalConfig get modalConfig => ModalConfig( + morph: modalMorph, + morphDuration: Duration(milliseconds: modalMorphDurationMs), + showDragHandle: modalDragHandle, + backgroundColor: modalPinnedColor ? const Color(0xFF223038) : null, + barrierDismissible: modalBarrierDismissible, + ); + + void update(void Function(PackageSettings s) change) { + change(this); + notifyListeners(); + } +} + +/// Opens the settings panel — as an adaptive modal, so the panel itself +/// exercises the modal with whatever modal settings are currently chosen. +void showPackageSettings(BuildContext context) { + showAdaptiveModal( + context: context, + config: PackageSettings.instance.modalConfig, + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 460 : double.infinity, + child: const PackageSettingsPanel(), + ), + ); +} + +class PackageSettingsPanel extends StatelessWidget { + const PackageSettingsPanel({super.key}); + + Widget _section(BuildContext context, String title) => Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: Text(title, style: Theme.of(context).textTheme.titleSmall), + ); + + Widget _choice({ + required BuildContext context, + required String label, + required List values, + required T value, + required String Function(T) name, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(label, style: Theme.of(context).textTheme.labelMedium), + ), + SegmentedButton( + segments: [ + for (final v in values) + ButtonSegment(value: v, label: Text(name(v))), + ], + selected: {value}, + showSelectedIcon: false, + onSelectionChanged: (selection) => onChanged(selection.first), + ), + ], + ), + ); + } + + Widget _toggle({ + required String label, + required bool value, + required ValueChanged onChanged, + }) { + return SwitchListTile( + title: Text(label), + value: value, + dense: true, + contentPadding: EdgeInsets.zero, + onChanged: onChanged, + ); + } + + @override + Widget build(BuildContext context) { + final s = PackageSettings.instance; + return ListenableBuilder( + listenable: s, + builder: (context, _) => SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Package settings', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'Everything applies live — resize, select, and dismiss while ' + 'flipping these. This panel is itself an adaptive modal. ' + 'Options appear only when the current choices use them.', + style: Theme.of(context).textTheme.bodySmall, + ), + _section(context, 'Layout'), + _choice( + context: context, + label: 'Expanded breakpoint', + values: const [600.0, 720.0, 840.0], + value: s.expandedBreakpoint, + name: (double v) => v.toInt().toString(), + onChanged: (v) => s.update((s) => s.expandedBreakpoint = v), + ), + _section(context, 'Compact detail'), + _choice( + context: context, + label: 'Mode', + values: CompactDetailMode.values, + value: s.compactDetailMode, + name: (CompactDetailMode v) => v.name, + onChanged: (v) => s.update((s) => s.compactDetailMode = v), + ), + // Route mode delegates back handling and transitions to the + // real route — these two only exist for the slide-over modes. + if (s.compactDetailMode != CompactDetailMode.route) ...[ + _toggle( + label: 'Intercept back gesture', + value: s.handleBackGesture, + onChanged: (v) => s.update((s) => s.handleBackGesture = v), + ), + _choice( + context: context, + label: 'Slide duration (ms)', + values: const [200, 300, 500], + value: s.slideDurationMs, + name: (int v) => '$v', + onChanged: (v) => s.update((s) => s.slideDurationMs = v), + ), + ], + _section(context, 'Expanded panes'), + _choice( + context: context, + label: 'Divider', + values: DividerStyle.values, + value: s.divider, + name: (DividerStyle v) => v.name, + onChanged: (v) => s.update((s) => s.divider = v), + ), + _choice( + context: context, + label: 'Resize mode', + values: PaneResizeMode.values, + value: s.resizeMode, + name: (PaneResizeMode v) => v.name, + onChanged: (v) => s.update((s) => s.resizeMode = v), + ), + _toggle( + label: 'Divider snap anchors', + value: s.anchorsEnabled, + onChanged: (v) => s.update((s) => s.anchorsEnabled = v), + ), + _choice( + context: context, + label: 'Expand-entry list style', + values: ExpandedEntryStyle.values, + value: s.entryStyle, + name: (ExpandedEntryStyle v) => v.name, + onChanged: (v) => s.update((s) => s.entryStyle = v), + ), + _choice( + context: context, + label: 'Divider width memory', + values: PaneWidthMemory.values, + value: s.widthMemory, + name: (PaneWidthMemory v) => v.name, + onChanged: (v) => s.update((s) => s.widthMemory = v), + ), + _choice( + context: context, + label: 'Empty detail slot', + values: ExpandedEmptyBehavior.values, + value: s.emptyBehavior, + name: (ExpandedEmptyBehavior v) => v.name, + onChanged: (v) => s.update((s) => s.emptyBehavior = v), + ), + _choice( + context: context, + label: 'Collapsible panes (drag past the limit)', + values: PaneCollapsible.values, + value: s.collapsible, + name: (PaneCollapsible v) => v.name, + onChanged: (v) => s.update((s) => s.collapsible = v), + ), + _toggle( + label: 'Collapse to 56px icon rail (vs fully hidden)', + value: s.collapseToIconRail, + onChanged: (v) => s.update((s) => s.collapseToIconRail = v), + ), + _toggle( + label: 'Auto-select first item (app-side recipe)', + value: s.autoSelectFirst, + onChanged: (v) => s.update((s) => s.autoSelectFirst = v), + ), + _section(context, 'Adaptive modal'), + _toggle( + label: 'Container-transform morph', + value: s.modalMorph, + onChanged: (v) => s.update((s) => s.modalMorph = v), + ), + if (s.modalMorph) + _choice( + context: context, + label: 'Morph duration (ms)', + values: const [350, 700, 1400], + value: s.modalMorphDurationMs, + name: (int v) => '$v', + onChanged: (v) => s.update((s) => s.modalMorphDurationMs = v), + ), + _toggle( + label: 'Sheet drag handle', + value: s.modalDragHandle, + onChanged: (v) => s.update((s) => s.modalDragHandle = v), + ), + _toggle( + label: 'Pin one surface color across forms', + value: s.modalPinnedColor, + onChanged: (v) => s.update((s) => s.modalPinnedColor = v), + ), + _toggle( + label: 'Barrier dismissible', + value: s.modalBarrierDismissible, + onChanged: (v) => s.update((s) => s.modalBarrierDismissible = v), + ), + ], + ), + ), + ); + } +} + // ============================================================================= // DEMO DATA // @@ -393,8 +721,6 @@ abstract final class Paths { static const work = 'work'; static const tickets = 'tickets'; static const ticketWithParam = ':${Params.ticketId}'; - static const newTicketPath = 'work/tickets/new'; - static const ticketOptionsPath = 'work/tickets/:${Params.ticketId}/options'; static const directory = 'directory'; static const prefs = 'prefs'; static const people = 'people'; @@ -468,8 +794,6 @@ abstract final class Params { // Route structure: // ``` // / -// ├── /work/tickets/new (modal — handled by deepLinkBuilder) -// ├── /work/tickets/:ticketId/options (modal — handled by deepLinkBuilder) // └── (app shell) // ├── /work // │ ├── /tickets/:ticketId @@ -508,17 +832,6 @@ class AppRouter extends RootStackRouter { page: RootShellRoute.page, path: Paths.root, children: [ - // Modals — registered at root level so the paths resolve; the - // deepLinkBuilder builds the stack (content + modal) for cold links. - AdaptiveModalRoute( - page: NewTicketRoute.page, - path: Paths.newTicketPath, - ), - AdaptiveModalRoute( - page: TicketOptionsRoute.page, - path: Paths.ticketOptionsPath, - ), - // App shell — domain navigation (bottom nav / rail). AutoRoute( page: AppShellRoute.page, @@ -716,370 +1029,6 @@ class AppRouter extends RootStackRouter { ]; } -// ============================================================================= -// DEEP LINK HANDLER -// -// A deep link straight to a modal (like /work/tickets/new) must build the -// FULL route stack so the app content renders behind the modal. Without this, -// a cold link shows only the modal with nothing behind it. -// ============================================================================= - -DeepLink handleDeepLink(PlatformDeepLink deepLink) { - final path = deepLink.path; - - for (final modal in _modalDeepLinks) { - final match = modal.matches(path); - if (match != null) { - return DeepLink([modal.buildNestedRoute(match)]); - } - } - - return deepLink; -} - -abstract class _ModalDeepLink { - Map? matches(String path); - PageRouteInfo buildNestedRoute(Map params); -} - -class _StaticModalDeepLink extends _ModalDeepLink { - final String path; - final PageRouteInfo Function() _build; - - _StaticModalDeepLink({ - required this.path, - required PageRouteInfo Function() build, - }) : _build = build; - - @override - Map? matches(String inputPath) => - inputPath == path ? {} : null; - - @override - PageRouteInfo buildNestedRoute(Map params) => _build(); -} - -class _DynamicModalDeepLink extends _ModalDeepLink { - final RegExp pattern; - final List paramNames; - final PageRouteInfo Function(Map params) _build; - - _DynamicModalDeepLink({ - required this.pattern, - required this.paramNames, - required PageRouteInfo Function(Map params) build, - }) : _build = build; - - @override - Map? matches(String path) { - final match = pattern.firstMatch(path); - if (match == null) return null; - final params = {}; - for (var i = 0; i < paramNames.length; i++) { - params[paramNames[i]] = match.group(i + 1)!; - } - return params; - } - - @override - PageRouteInfo buildNestedRoute(Map params) => _build(params); -} - -final _modalDeepLinks = <_ModalDeepLink>[ - // New ticket modal: /work/tickets/new — tickets list behind, modal on top. - _StaticModalDeepLink( - path: '/work/tickets/new', - build: () => RootShellRoute( - children: [ - AppShellRoute( - children: [ - WorkDomainRoute(children: [TicketsTabRoute()]), - ], - ), - NewTicketRoute(), - ], - ), - ), - - // Ticket options modal: /work/tickets/:ticketId/options — ticket selected - // behind the modal. - _DynamicModalDeepLink( - pattern: RegExp(r'^/work/tickets/([^/]+)/options$'), - paramNames: [Params.ticketId], - build: (params) { - final ticketId = params[Params.ticketId]!; - return RootShellRoute( - children: [ - AppShellRoute( - children: [ - WorkDomainRoute( - children: [ - TicketsTabRoute(children: [TicketRoute(id: ticketId)]), - ], - ), - ], - ), - TicketOptionsRoute(ticketId: ticketId), - ], - ); - }, - ), -]; - -// ============================================================================= -// ADAPTIVE MODAL ROUTE -// -// Dialog on expanded widths, bottom sheet on compact — and it live-transforms -// between the two when the window resizes across the breakpoint, preserving -// the content widget's state. -// ============================================================================= - -class AdaptiveModalRoute extends CustomRoute { - AdaptiveModalRoute({required super.page, super.path}) - : super(customRouteBuilder: _adaptiveBuilder, fullscreenDialog: true); - - static Route _adaptiveBuilder( - BuildContext context, - Widget child, - AutoRoutePage page, - ) { - return _AdaptiveModalPageRoute(settings: page, child: child); - } -} - -class _AdaptiveModalPageRoute extends PopupRoute { - _AdaptiveModalPageRoute({ - required this.child, - required RouteSettings settings, - }) : super(settings: settings); - - final Widget child; - - @override - Color? get barrierColor => Colors.black54; - - @override - String? get barrierLabel => 'Dismiss'; - - @override - bool get barrierDismissible => true; - - @override - Duration get transitionDuration => const Duration(milliseconds: 300); - - @override - Duration get reverseTransitionDuration => const Duration(milliseconds: 250); - - @override - Widget buildPage( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) { - return _AdaptiveModalPage(route: this, child: child); - } -} - -class _AdaptiveModalPage extends StatefulWidget { - final _AdaptiveModalPageRoute route; - final Widget child; - - const _AdaptiveModalPage({required this.route, required this.child}); - - @override - State<_AdaptiveModalPage> createState() => _AdaptiveModalPageState(); -} - -class _AdaptiveModalPageState extends State<_AdaptiveModalPage> - with SingleTickerProviderStateMixin { - late AnimationController _dragController; - double _sheetHeight = 400; - - @override - void initState() { - super.initState(); - _dragController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 250), - value: 0, - ); - } - - @override - void dispose() { - _dragController.dispose(); - super.dispose(); - } - - void _onDragStart(DragStartDetails details) => _dragController.stop(); - - void _onDragUpdate(DragUpdateDetails details) { - if (_sheetHeight <= 0) return; - final delta = details.primaryDelta ?? 0; - _dragController.value = (_dragController.value + delta / _sheetHeight) - .clamp(0.0, 1.0); - } - - void _onDragEnd(DragEndDetails details) { - final velocity = details.primaryVelocity ?? 0; - if (velocity > 300 || _dragController.value > 0.4) { - _dragController.forward().then((_) { - if (mounted) Navigator.of(context).pop(); - }); - } else { - _dragController.reverse(); - } - } - - @override - Widget build(BuildContext context) { - final animation = widget.route.animation!; - return context.isExpanded - ? _buildDialog(context, animation) - : _buildBottomSheet(context, animation); - } - - Widget _buildDialog(BuildContext context, Animation animation) { - final colorScheme = Theme.of(context).colorScheme; - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - - return SafeArea( - child: Center( - child: FadeTransition( - opacity: curved, - child: ScaleTransition( - scale: Tween(begin: 0.9, end: 1.0).animate(curved), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 560, - maxHeight: MediaQuery.sizeOf(context).height * 0.85, - ), - child: Material( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - clipBehavior: Clip.antiAlias, - elevation: 6, - child: widget.child, - ), - ), - ), - ), - ), - ); - } - - Widget _buildBottomSheet(BuildContext context, Animation animation) { - final colorScheme = Theme.of(context).colorScheme; - final bottomPadding = MediaQuery.viewInsetsOf(context).bottom; - final screenHeight = MediaQuery.sizeOf(context).height; - final slide = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - - return AnimatedBuilder( - animation: Listenable.merge([slide, _dragController]), - builder: (context, child) { - final totalOffset = - (1 - slide.value) + (slide.value * _dragController.value); - - return Align( - alignment: Alignment.bottomCenter, - child: Transform.translate( - offset: Offset(0, totalOffset * _sheetHeight), - child: Padding( - padding: EdgeInsets.only(bottom: bottomPadding), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: screenHeight * 0.9), - child: _SheetHeightReporter( - onHeightChanged: (h) => _sheetHeight = h, - child: Material( - color: colorScheme.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(28), - ), - clipBehavior: Clip.antiAlias, - elevation: 8, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onVerticalDragStart: _onDragStart, - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: _onDragEnd, - behavior: HitTestBehavior.opaque, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12), - child: Center( - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ), - ), - Flexible(child: widget.child), - ], - ), - ), - ), - ), - ), - ), - ); - }, - ); - } -} - -class _SheetHeightReporter extends SingleChildRenderObjectWidget { - final ValueChanged onHeightChanged; - - const _SheetHeightReporter({ - required this.onHeightChanged, - required Widget child, - }) : super(child: child); - - @override - RenderObject createRenderObject(BuildContext context) => - _RenderSheetHeightReporter(onHeightChanged); - - @override - void updateRenderObject( - BuildContext context, - _RenderSheetHeightReporter renderObject, - ) { - renderObject.onHeightChanged = onHeightChanged; - } -} - -class _RenderSheetHeightReporter extends RenderProxyBox { - ValueChanged onHeightChanged; - - _RenderSheetHeightReporter(this.onHeightChanged); - - @override - void performLayout() { - super.performLayout(); - if (hasSize && size.height > 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - onHeightChanged(size.height); - }); - } - } -} - // ============================================================================= // TAB ROUTERS // @@ -1152,7 +1101,7 @@ Widget domainTab({required IconData icon, required String label}) { // // The bridge between ListDetailLayout and auto_route: URL → controller on // deep links / back-forward, controller → URL on select / dismiss. Both run -// the package in CompactDetailMode.overlay. +// the package in whichever CompactDetailMode the ⚙ settings panel selects. // ============================================================================= /// Single entity type per tab (tickets, people, builds...). @@ -1162,14 +1111,18 @@ class ListDetailRouter extends StatefulWidget { final ListPaneBuilder listBuilder; final DetailPaneBuilder detailBuilder; final WidgetBuilder? emptyStateBuilder; - final DividerBuilder? dividerBuilder; - final double? expandedBreakpoint; - final PaneConfig paneConfig; - final CompactConfig compactConfig; + final WidgetBuilder? collapsedListBuilder; + final WidgetBuilder? collapsedDetailBuilder; /// When provided, auto-clears selection if the entity no longer exists. final bool Function(String id)? selectedIdExists; + /// First item for the auto-select school (the ⚙ toggle): when set and + /// the toggle is on, an empty expanded layout selects this item — the + /// Notes/Slack pattern, implemented app-side with the controller. + /// Returns null when the list is empty (nothing to select). + final String? Function()? firstItemId; + const ListDetailRouter({ super.key, this.idParamName = 'id', @@ -1177,11 +1130,10 @@ class ListDetailRouter extends StatefulWidget { required this.listBuilder, required this.detailBuilder, this.emptyStateBuilder, - this.dividerBuilder, - this.expandedBreakpoint, - this.paneConfig = const PaneConfig(), - this.compactConfig = const CompactConfig(), + this.collapsedListBuilder, + this.collapsedDetailBuilder, this.selectedIdExists, + this.firstItemId, }); @override @@ -1305,18 +1257,48 @@ class _ListDetailRouterState extends State { @override Widget build(BuildContext context) { - return ListDetailLayout( - controller: _controller, - listBuilder: widget.listBuilder, - detailBuilder: widget.detailBuilder, - emptyStateBuilder: widget.emptyStateBuilder, - dividerBuilder: widget.dividerBuilder, - expandedBreakpoint: widget.expandedBreakpoint, - paneConfig: widget.paneConfig, - compactConfig: widget.compactConfig, - compactDetailMode: CompactDetailMode.overlay, + // Layout configuration comes from the live package settings (⚙ in the + // status strip). The ListenableBuilder is load-bearing: routed page + // subtrees don't rebuild from app-level rebuilds, so each layout + // listens to the settings itself. + return ListenableBuilder( + listenable: PackageSettings.instance, + builder: (context, _) { + final settings = PackageSettings.instance; + _maybeAutoSelectFirst(context, settings); + return ListDetailLayout( + controller: _controller, + listBuilder: widget.listBuilder, + detailBuilder: widget.detailBuilder, + emptyStateBuilder: widget.emptyStateBuilder, + collapsedListBuilder: widget.collapsedListBuilder, + collapsedDetailBuilder: widget.collapsedDetailBuilder, + dividerBuilder: settings.dividerBuilder, + paneConfig: settings.paneConfig, + compactConfig: settings.compactConfig, + compactDetailMode: settings.compactDetailMode, + expandedEmptyBehavior: settings.emptyBehavior, + ); + }, ); } + + /// The auto-select school, app-side: when the expanded layout would + /// show an empty slot, select the first item instead (Notes, Slack). + /// Deliberately reselects after dismiss too — that IS the pattern: + /// these apps never show emptiness at a wide window. + void _maybeAutoSelectFirst(BuildContext context, PackageSettings settings) { + if (!settings.autoSelectFirst || widget.firstItemId == null) return; + if (_controller.hasSelection) return; + final width = MediaQuery.sizeOf(context).width; + if (width < AdaptiveLayoutConfig.resolveBreakpoint(context, null)) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _controller.hasSelection) return; + if (!PackageSettings.instance.autoSelectFirst) return; + final id = widget.firstItemId!(); + if (id != null) _controller.select(id); + }); + } } /// Multi-type selection (settings-style screens where the list mixes entity @@ -1365,20 +1347,12 @@ class MultiTypeListDetailRouter extends StatefulWidget { final Map types; final MultiTypeListBuilder listBuilder; final WidgetBuilder? emptyStateBuilder; - final DividerBuilder? dividerBuilder; - final double? expandedBreakpoint; - final PaneConfig paneConfig; - final CompactConfig compactConfig; const MultiTypeListDetailRouter({ super.key, required this.types, required this.listBuilder, this.emptyStateBuilder, - this.dividerBuilder, - this.expandedBreakpoint, - this.paneConfig = const PaneConfig(), - this.compactConfig = const CompactConfig(), }); @override @@ -1555,23 +1529,28 @@ class _MultiTypeListDetailRouterState extends State { ? widget.types[_selection.activeType] : null; - return ListDetailLayout( - controller: _controller, - listBuilder: (context, selectedId, onSelect) { - return widget.listBuilder(context, _selection, _onListSelect); - }, - detailBuilder: (context, id, mode, onDismiss) { - if (activeConfig != null) { - return activeConfig.detailBuilder(context, id, mode, onDismiss); - } - return const SizedBox.shrink(); + return ListenableBuilder( + listenable: PackageSettings.instance, + builder: (context, _) { + final settings = PackageSettings.instance; + return ListDetailLayout( + controller: _controller, + listBuilder: (context, selectedId, onSelect) { + return widget.listBuilder(context, _selection, _onListSelect); + }, + detailBuilder: (context, id, mode, onDismiss) { + if (activeConfig != null) { + return activeConfig.detailBuilder(context, id, mode, onDismiss); + } + return const SizedBox.shrink(); + }, + emptyStateBuilder: widget.emptyStateBuilder, + dividerBuilder: settings.dividerBuilder, + paneConfig: settings.paneConfig, + compactConfig: settings.compactConfig, + compactDetailMode: settings.compactDetailMode, + ); }, - emptyStateBuilder: widget.emptyStateBuilder, - dividerBuilder: widget.dividerBuilder, - expandedBreakpoint: widget.expandedBreakpoint, - paneConfig: widget.paneConfig, - compactConfig: widget.compactConfig, - compactDetailMode: CompactDetailMode.overlay, ); } } @@ -1580,34 +1559,29 @@ class _MultiTypeListDetailRouterState extends State { // SHELLS // ============================================================================= -/// Root shell — a persistent zone that sits ABOVE the router, so route swaps -/// and overlay details never cover or remount it. +/// Root shell — the top page of the root stack. The persistent strip is +/// NOT here: a page can be covered by anything pushed above it, so the +/// strip lives in `MaterialApp.builder`, above the Navigator itself. @RoutePage() class RootShellScreen extends StatelessWidget { const RootShellScreen({super.key}); @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Material( - color: colorScheme.surface, - child: const Column( - children: [ - StatusStrip(), - Expanded(child: AutoRouter()), - ], - ), - ); + return const AutoRouter(); } } /// The persistent strip: shows the active deploy when one is running. -/// Rendered outside the nested router, so overlay-mode details (which live in -/// the nested Navigator's overlay) slide UNDER it — that layering is part of -/// the contract being demonstrated. +/// Mounted in `MaterialApp.builder` — above the app's Navigator — so no +/// route, detail page, or modal can ever cover it. class StatusStrip extends StatelessWidget { - const StatusStrip({super.key}); + const StatusStrip({super.key, required this.navigatorContext}); + + /// The navigator's context, for actions that present routes (the ⚙ + /// settings modal): the strip's own context sits ABOVE the Navigator + /// and cannot push into it. + final BuildContext Function() navigatorContext; @override Widget build(BuildContext context) { @@ -1648,6 +1622,12 @@ class StatusStrip extends StatelessWidget { visualDensity: VisualDensity.compact, onPressed: () => Store.instance.deploying.value = null, ), + IconButton( + icon: const Icon(Icons.tune, size: 16), + visualDensity: VisualDensity.compact, + tooltip: 'Package settings', + onPressed: () => showPackageSettings(navigatorContext()), + ), const SizedBox(width: 4), ], ), @@ -1835,6 +1815,9 @@ class DemoListPane extends StatelessWidget { ), floatingActionButton: !isExpandedLayout && onNew != null ? FloatingActionButton( + // Several list panels coexist in kept-alive tabs; default + // FAB hero tags collide the moment any route pushes. + heroTag: null, onPressed: onNew, child: const Icon(Icons.add), ) @@ -2205,19 +2188,26 @@ class TicketsTabScreen extends StatelessWidget { builder: (context, tickets, _) { return ListDetailRouter( idParamName: Params.ticketId, - paneConfig: const PaneConfig( - defaultListWidth: 300, - minListWidth: 240, - maxListRatio: 0.4, - ), - dividerBuilder: HandleDivider.builder, selectedIdExists: (id) => tickets.any((t) => t.id == id), + firstItemId: () => tickets.isEmpty ? null : tickets.first.id, listBuilder: (context, selectedId, onSelect) => DemoListPane( collection: store.tickets, selectedId: selectedId, onSelect: onSelect, newLabel: 'New ticket', - onNew: () => context.router.push(const NewTicketRoute()), + onNew: () async { + final id = await showAdaptiveModal( + context: context, + config: PackageSettings.instance.modalConfig, + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 420 : double.infinity, + child: const NewTicketScreen(), + ), + ); + if (id != null && context.mounted) { + context.router.navigatePath('/work/tickets/$id'); + } + }, emptyIcon: Icons.confirmation_number_outlined, ), detailBuilder: (context, id, mode, onDismiss) => @@ -2226,12 +2216,87 @@ class TicketsTabScreen extends StatelessWidget { icon: Icons.confirmation_number_outlined, message: 'Select a ticket', ), + // The icon-rail recipe: with "collapse to icon rail" on, a + // snapped-shut list renders THIS instead of clipped content — + // laid out at the real 56px slot. Tapping an icon restores the + // list and selects that ticket. + collapsedListBuilder: (context) => CollapsedIconRail( + items: [ + for (final t in tickets) + CollapsedRailItem( + icon: t.icon, + tooltip: t.name, + onTap: () => + context.router.navigatePath('/work/tickets/${t.id}'), + ), + ], + ), + collapsedDetailBuilder: (context) => const CollapsedIconRail(), ); }, ); } } +/// A 56px icon rail for a collapsed pane — the VS Code activity-bar +/// shape. Reads [PaneScope] for the restore action; item taps restore +/// AND select, so the rail stays a functional mini version of the list. +class CollapsedRailItem { + const CollapsedRailItem({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + final IconData icon; + final String tooltip; + final VoidCallback onTap; +} + +class CollapsedIconRail extends StatelessWidget { + const CollapsedIconRail({super.key, this.items = const []}); + + final List items; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final scope = PaneScope.of(context); + return Material( + color: colorScheme.surfaceContainerLow, + child: Column( + children: [ + const SizedBox(height: 8), + IconButton( + icon: Icon( + scope.collapsed == PaneSide.start + ? Icons.keyboard_double_arrow_right + : Icons.keyboard_double_arrow_left, + ), + tooltip: 'Expand', + onPressed: scope.restore, + ), + const Divider(height: 16, indent: 12, endIndent: 12), + Expanded( + child: ListView( + children: [ + for (final item in items) + IconButton( + icon: Icon(item.icon, size: 20), + tooltip: item.tooltip, + color: colorScheme.onSurfaceVariant, + // Navigate without expanding — a rail is a mini + // list (the Discord model). Only the chevron expands. + onPressed: item.onTap, + ), + ], + ), + ), + ], + ), + ); + } +} + /// The ticket thread pane. Comment draft + thread live in this State — /// resize the window across the breakpoint and both survive, because the /// package reparents the detail subtree via GlobalKey instead of rebuilding. @@ -2281,6 +2346,10 @@ class _TicketPaneState extends State { widget.ticketId, ); + // The hamburger recipe: when the list pane snap-collapses, the + // surviving detail pane reads PaneScope and offers a restore button. + final paneScope = PaneScope.maybeOf(context); + return Scaffold( appBar: AppBar( leading: widget.mode == DetailLayoutMode.stacked @@ -2288,7 +2357,17 @@ class _TicketPaneState extends State { icon: const Icon(Icons.arrow_back), onPressed: widget.onDismiss, ) - : null, + // Only when the list is FULLY hidden — a visible icon rail + // already carries the expand control; duplicating it here + // would be two affordances for one action. + : (paneScope?.collapsed == PaneSide.start && + paneScope!.collapsedSize == 0 + ? IconButton( + icon: const Icon(Icons.view_sidebar_outlined), + tooltip: 'Show list', + onPressed: paneScope.restore, + ) + : null), automaticallyImplyLeading: false, title: Row( children: [ @@ -2309,8 +2388,13 @@ class _TicketPaneState extends State { IconButton( icon: const Icon(Icons.more_horiz), tooltip: 'Ticket options (modal)', - onPressed: () => context.router.push( - TicketOptionsRoute(ticketId: widget.ticketId), + onPressed: () => showAdaptiveModal( + context: context, + config: PackageSettings.instance.modalConfig, + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 420 : double.infinity, + child: TicketOptionsScreen(ticketId: widget.ticketId), + ), ), ), if (widget.mode == DetailLayoutMode.sideBySide) @@ -2447,7 +2531,6 @@ class WorkPrefsTabScreen extends StatelessWidget { // ── Work modals ── -@RoutePage() class NewTicketScreen extends StatefulWidget { const NewTicketScreen({super.key}); @@ -2477,9 +2560,9 @@ class _NewTicketScreenState extends State { icon: Icons.fiber_new_outlined, ), ); - // Close the modal, then navigate into the new ticket (list-detail URL). - Navigator.of(context).pop(); - context.router.navigatePath('/work/tickets/$id'); + // Pop with the id as the modal result — the opener navigates. The + // result future survives any dialog ↔ sheet swaps in between. + Navigator.of(context).pop(id); } @override @@ -2520,12 +2603,8 @@ class _NewTicketScreenState extends State { } } -@RoutePage() class TicketOptionsScreen extends StatelessWidget { - const TicketOptionsScreen({ - super.key, - @PathParam(Params.ticketId) required this.ticketId, - }); + const TicketOptionsScreen({super.key, required this.ticketId}); final String ticketId; @@ -2650,7 +2729,6 @@ class _DirectorySegment extends StatelessWidget { builder: (context, items, _) { return ListDetailRouter( idParamName: idParamName, - dividerBuilder: HandleDivider.builder, selectedIdExists: (id) => items.any((i) => i.id == id), listBuilder: (context, selectedId, onSelect) => DemoListPane( collection: collection, @@ -2909,7 +2987,6 @@ class BuildsTabScreen extends StatelessWidget { builder: (context, builds, _) { return ListDetailRouter( idParamName: Params.buildId, - dividerBuilder: HandleDivider.builder, selectedIdExists: (id) => builds.any((b) => b.id == id), listBuilder: (context, selectedId, onSelect) => DemoListPane( collection: store.builds, @@ -2984,7 +3061,6 @@ class SetupTabScreen extends StatelessWidget { valueListenable: store.pipelines, builder: (context, pipelines, _) { return MultiTypeListDetailRouter( - dividerBuilder: HandleDivider.builder, emptyStateBuilder: (_) => const DemoEmptyPane( icon: Icons.tune_outlined, message: 'Select a setting', @@ -3126,7 +3202,6 @@ class WorkspaceTabScreen extends StatelessWidget { final store = Store.instance; return MultiTypeListDetailRouter( - dividerBuilder: HandleDivider.builder, emptyStateBuilder: (_) => const DemoEmptyPane( icon: Icons.business_outlined, message: 'Select a workspace item', @@ -3202,7 +3277,6 @@ class IntegrationsTabScreen extends StatelessWidget { valueListenable: store.services, builder: (context, services, _) { return MultiTypeListDetailRouter( - dividerBuilder: HandleDivider.builder, emptyStateBuilder: (_) => const DemoEmptyPane( icon: Icons.extension_outlined, message: 'Select an integration', @@ -3293,7 +3367,6 @@ class AppearanceTabScreen extends StatelessWidget { @override Widget build(BuildContext context) { return MultiTypeListDetailRouter( - dividerBuilder: HandleDivider.builder, emptyStateBuilder: (_) => const DemoEmptyPane( icon: Icons.palette_outlined, message: 'Select a setting', @@ -3332,7 +3405,6 @@ class AdvancedTabScreen extends StatelessWidget { @override Widget build(BuildContext context) { return MultiTypeListDetailRouter( - dividerBuilder: HandleDivider.builder, emptyStateBuilder: (_) => const DemoEmptyPane( icon: Icons.settings_suggest_outlined, message: 'Select an item', diff --git a/example/lib/main.gr.dart b/example/lib/main.gr.dart index d8297d5..65e1a59 100644 --- a/example/lib/main.gr.dart +++ b/example/lib/main.gr.dart @@ -478,22 +478,6 @@ class MonitorTabRoute extends PageRouteInfo { ); } -/// generated route for -/// [NewTicketScreen] -class NewTicketRoute extends PageRouteInfo { - const NewTicketRoute({List? children}) - : super(NewTicketRoute.name, initialChildren: children); - - static const String name = 'NewTicketRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const NewTicketScreen(); - }, - ); -} - /// generated route for /// [OpsDomainScreen] class OpsDomainRoute extends PageRouteInfo { @@ -1002,58 +986,6 @@ class TeamsTabRoute extends PageRouteInfo { ); } -/// generated route for -/// [TicketOptionsScreen] -class TicketOptionsRoute extends PageRouteInfo { - TicketOptionsRoute({ - Key? key, - required String ticketId, - List? children, - }) : super( - TicketOptionsRoute.name, - args: TicketOptionsRouteArgs(key: key, ticketId: ticketId), - rawPathParams: {'ticketId': ticketId}, - initialChildren: children, - ); - - static const String name = 'TicketOptionsRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - final pathParams = data.inheritedPathParams; - final args = data.argsAs( - orElse: () => - TicketOptionsRouteArgs(ticketId: pathParams.getString('ticketId')), - ); - return TicketOptionsScreen(key: args.key, ticketId: args.ticketId); - }, - ); -} - -class TicketOptionsRouteArgs { - const TicketOptionsRouteArgs({this.key, required this.ticketId}); - - final Key? key; - - final String ticketId; - - @override - String toString() { - return 'TicketOptionsRouteArgs{key: $key, ticketId: $ticketId}'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! TicketOptionsRouteArgs) return false; - return key == other.key && ticketId == other.ticketId; - } - - @override - int get hashCode => key.hashCode ^ ticketId.hashCode; -} - /// generated route for /// [TicketScreen] class TicketRoute extends PageRouteInfo { diff --git a/example/test/journeys/journeys_test.dart b/example/test/journeys/journeys_test.dart index af3dbac..ef4e8de 100644 --- a/example/test/journeys/journeys_test.dart +++ b/example/test/journeys/journeys_test.dart @@ -3,6 +3,7 @@ // overlay-mode details under nested tab routers, URL sync, resize-across- // breakpoint state preservation, and selectedIdExists auto-dismiss. +import 'package:adaptive_layouts/adaptive_layouts.dart'; import 'package:adaptive_layouts_example/main.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart' hide ModalRoute; @@ -192,5 +193,176 @@ void main() { startsWith('/work/tickets/ticket-'), ); }); + + testWidgets('typed text survives the dialog ↔ sheet swap on resize', ( + tester, + ) async { + await pumpApp(tester, size: expanded); + + await tester.tap(find.text('New ticket')); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsOneWidget); + + await tester.enterText(find.byType(TextField).last, 'Half-typed title'); + + // Shrink across the breakpoint: the dialog route is atomically + // replaced by a real bottom sheet route — same content element. + await resize(tester, compact); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.text('Half-typed title'), findsOneWidget); + + // Grow back: sheet becomes a dialog again, text still intact. + await resize(tester, expanded); + expect(find.byType(Dialog), findsOneWidget); + expect(find.text('Half-typed title'), findsOneWidget); + + // The result future survived both swaps: create → navigates. + await tester.tap(find.text('Create ticket')); + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsOneWidget); + expect( + rootRouter(tester).currentPath, + startsWith('/work/tickets/ticket-'), + ); + }); + }); + + group('package settings', () { + testWidgets( + 'route mode: expanded detail, leave via TOP TABS, shrink, return → routed', + (tester) async { + addTearDown(() { + PackageSettings.instance.update( + (s) => s.compactDetailMode = CompactDetailMode.overlay, + ); + }); + await pumpApp(tester, size: expanded); + + // The mode is flipped on the RUNNING app via the ⚙ panel — live + // layouts must wire route mode in didUpdateWidget, not initState. + await tester.tap(find.byTooltip('Package settings')); + await tester.pumpAndSettle(); + await tester.tap(find.text('route')); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Webhook drops events')); + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsOneWidget); + await tester.enterText( + find.descendant( + of: find.byType(TicketPane), + matching: find.byType(TextField), + ), + 'draft comment', + ); + await tester.pump(); + + // Leave through the top tab bar (TabBarView detaches Tickets). + await tester.tap(find.text('Directory')); + await tester.pumpAndSettle(); + + await resize(tester, compact); + + await tester.tap(find.text('Tickets')); + await tester.pumpAndSettle(); + + final rootNavigator = tester.state( + find.byType(Navigator).first, + ); + expect(find.byType(TicketPane), findsOneWidget); + expect(rootNavigator.canPop(), isTrue); // routed, never inline + // The pane instance survived hide → hidden resize → routed return. + expect(find.text('draft comment'), findsOneWidget); + }, + ); + + testWidgets( + 'route mode: expanded detail, leave via NAV RAIL, shrink, return → routed', + (tester) async { + PackageSettings.instance.update( + (s) => s.compactDetailMode = CompactDetailMode.route, + ); + addTearDown(() { + PackageSettings.instance.update( + (s) => s.compactDetailMode = CompactDetailMode.overlay, + ); + }); + await pumpApp(tester, size: expanded); + + await tester.tap(find.text('Webhook drops events')); + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsOneWidget); + + // Leave through the primary destinations (IndexedStack keeps Work + // mounted and laid out, just unpainted). + await tester.tap(find.text('Ops')); + await tester.pumpAndSettle(); + + await resize(tester, compact); + + // Back via the bottom nav (compact width now). + await tester.tap(find.text('Work')); + await tester.pumpAndSettle(); + + final rootNavigator = tester.state( + find.byType(Navigator).first, + ); + expect(find.byType(TicketPane), findsOneWidget); + expect(rootNavigator.canPop(), isTrue); // routed, never inline + }, + ); + + testWidgets('route mode gives the compact detail a real page route', ( + tester, + ) async { + addTearDown(() { + PackageSettings.instance.update( + (s) => s.compactDetailMode = CompactDetailMode.overlay, + ); + }); + await pumpApp(tester, size: compact); + + // Flip the mode from the ⚙ panel (itself an adaptive modal). + await tester.tap(find.byTooltip('Package settings')); + await tester.pumpAndSettle(); + await tester.tap(find.text('route')); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); // close the panel + await tester.pumpAndSettle(); + + final rootNavigator = tester.state( + find.byType(Navigator).first, + ); + expect(rootNavigator.canPop(), isFalse); + + await tester.tap(find.text('Webhook drops events')); + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsOneWidget); + expect(rootRouter(tester).currentPath, '/work/tickets/ticket-hooks'); + // The distinguishing observable vs overlay mode: a REAL route now + // sits on the root navigator. (Overlay mode would pass the back + // assertions below via PopScope — this is what proves route-ness.) + expect(rootNavigator.canPop(), isTrue); + + // The status strip lives in MaterialApp.builder — above the + // Navigator — so the pushed detail cannot cover it and its actions + // stay live. Opening the panel proves it is on top and tappable. + await tester.tap(find.byTooltip('Package settings')); + await tester.pumpAndSettle(); + expect(find.text('Layout'), findsOneWidget); + await tester.binding.handlePopRoute(); // close the panel + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsOneWidget); // detail still routed + + // REAL system back pops the real route; the URL syncs through the + // controller exactly as in the other modes. + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsNothing); + expect(rootRouter(tester).currentPath, '/work/tickets'); + }); }); } diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 4ddb483..bc493a3 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -23,15 +23,24 @@ library; export 'src/core/list_detail/list_detail_layout.dart'; export 'src/core/list_detail/list_detail_controller.dart'; export 'src/core/list_detail/detail_layout_mode.dart'; +export 'src/core/list_detail/expanded_empty_behavior.dart'; export 'src/core/list_detail/compact_config.dart'; -export 'src/core/split/adaptive_split.dart'; +export 'src/core/modal/adaptive_modal.dart'; +export 'src/core/modal/modal_config.dart'; +export 'src/core/modal/modal_layout_mode.dart'; +export 'src/core/split/split_layout.dart'; // Core — shared configuration + vocabulary export 'src/core/shared/adaptive_layout_config.dart'; export 'src/core/shared/divider_builder.dart'; +export 'src/core/shared/expanded_entry_style.dart'; export 'src/core/shared/pane_anchor.dart'; +export 'src/core/shared/pane_collapse.dart'; export 'src/core/shared/pane_config.dart'; +export 'src/core/shared/pane_divider_region.dart'; +export 'src/core/shared/pane_scope.dart'; export 'src/core/shared/pane_resize_mode.dart'; +export 'src/core/shared/pane_width_memory.dart'; // Components — convenience library (replaceable) export 'src/components/dividers/handle_divider.dart'; diff --git a/lib/src/components/dividers/handle_divider.dart b/lib/src/components/dividers/handle_divider.dart index 309a12a..bee79f4 100644 --- a/lib/src/components/dividers/handle_divider.dart +++ b/lib/src/components/dividers/handle_divider.dart @@ -1,14 +1,24 @@ import 'package:flutter/material.dart'; -/// Pane divider with hover feedback, drag handle dots, and settle indicator. +import 'package:adaptive_layouts/src/core/shared/divider_builder.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; + +/// Pane divider with hover feedback, drag handle dots, settle indicator, +/// at-limit tinting, and a pull-tab restore affordance when a pane is +/// collapsed. /// -/// Inspired by macOS split view and Material 3 drag affordances. +/// Inspired by macOS split views and Material 3 drag affordances. /// /// Visual states: /// - **Idle**: thin 1px line in [ColorScheme.outlineVariant] -/// - **Hover/Drag**: thickens to 4px in [ColorScheme.primary], shows +/// - **Hover/Drag/Focus**: thickens to 4px in [ColorScheme.primary], shows /// three-dot drag handle in [ColorScheme.onPrimary] /// - **Settling**: 4px in [ColorScheme.primary] at 70% opacity (anchor snap) +/// - **At a limit**: [ColorScheme.tertiary] tint — the pane is pinned; a +/// further forced drag collapses it (when the config allows) +/// - **Collapsed**: a pull tab with a chevron pointing where the pane +/// went — drag it back out (or the app calls restore) to bring the +/// pane back /// /// Shows [SystemMouseCursors.resizeColumn] on hover. /// @@ -19,30 +29,19 @@ import 'package:flutter/material.dart'; /// ) /// ``` class HandleDivider extends StatefulWidget { - /// Creates a divider with the given interaction state. - const HandleDivider({ - super.key, - required this.isDragging, - required this.isSettling, - }); + /// Creates a divider for the given interaction state. + const HandleDivider({super.key, required this.state}); - /// Whether the user is actively dragging this divider. - final bool isDragging; - - /// Whether the divider is animating to an anchor snap point. - final bool isSettling; + /// The divider's interaction state. + final DividerState state; /// Builder that matches the `DividerBuilder` typedef. /// /// This is a [StatefulWidget] that manages its own hover state, /// so it returns a new instance each call. The widget framework /// handles diffing and state preservation via the element tree. - static Widget builder( - BuildContext context, - bool isDragging, - bool isSettling, - ) { - return HandleDivider(isDragging: isDragging, isSettling: isSettling); + static Widget builder(BuildContext context, DividerState state) { + return HandleDivider(state: state); } @override @@ -55,13 +54,26 @@ class _HandleDividerState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final isActive = _isHovering || widget.isDragging; + final state = widget.state; + + if (state.collapsed != null) { + return _PullTab( + collapsed: state.collapsed!, + hovering: _isHovering, + onHover: (v) => setState(() => _isHovering = v), + ); + } + + final isActive = _isHovering || state.isDragging || state.isFocused; + final atLimit = state.atMinimum || state.atMaximum; // Resolve the line color based on state priority: - // settling > active (hover/drag) > idle + // settling > at-limit > active (hover/drag/focus) > idle final Color lineColor; - if (widget.isSettling) { + if (state.isSettling) { lineColor = colorScheme.primary.withValues(alpha: 0.7); + } else if (atLimit && isActive) { + lineColor = colorScheme.tertiary; } else if (isActive) { lineColor = colorScheme.primary; } else { @@ -98,6 +110,57 @@ class _HandleDividerState extends State { } } +/// Restore affordance shown while a pane is collapsed: a rounded tab with +/// a chevron pointing towards the hidden pane. Dragging the divider (this +/// whole hit zone) restores it. +class _PullTab extends StatelessWidget { + const _PullTab({ + required this.collapsed, + required this.hovering, + required this.onHover, + }); + + final PaneSide collapsed; + final bool hovering; + final ValueChanged onHover; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final ltr = Directionality.of(context) == TextDirection.ltr; + // Chevron points AWAY from the collapsed pane — the direction a drag + // restores it. + final towardsEnd = (collapsed == PaneSide.start) == ltr; + + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => onHover(true), + onExit: (_) => onHover(false), + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 16, + height: 56, + decoration: BoxDecoration( + color: hovering + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Icon( + towardsEnd ? Icons.chevron_right : Icons.chevron_left, + size: 14, + color: hovering + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} + /// Small circular dot used in the three-dot drag handle indicator. class _HandleDot extends StatelessWidget { const _HandleDot({required this.color}); diff --git a/lib/src/components/dividers/material_divider.dart b/lib/src/components/dividers/material_divider.dart index 6821d7a..1570121 100644 --- a/lib/src/components/dividers/material_divider.dart +++ b/lib/src/components/dividers/material_divider.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:adaptive_layouts/src/core/shared/divider_builder.dart'; + /// Material-style pane divider with drag and settle visual feedback. /// /// Shows a thin vertical line that thickens and changes color when dragged. @@ -15,13 +17,9 @@ class MaterialDivider { static const double lineWidth = 1; /// Builder that matches the `DividerBuilder` typedef. - static Widget builder( - BuildContext context, - bool isDragging, - bool isSettling, - ) { + static Widget builder(BuildContext context, DividerState state) { final colorScheme = Theme.of(context).colorScheme; - final isActive = isDragging || isSettling; + final isActive = state.isDragging || state.isSettling || state.isFocused; return Center( child: AnimatedContainer( diff --git a/lib/src/core/list_detail/compact_config.dart b/lib/src/core/list_detail/compact_config.dart index 5877362..aec5f05 100644 --- a/lib/src/core/list_detail/compact_config.dart +++ b/lib/src/core/list_detail/compact_config.dart @@ -25,6 +25,7 @@ class CompactConfig { this.detailBackground, this.handleBackGesture = true, this.useRootOverlay = false, + this.useRootNavigator = true, }); /// Duration of the slide open/close animation. @@ -69,4 +70,10 @@ class CompactConfig { /// /// Ignored when `CompactDetailMode.inline` is used. final bool useRootOverlay; + + /// Which `Navigator` receives the detail route in + /// `CompactDetailMode.route`. True (default) pushes on the root + /// navigator so the detail covers nested shells (tab bars, nested + /// routers); false uses the nearest enclosing navigator. + final bool useRootNavigator; } diff --git a/lib/src/core/list_detail/detail_layout_mode.dart b/lib/src/core/list_detail/detail_layout_mode.dart index 3f4ab01..b5508cb 100644 --- a/lib/src/core/list_detail/detail_layout_mode.dart +++ b/lib/src/core/list_detail/detail_layout_mode.dart @@ -11,16 +11,86 @@ enum DetailLayoutMode { /// How the detail pane is placed in compact (mobile) layout. /// -/// Both modes use the same slide animation and swipe-to-dismiss gesture. -/// The difference is WHERE the detail widget tree is mounted. +/// All three modes keep the same guarantee — the detail's widget +/// *instance* survives compact ↔ expanded resizes — and differ in where +/// the detail is mounted, which decides what it covers, who owns the +/// back gesture, and how the page's own chrome (snackbars, FABs) +/// stacks against it. enum CompactDetailMode { - /// Detail renders inline within the layout's widget tree. - /// Does NOT cover sibling widgets (bottom nav, tab bars). + /// Detail renders inline, within the layout's own bounds. + /// + /// - Does NOT cover siblings: bottom nav and tab bars stay visible + /// and tappable next to the open detail. + /// - Slide-over entrance, swipe anywhere on the detail to dismiss, + /// system back intercepted via `PopScope` + /// (`CompactConfig.handleBackGesture`). The interception costs + /// Android's predictive-back preview — the system cannot peek a + /// route that isn't one. + /// - The page's snackbars, FABs, and bottom sheets render above the + /// layout as usual — nothing is hidden. + /// - Assistive tech gets route parity: the open detail scopes as a + /// route, the covered list leaves the semantics tree, and + /// `DismissIntent` (Escape on desktop) dismisses when the focus is + /// inside the detail. + /// - Breakpoint crossings with an open detail animate both ways: into + /// compact the detail grows out of its pane to full width; into + /// expanded the list slides in and pushes it back into its pane. + /// + /// Pick when the detail is part of the screen and the surrounding + /// chrome should stay present. inline, /// Detail renders in an ancestor `Overlay` via `OverlayPortal`. - /// Covers sibling widgets (bottom nav, tab bars) while keeping the - /// detail in the widget tree (state preserved via GlobalKey). - /// Which overlay is controlled by `CompactConfig.useRootOverlay`. + /// + /// - Covers sibling widgets (bottom nav, tab bars) for a full-screen + /// feel WITHOUT real navigation — no route churn, no Hero scans. + /// Which overlay via `CompactConfig.useRootOverlay`. + /// - Same gestures as [inline]: slide-over, swipe anywhere to + /// dismiss, `PopScope` back interception (predictive-back preview + /// lost). Later-pushed routes (dialogs, sheets) still appear above. + /// - The page's snackbars, FABs, and bottom sheets render INSIDE the + /// page — structurally BELOW the overlay entry — so an open detail + /// hides them. Inherent to overlay stacking, not configurable. + /// - Kept-alive multi-tab shells are handled: inactive tabs' overlays + /// suppress themselves (paint-probe) and reappear on return. + /// - Assistive tech gets route parity: the open detail scopes as a + /// route, everything it covers leaves the semantics tree + /// (`BlockSemantics` — the Drawer/ModalBarrier primitive), and + /// `DismissIntent` (Escape on desktop) dismisses when the focus is + /// inside the detail. + /// - Breakpoint crossings with an open detail animate both ways: into + /// compact the detail grows out of its pane to full width; into + /// expanded the list slides in and pushes it back into its pane. + /// + /// Pick for full-screen details with instant resize morphs, when the + /// page's own snackbars/FABs are not needed while a detail is open. overlay, + + /// Detail is pushed as a REAL page route on the Navigator. + /// + /// - Covers everything — it is a page. Which navigator via + /// `CompactConfig.useRootNavigator`. + /// - Back belongs to the route: Android predictive back, iOS/macOS + /// edge swipe, and the transition animation all come from the + /// app's `PageTransitionsTheme` — platform behavior updates arrive + /// with Flutter upgrades. The package's slide/swipe machinery and + /// `CompactConfig.handleBackGesture` are not used. There is no + /// swipe-anywhere dismissal; the platform decides the gesture. + /// - Snackbars behave like normal page navigation: an in-progress + /// snackbar re-homes into the detail's `Scaffold` (give the detail + /// one to receive them), per `ScaffoldMessenger`'s standard rules. + /// - Every push runs Flutter's Hero scan over the shell — multiple + /// `FloatingActionButton`s under one page need explicit `heroTag`s. + /// - State is still preserved across resizes: the detail element + /// reparents between the route and the expanded pane. Hidden + /// kept-alive tabs remove their route (selection and state kept) + /// and restore it instantly when shown again. + /// - Breakpoint crossings with an open detail animate both ways: into + /// compact the route's REAL entrance plays over the list; into + /// expanded the route is replaced in one seamless frame by the + /// full-width pane detail, and the list slides in beside it. + /// + /// Pick when the detail should feel like native navigation and evolve + /// with the platform's back-gesture conventions. + route, } diff --git a/lib/src/core/list_detail/detail_page_route.dart b/lib/src/core/list_detail/detail_page_route.dart new file mode 100644 index 0000000..2deb2cb --- /dev/null +++ b/lib/src/core/list_detail/detail_page_route.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// The real page route that presents the detail in +/// `CompactDetailMode.route`. +/// +/// Uses [MaterialRouteTransitionMixin] — the exact transition machinery +/// [MaterialPageRoute] runs on — so the app's [PageTransitionsTheme] +/// applies: platform transitions, predictive back, Cupertino edge swipes, +/// none of it reimplemented here. A plain [PageRoute] base (instead of +/// [MaterialPageRoute] itself) only because that class asserts +/// `opaque == true`, and this route must not be opaque. +class DetailPageRoute extends PageRoute + with MaterialRouteTransitionMixin { + /// Creates the detail route. + DetailPageRoute({ + required WidgetBuilder builder, + this.instantEntrance = false, + }) : _builder = builder; + + final WidgetBuilder _builder; + + /// Skips the entrance animation for pushes that restore an + /// already-visible detail (resize swaps, deep links, tab re-shows). + /// Exits always animate for real. + final bool instantEntrance; + + @override + Widget buildContent(BuildContext context) => _builder(context); + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => + instantEntrance ? Duration.zero : super.transitionDuration; + + /// Non-opaque so the layout below keeps painting: the paint-visibility + /// probe is what detects "this tab was navigated away from under the + /// route" — an opaque route would blind the probe and un-paint the very + /// layout that owns the route, suppressing it in a loop. + @override + bool get opaque => false; +} diff --git a/lib/src/core/list_detail/expanded_empty_behavior.dart b/lib/src/core/list_detail/expanded_empty_behavior.dart new file mode 100644 index 0000000..13f7324 --- /dev/null +++ b/lib/src/core/list_detail/expanded_empty_behavior.dart @@ -0,0 +1,19 @@ +/// What the expanded layout does with the detail slot when nothing is +/// selected. +/// +/// Two of the three established list-detail patterns are layout +/// decisions and live here. The third — auto-selecting the first item so +/// the pane is never empty (Notes, Slack, Settings apps) — is a data +/// decision the app makes with its controller: select an item when the +/// list loads and nothing is selected. +enum ExpandedEmptyBehavior { + /// The detail pane is always present; without a selection it shows + /// `emptyStateBuilder`. The Apple Mail / Outlook reading-pane shape. + placeholder, + + /// The list owns the full width until a selection summons the detail + /// pane, which reveals from the end edge; dismissing hands the width + /// back. The Material "supporting pane" shape (Gmail without a + /// reading pane). `emptyStateBuilder` is not used. + listOnly, +} diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 8c165f9..fe3c245 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -5,11 +5,18 @@ import 'package:flutter/material.dart'; import 'package:adaptive_layouts/src/core/list_detail/compact_config.dart'; import 'package:adaptive_layouts/src/core/list_detail/compact_detail_overlay.dart'; import 'package:adaptive_layouts/src/core/list_detail/detail_layout_mode.dart'; +import 'package:adaptive_layouts/src/core/list_detail/detail_page_route.dart'; +import 'package:adaptive_layouts/src/core/list_detail/expanded_empty_behavior.dart'; import 'package:adaptive_layouts/src/core/list_detail/list_detail_controller.dart'; import 'package:adaptive_layouts/src/core/list_detail/paint_visibility_detector.dart'; import 'package:adaptive_layouts/src/core/shared/adaptive_layout_config.dart'; import 'package:adaptive_layouts/src/core/shared/divider_builder.dart'; +import 'package:adaptive_layouts/src/core/shared/expanded_entry_style.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; import 'package:adaptive_layouts/src/core/shared/pane_config.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_divider_region.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_scope.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_width_memory.dart'; import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; part 'list_detail_layout_builders.dart'; @@ -81,11 +88,14 @@ class ListDetailLayout extends StatefulWidget { required this.listBuilder, required this.detailBuilder, this.emptyStateBuilder, + this.collapsedListBuilder, + this.collapsedDetailBuilder, this.dividerBuilder, this.expandedBreakpoint, this.paneConfig = const PaneConfig(), this.compactConfig = const CompactConfig(), this.compactDetailMode = CompactDetailMode.inline, + this.expandedEmptyBehavior = ExpandedEmptyBehavior.placeholder, }); /// Controller for selection state. If null, creates one internally. @@ -101,6 +111,17 @@ class ListDetailLayout extends StatefulWidget { /// Null by default (shows empty space). Use a shipped component or your own. final WidgetBuilder? emptyStateBuilder; + /// What a collapsed list pane shows, laid out at the REAL slot width + /// (`PaneConfig.collapsedSize`) — the icon-rail slot. While it shows, + /// the list pane parks offstage with its state alive; restoring brings + /// the same instance back. Null keeps the default: the list clipped at + /// its minimum width. The builder's context sits under [PaneScope], + /// so `PaneScope.of(context).restore` is the expand affordance. + final WidgetBuilder? collapsedListBuilder; + + /// Same slot for a collapsed detail pane ([PaneCollapsible.end]). + final WidgetBuilder? collapsedDetailBuilder; + /// Builder for the expanded-layout pane divider. /// Null by default (invisible drag zone). Use a shipped component or your own. final DividerBuilder? dividerBuilder; @@ -125,6 +146,13 @@ class ListDetailLayout extends StatefulWidget { /// [OverlayPortal] — covers sibling widgets. Which overlay is controlled /// by [CompactConfig.useRootOverlay]. Widget state preserved via [GlobalKey]. /// + /// [CompactDetailMode.route]: detail is pushed as a REAL page route — + /// the app's [PageTransitionsTheme] (platform transitions, predictive + /// back, edge swipes) applies natively, and back is the route's own. + /// The same [PaintVisibilityDetector] suppression applies: an unpainted + /// instance (hidden tab) removes its route, keeping the selection, and + /// re-pushes instantly when painted again. + /// /// **Note on overlay mode with tabs:** When multiple overlay-mode instances /// are mounted simultaneously (e.g. tab navigation with state preservation), /// a [PaintVisibilityDetector] automatically suppresses inactive instances' @@ -132,9 +160,15 @@ class ListDetailLayout extends StatefulWidget { /// one-frame delay when switching back. Works with any parent that stops /// painting inactive children (IndexedStack, Offstage, Visibility, etc.). /// - /// Both modes use the same slide animation and swipe-to-dismiss gesture. + /// Inline and overlay share the slide animation and swipe-to-dismiss + /// gesture; route mode delegates both to the real route. final CompactDetailMode compactDetailMode; + /// What the expanded layout does with the detail slot when nothing is + /// selected: a persistent pane showing [emptyStateBuilder] (default), + /// or a full-width list that yields only when a selection opens. + final ExpandedEmptyBehavior expandedEmptyBehavior; + @override State> createState() => _ListDetailLayoutState(); } @@ -223,6 +257,18 @@ class _ListDetailLayoutState extends State> final GlobalKey _detailKey = GlobalKey(); + /// Same guarantee for the list pane: wraps every list slot so mode + /// switches, collapse clipping, and rail parking reparent the live + /// element instead of remounting it. + final GlobalKey _listPaneKey = GlobalKey(); + + /// The list pane under its reparenting key. Every build site uses + /// this — exactly one renders per frame. + Widget _keyedList(T? selectedId) => KeyedSubtree( + key: _listPaneKey, + child: widget.listBuilder(context, selectedId, _handleSelect), + ); + // --------------------------------------------------------------------------- // Overlay-based compact detail (CompactDetailMode.overlay) // @@ -242,6 +288,73 @@ class _ListDetailLayoutState extends State> bool get _useOverlay => widget.compactDetailMode == CompactDetailMode.overlay; + // --------------------------------------------------------------------------- + // Route-based compact detail (CompactDetailMode.route) + // + // The detail lives in a REAL page route. All navigation runs post-frame + // through one reconciler (_syncDetailRoute). The detail key has exactly + // one holder per frame: the route (via _detailRouted), the expanded pane, + // or the one-frame bridge (buildCompactRouteList). + // --------------------------------------------------------------------------- + + bool get _useRoute => widget.compactDetailMode == CompactDetailMode.route; + + /// The pushed detail route while active (not popping). + DetailPageRoute? _detailRoute; + + /// A popped route still playing its real exit animation. Its subtree + /// holds the detail key until the animation is dismissed — new pushes + /// wait for it. + DetailPageRoute? _exitingRoute; + + /// Captured at push time — the layout's context is unusable in dispose. + NavigatorState? _routeNavigator; + + /// True while a route (active or exiting) owns the detail key; the + /// expanded pane builds an empty slot meanwhile. + final ValueNotifier _detailRouted = ValueNotifier(false); + + /// Render the detail inline (keyed) for the frame between a resize into + /// compact (or a deep-link mount) and the instant route push — without + /// it that frame flashes the bare list. + bool _bridgeDetail = false; + + /// Next push skips the entrance animation (resize swap, deep link, + /// paint re-show) — the detail was already visually present. + bool _instantRoutePush = false; + + bool _routeSyncScheduled = false; + bool _routePaintCheckArmed = false; + + /// Render the bridge invisibly: the element stays alive for the key + /// handoff while the LIST shows beneath an animated route entrance. + bool _bridgeOffstage = false; + + /// Expanded/compact state of the previous build — a flip marks the + /// breakpoint-crossing frame (all modes). Meaningless until the first + /// build has happened: a deep-link mount is not a crossing. + bool _wasExpandedLastBuild = false; + bool _hasBuiltOnce = false; + + /// Drives the crossing INTO expanded with an open detail: the detail + /// starts full width (matching what compact just showed — one frame of + /// perfect continuity) and the list slides in, pushing it into its + /// pane. 0 = no list; 1 = settled expanded layout. + late final AnimationController _expandEntryController; + + /// Detail-pane presence at expanded for + /// [ExpandedEmptyBehavior.listOnly]: 0 = the list owns the full width, + /// 1 = both panes settled. Tracks the selection in every mode (cheap, + /// tickless when idle) so a crossing into expanded finds it correct. + late final AnimationController _detailPaneController; + + /// True between a route-mode build (which ran `evaluate()`) and the + /// sync that consumes it. In such a frame `paintedThisFrame` is the + /// authoritative visibility signal; the notifier can be a stale TRUE + /// when the widget sat in a keep-alive bucket where paint stopped but + /// no build ever ran `evaluate()` to correct it (TabBarView tabs). + bool _routeFrameEvaluated = false; + // =========================================================================== // LIFECYCLE // =========================================================================== @@ -255,6 +368,19 @@ class _ListDetailLayoutState extends State> duration: widget.compactConfig.duration, ); _slideAnimation = _buildSlideAnimation(); + // No listeners on either: buildExpandedLayout wraps itself in an + // AnimatedBuilder on both — a setState listener would fire during + // build when a crossing frame seeds a value. + _expandEntryController = AnimationController( + vsync: this, + duration: widget.compactConfig.duration, + value: 1.0, + ); + _detailPaneController = AnimationController( + vsync: this, + duration: widget.compactConfig.duration, + value: _controller.hasSelection ? 1.0 : 0.0, + ); _paneWidth = PaneWidthModel( widget.paneConfig, @@ -262,7 +388,7 @@ class _ListDetailLayoutState extends State> ); _settleController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 220), + duration: widget.paneConfig.settleDuration, ); _controller.addListener(_onControllerChanged); @@ -282,6 +408,14 @@ class _ListDetailLayoutState extends State> if (mounted) _overlay.show(); }); } + + if (_useRoute) { + // A mount with a selection (deep link) shows the detail from the + // first frame via the bridge, then hands it to an instant push. + _bridgeDetail = _controller.hasSelection; + _instantRoutePush = _controller.hasSelection; + _paintVisibility.notifier.addListener(_scheduleRouteSync); + } } @override @@ -310,17 +444,62 @@ class _ListDetailLayoutState extends State> if (widget.compactConfig.duration != oldWidget.compactConfig.duration) { _slideController.duration = widget.compactConfig.duration; + _expandEntryController.duration = widget.compactConfig.duration; + _detailPaneController.duration = widget.compactConfig.duration; + } + if (widget.expandedEmptyBehavior != oldWidget.expandedEmptyBehavior) { + // A live flip mid-animation would strand the pane half-open. + _detailPaneController.value = _controller.hasSelection ? 1.0 : 0.0; } if (widget.compactConfig.curve != oldWidget.compactConfig.curve) { _slideAnimation = _buildSlideAnimation(); } - if (!identical(widget.paneConfig, oldWidget.paneConfig)) { + // Value comparison, not identity: apps construct configs inline in + // build, and resetting the model on every rebuild would erase the + // user's dragged divider position. + if (widget.paneConfig != oldWidget.paneConfig) { _settleController.stop(); + _settleController.duration = widget.paneConfig.settleDuration; _paneWidth = PaneWidthModel( widget.paneConfig, referenceWidth: _referenceWidth, ); } + + if (widget.compactDetailMode != oldWidget.compactDetailMode) { + // Mode flips happen on LIVE layouts (a settings screen, a debug + // toggle). Every per-mode wiring initState does must be mirrored + // here or the new mode runs with dead machinery. + if (oldWidget.compactDetailMode == CompactDetailMode.route) { + _paintVisibility.notifier.removeListener(_scheduleRouteSync); + _teardownDetailRoute(); + _bridgeDetail = false; + _instantRoutePush = false; + } + // Leaving overlay mode needs no teardown: the OverlayPortal only + // exists inside the overlay build branch, so it leaves the tree + // with the mode. + if (widget.compactDetailMode == CompactDetailMode.route) { + // Same wiring as a route-mode initState: the paint-probe listener + // IS the re-show chain — without it a repaint never wakes the + // reconciler and an open detail rests inline forever. An open + // selection starts bridged until the instant push claims it. + _bridgeDetail = _controller.hasSelection; + _instantRoutePush = _controller.hasSelection; + _paintVisibility.notifier.addListener(_scheduleRouteSync); + } + if (widget.compactDetailMode == CompactDetailMode.overlay) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _useOverlay) _overlay.show(); + }); + } + // Entering the slide-driven modes (inline / overlay) with a + // selection already open: the detail is a settled fact, not an + // entrance — snap the slide to match. + if (widget.compactDetailMode != CompactDetailMode.route) { + _slideController.value = _controller.hasSelection ? 1.0 : 0.0; + } + } } /// Reference width for ratio conversion — the expanded breakpoint, since @@ -331,14 +510,37 @@ class _ListDetailLayoutState extends State> @override void dispose() { + // Unconditional: the listener may have been wired by a live mode flip + // rather than initState; removing an unattached listener is a no-op. + _paintVisibility.notifier.removeListener(_scheduleRouteSync); + _teardownDetailRoute(); _controller.removeListener(_onControllerChanged); _ownedController?.dispose(); _slideController.dispose(); + _expandEntryController.dispose(); + _detailPaneController.dispose(); _settleController.dispose(); _paintVisibility.dispose(); super.dispose(); } + /// Removes any live route without animation. Used on dispose and on a + /// compactDetailMode change away from route mode. Navigation is illegal + /// while the tree is locked, so the removal is deferred a frame. + void _teardownDetailRoute() { + final orphan = _detailRoute ?? _exitingRoute; + _detailRoute = null; + _exitingRoute = null; + _detailRouted.value = false; + if (orphan == null) return; + final navigator = _routeNavigator; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (navigator != null && navigator.mounted && orphan.isActive) { + navigator.removeRoute(orphan); + } + }); + } + // =========================================================================== // SLIDE ANIMATION // =========================================================================== @@ -369,14 +571,33 @@ class _ListDetailLayoutState extends State> // =========================================================================== void _onControllerChanged() { + if (_controller.selectedId != null) { + _lastSeenSelectedId = _controller.selectedId; + } + + // Route mode (compact): selection changes translate to route pushes and + // pops in the reconciler — none of the slide machinery below applies. + if (_useRoute && !_isExpanded) { + _scheduleRouteSync(); + if (mounted) setState(() {}); + return; + } + final shouldBeOpen = _controller.hasSelection; final isCurrentlyOpen = _slideController.value > 0 || _slideController.isAnimating; + // The pane controller means "selection presence" only in listOnly; + // in placeholder behavior it is a crossing transient for the empty + // pane, and selection changes must not drag it around. + final selectionDrivesPane = + widget.expandedEmptyBehavior == ExpandedEmptyBehavior.listOnly; + if (shouldBeOpen && !isCurrentlyOpen) { // === ENTERING: detail was closed, now something is selected === _outgoingDetailId = null; unawaited(_slideController.forward()); + if (selectionDrivesPane) unawaited(_detailPaneController.forward()); } else if (!shouldBeOpen && isCurrentlyOpen) { // === EXITING: detail was open, selection cleared === // @@ -386,8 +607,14 @@ class _ListDetailLayoutState extends State> // which captured the ID before the controller cleared it. _outgoingDetailId ??= _lastSeenSelectedId; _controller.setAnimatingOut(true); + // Both exits ride together (compact slide-out / expanded pane + // retreat); the outgoing detail is released only when the slower + // one lands, so neither surface loses its content mid-exit. unawaited( - _slideController.reverse().then((_) { + Future.wait([ + _slideController.reverse(), + if (selectionDrivesPane) _detailPaneController.reverse(), + ]).then((_) { if (mounted) { setState(() => _outgoingDetailId = null); _controller.setAnimatingOut(false); @@ -397,15 +624,192 @@ class _ListDetailLayoutState extends State> } // SWITCHING (shouldBeOpen && isCurrentlyOpen with different ID): // No animation needed — just rebuild with the new ID. + // (_lastSeenSelectedId tracking happens at the top of this method.) - // Track the last non-null selection for external dismiss fallback. - if (_controller.selectedId != null) { - _lastSeenSelectedId = _controller.selectedId; + if (mounted) setState(() {}); + } + + // =========================================================================== + // ROUTE MODE — the reconciler and its verbs + // =========================================================================== + + void _armRoutePaintCheck() { + if (_routePaintCheckArmed) return; + _routePaintCheckArmed = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _routePaintCheckArmed = false; + if (!mounted) return; + if (!_paintVisibility.paintedThisFrame && + _paintVisibility.notifier.value) { + // Built but not painted: the notifier is stale-true (paint can + // stop without a build running evaluate() — keep-alive buckets). + // Correcting it here is what re-arms the paint probe's deferred + // "paint resumed" signal; a stuck-true notifier never fires it + // and the re-show sync would never be woken. + _paintVisibility.notifier.value = false; + _scheduleRouteSync(); + } + }); + } + + void _scheduleRouteSync() { + if (!_useRoute || _routeSyncScheduled) return; + _routeSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _routeSyncScheduled = false; + if (mounted) _syncDetailRoute(); + }); + // The paint-probe listener fires inside another frame's post-frame + // flush; post-frame callbacks don't schedule frames, so without this + // the sync waits for a frame that may never come on an idle device. + WidgetsBinding.instance.ensureVisualUpdate(); + } + + /// Reconciles the pushed route with the desired state — the ONLY place + /// route-mode navigation happens, always post-frame, never during build. + void _syncDetailRoute() { + // When this frame's build ran evaluate(), paintedThisFrame is ground + // truth — fresh in both directions: it covers the frame where paint + // just resumed (the notifier flips true a deferred callback later) + // AND it vetoes a stale-true notifier for a layout whose paint + // stopped without any build noticing (keep-alive tab buckets). Only + // syncs woken by the notifier itself, in a frame with no build, fall + // back to the notifier. + final painted = _routeFrameEvaluated + ? _paintVisibility.paintedThisFrame + : _paintVisibility.notifier.value; + _routeFrameEvaluated = false; + final wantRoute = !_isExpanded && _controller.hasSelection && painted; + final route = _detailRoute; + + if (wantRoute && route == null) { + if (_exitingRoute != null) { + // The previous detail still holds the key through its exit + // animation — try again next frame. + _scheduleRouteSync(); + return; + } + _pushDetailRoute(); + } else if (!wantRoute && route != null) { + if (!_isExpanded && !painted && _controller.hasSelection) { + // Tab hidden under the route (URL navigation): remove instantly, + // KEEP the selection, re-push instantly when painted again. The + // bridge takes the detail key in the same frame the route dies — + // without a holder the element unmounts and the state is gone. + _instantRoutePush = true; + _bridgeDetail = true; + _bridgeOffstage = false; + _removeDetailRoute(route); + } else if (_isExpanded) { + // Resize into expanded: the pane claims the key in the same + // synchronous block the route releases it. + _removeDetailRoute(route); + } else { + // Programmatic dismissal — real exit animation. + _popDetailRoute(route); + } } + } + + void _pushDetailRoute() { + final navigator = Navigator.of( + context, + rootNavigator: widget.compactConfig.useRootNavigator, + ); + final route = DetailPageRoute( + instantEntrance: _instantRoutePush, + builder: _buildRoutedDetail, + ); + _detailRoute = route; + _routeNavigator = navigator; + _detailRouted.value = true; + _bridgeDetail = false; + _bridgeOffstage = false; + _instantRoutePush = false; + unawaited( + route.popped.then((_) { + if (mounted) _onDetailRoutePopped(route); + }), + ); + unawaited(navigator.push(route)); + // The route animation exists only after install. + route.animation?.addStatusListener((status) { + if (status != AnimationStatus.dismissed || !mounted) return; + if (!identical(_exitingRoute, route)) return; + _exitingRoute = null; + if (_detailRoute == null) _detailRouted.value = false; + _controller.setAnimatingOut(false); + _scheduleRouteSync(); + }); + if (mounted) setState(() {}); + } + /// Handles the route being popped — by the system back gesture, + /// predictive back, or our own [_popDetailRoute]. Fires at pop START; + /// the route keeps the detail key through its exit animation. + void _onDetailRoutePopped(DetailPageRoute route) { + // Identity guard: a removed route also completes `popped` — only the + // still-active route's completion is a real dismissal. + if (!identical(route, _detailRoute)) return; + _detailRoute = null; + _exitingRoute = route; + _controller.setAnimatingOut(true); + if (_controller.hasSelection) { + // Externally popped — sync the data state. The route-mode branch of + // _onControllerChanged schedules a sync, which finds nothing to do. + _controller.dismiss(); + } + if (mounted) setState(() {}); + } + + /// Animated dismissal of the active route. + void _popDetailRoute(DetailPageRoute route) { + final navigator = _routeNavigator; + if (navigator == null || !navigator.mounted) return; + if (route.isCurrent) { + navigator.pop(); + } else if (route.isActive) { + // Something sits above the detail (a dialog) — remove silently. + _detailRoute = null; + _detailRouted.value = false; + navigator.removeRoute(route); + } + } + + /// Instant removal — resize swaps, paint suppression. Releases the key + /// in the same synchronous block so the next holder can claim it. + void _removeDetailRoute(DetailPageRoute route) { + _detailRoute = null; + _detailRouted.value = false; + final navigator = _routeNavigator; + if (navigator != null && navigator.mounted && route.isActive) { + navigator.removeRoute(route); + } if (mounted) setState(() {}); } + /// The route's content: the keyed detail, live against the controller. + /// During the exit animation the selection is already cleared — + /// [_lastSeenSelectedId] keeps the content on screen for the ride out. + Widget _buildRoutedDetail(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final id = _controller.selectedId ?? _lastSeenSelectedId; + if (id == null) return const SizedBox.shrink(); + return KeyedSubtree( + key: _detailKey, + child: widget.detailBuilder( + context, + id, + DetailLayoutMode.stacked, + _handleDismiss, + ), + ); + }, + ); + } + // =========================================================================== // CALLBACKS // =========================================================================== @@ -475,12 +879,16 @@ class _ListDetailLayoutState extends State> void _handleDividerDragStart() { _settleController.stop(); + _paneWidth.dragStart(_lastExpandedWidth); setState(() => _isDividerDragging = true); } void _handleDividerDragEnd() { + _paneWidth.dragEnd(); setState(() => _isDividerDragging = false); - _settleToNearestAnchor(); + // A collapsed pane parks — anchor snapping applies only to visible + // panes. + if (_paneWidth.collapsed == null) _settleToNearestAnchor(); } void _handleDividerDragUpdate(double delta) { @@ -491,14 +899,18 @@ class _ListDetailLayoutState extends State> /// Animates the divider to the nearest anchor. No-op without anchors. void _settleToNearestAnchor() { - final availableWidth = _lastExpandedWidth; - final target = _paneWidth.snapTarget(availableWidth); - if (target == null) return; + final target = _paneWidth.snapTarget(_lastExpandedWidth); + if (target != null) _settleToWidth(target); + } + /// Animates the divider to [target] using the settle knobs. + void _settleToWidth(double target) { + final availableWidth = _lastExpandedWidth; final begin = _paneWidth.width(availableWidth); + if ((target - begin).abs() < 0.5) return; final curve = CurvedAnimation( parent: _settleController, - curve: Curves.easeOutCubic, + curve: widget.paneConfig.settleCurve, ); void tick() { _paneWidth.setWidth( @@ -517,6 +929,206 @@ class _ListDetailLayoutState extends State> }); } + // =========================================================================== + // EXPANDED LAYOUT — DIVIDER KEYBOARD / COLLAPSE ACTIONS + // =========================================================================== + + /// Keyboard step: one micro-drag through the normal resize path, so RTL + /// correction and clamping apply identically to pointer drags. + void _handleDividerStep(double delta) { + if (_paneWidth.collapsed != null) return; + _settleController.stop(); + _handleDividerDragUpdate(delta); + } + + /// Collapses [side] instantly. Programmatic collapse (keyboard, app + /// buttons) snaps — the drag path is the only animated route in 1b. + void _collapsePane(PaneSide side) { + if (!_isExpanded || _paneWidth.collapsed != null) return; + if (!widget.paneConfig.collapsible.allows(side)) return; + _settleController.stop(); + setState(() => _paneWidth.collapse(side, _lastExpandedWidth)); + } + + /// Restores a collapsed pane to its remembered width instantly. + void _restorePane() { + if (_paneWidth.collapsed == null) return; + setState(() => _paneWidth.restore(_lastExpandedWidth)); + } + + /// Enter on the focused divider: restore when collapsed, else collapse + /// the first side the config allows. + void _handleDividerToggleCollapse() { + if (_paneWidth.collapsed != null) { + _restorePane(); + return; + } + final collapsible = widget.paneConfig.collapsible; + if (collapsible.allows(PaneSide.start)) { + _collapsePane(PaneSide.start); + } else if (collapsible.allows(PaneSide.end)) { + _collapsePane(PaneSide.end); + } + } + + /// Double-click / double-tap: back to the configured default width + /// (the VS Code sash-reset gesture). Restores first when collapsed. + void _handleDividerReset() { + if (_paneWidth.collapsed != null) { + _restorePane(); + return; + } + _settleToWidth(_paneWidth.defaultWidth(_lastExpandedWidth)); + } + + void _handleDividerJumpToMinimum() { + if (_paneWidth.collapsed != null) _restorePane(); + _settleToWidth(widget.paneConfig.minListWidth); + } + + void _handleDividerJumpToMaximum() { + if (_paneWidth.collapsed != null) _restorePane(); + _settleToWidth(_lastExpandedWidth * widget.paneConfig.maxListRatio); + } + + /// The scope data descendants read via [PaneScope]. Collapse/restore + /// route through the same actions the divider uses. + PaneScopeData _paneScopeData() => PaneScopeData( + collapsed: _isExpanded ? _paneWidth.collapsed : null, + isExpanded: _isExpanded, + collapsedSize: widget.paneConfig.collapsedSize, + collapse: _collapsePane, + restore: _restorePane, + ); + + // =========================================================================== + // BREAKPOINT CROSSINGS + // =========================================================================== + + /// Finds x with `curve.transform(x) == y` by bisection. Easing curves + /// are monotonic; for a non-monotonic curve this still lands on ONE + /// valid crossing, which is all a seed needs. + static double _inverseCurve(Curve curve, double y) { + var lo = 0.0; + var hi = 1.0; + for (var i = 0; i < 24; i++) { + final mid = (lo + hi) / 2; + if (curve.transform(mid) < y) { + lo = mid; + } else { + hi = mid; + } + } + return (lo + hi) / 2; + } + + /// True while the empty placeholder pane is animating out after a + /// crossing into compact: build() keeps the expanded geometry alive at + /// the compact width until the retreat lands. The steady visuals on + /// both ends are identical (a full-width list), so the swap to the + /// real compact tree afterwards is invisible. + bool get _emptyPaneRetreating => + widget.expandedEmptyBehavior == ExpandedEmptyBehavior.placeholder && + !_controller.hasSelection && + _detailPaneController.isAnimating; + + /// Detects a breakpoint-crossing frame against the previous build. + /// A first build is never a crossing — deep links render settled. + ({bool intoCompact, bool intoExpanded}) _detectCrossing(bool isExpanded) { + final intoCompact = _hasBuiltOnce && _wasExpandedLastBuild && !isExpanded; + final intoExpanded = _hasBuiltOnce && !_wasExpandedLastBuild && isExpanded; + _wasExpandedLastBuild = isExpanded; + _hasBuiltOnce = true; + return (intoCompact: intoCompact, intoExpanded: intoExpanded); + } + + /// Crossing-frame side effects: the arrangement-flip motion and the + /// width-memory policy. Pane geometry tracks a window drag with no + /// motion; the arrangement flip always animates — the Compose-canonical + /// pane motion. + void _handleCrossing(({bool intoCompact, bool intoExpanded}) crossing) { + if (crossing.intoCompact && !_useRoute && _controller.hasSelection) { + // The detail GROWS out of its pane: the slide starts with its + // leading edge at the divider's FRACTIONAL position and settles to + // full width. The fraction, not the old pixel position — when the + // window itself jumped narrower, the old pixels can lie beyond the + // new width entirely and the detail would vanish and slide in from + // off-screen instead of growing from the divider. (Route mode's + // equivalent is the real route entrance — see the route branch in + // build.) + final dividerFraction = + (_paneWidth.width(_lastExpandedWidth) / _lastExpandedWidth).clamp( + 0.0, + 1.0, + ); + // The controller value passes through the easing curve before it + // becomes an offset — seed with the curve's inverse so the FIRST + // painted frame puts the leading edge exactly on the divider. + _slideController.value = _inverseCurve( + widget.compactConfig.curve, + 1.0 - dividerFraction, + ); + unawaited(_slideController.forward()); + } + + // Placeholder behavior, nothing selected: the EMPTY pane is still an + // arrangement flip — it reveals from the end edge on expand and + // retreats into it on shrink, like any pane. Seeds skip when already + // animating so a rapid re-cross continues from the current position. + // Parked panes don't dance: with a collapsed pane the crossing + // arrives at the final arrangement directly. The collapse animation + // belongs to the moment the user collapsed; a window resize isn't + // that moment. (Route mode's popping route still animates the + // full-screen detail away — that story is already told.) + final parked = _paneWidth.collapsed != null; + final placeholderEmpty = + !parked && + widget.expandedEmptyBehavior == ExpandedEmptyBehavior.placeholder && + !_controller.hasSelection; + if (crossing.intoExpanded && placeholderEmpty) { + if (!_detailPaneController.isAnimating) { + _detailPaneController.value = 0.0; + } + unawaited(_detailPaneController.forward()); + } + if (crossing.intoCompact && placeholderEmpty) { + if (!_detailPaneController.isAnimating) { + _detailPaneController.value = 1.0; + } + // The compact tree has no pane to animate — build() keeps the + // expanded GEOMETRY alive at the compact width until the retreat + // lands, then the setState swaps in the real compact tree. + unawaited( + _detailPaneController.reverse().then((_) { + if (mounted) setState(() {}); + }), + ); + } + + if (crossing.intoExpanded) { + if (widget.paneConfig.widthMemory == PaneWidthMemory.resetOnReentry) { + // Fresh divider on every re-entry — the opt-out from the default + // persistent memory. Reset BEFORE the entry animation reads the + // model, so the list arrives at its default width. + _settleController.stop(); + _paneWidth = PaneWidthModel( + widget.paneConfig, + referenceWidth: _referenceWidth, + ); + } + if (_controller.hasSelection && _paneWidth.collapsed != PaneSide.start) { + // The detail starts full width — matching what compact just + // showed, route or slide-over alike — and the list slides in, + // pushing it into its pane. A parked LIST skips this: a 56px + // rail has no slide to perform, so the arrangement just appears. + // (With a parked DETAIL the slide still plays — the rail docks + // at the end edge from frame one and the list slides in.) + _expandEntryController.value = 0.0; + unawaited(_expandEntryController.forward()); + } + } + } + // =========================================================================== // BUILD // =========================================================================== @@ -524,46 +1136,95 @@ class _ListDetailLayoutState extends State> @override Widget build(BuildContext context) { return LayoutBuilder( - builder: (context, constraints) { - final breakpoint = AdaptiveLayoutConfig.resolveBreakpoint( - context, - widget.expandedBreakpoint, - ); - final isExpanded = constraints.maxWidth >= breakpoint; - _isExpanded = isExpanded; - - // Evaluate overlay visibility (immediate hide for inactive tabs). - if (_useOverlay) _paintVisibility.evaluate(); - - // When transitioning expanded→compact with overlay and active - // selection, jump the slide animation to fully open so the detail - // appears instantly (it was already visible in expanded mode). - // No need to toggle the overlay — it's always showing. - if (_useOverlay && !isExpanded && _controller.hasSelection) { - if (_slideController.value == 0 && !_slideController.isAnimating) { - _slideController.value = 1.0; - } - } + builder: (context, constraints) => PaneScope( + data: _paneScopeData(), + child: _buildForConstraints(context, constraints), + ), + ); + } - if (_useOverlay) { - // Overlay mode: OverlayPortal wraps BOTH layout modes so it's - // always in the tree. The overlay child returns the sliding detail - // when compact, or SizedBox.shrink() when expanded. - final innerLayout = isExpanded - ? buildExpandedLayout(constraints) - : buildCompactOverlayList(); - return buildOverlayPortalWrapper( - child: PaintVisibilityObserver( - detector: _paintVisibility, - child: innerLayout, - ), - ); + Widget _buildForConstraints( + BuildContext context, + BoxConstraints constraints, + ) { + final breakpoint = AdaptiveLayoutConfig.resolveBreakpoint( + context, + widget.expandedBreakpoint, + ); + final isExpanded = constraints.maxWidth >= breakpoint; + _isExpanded = isExpanded; + + final crossing = _detectCrossing(isExpanded); + + // Evaluate overlay visibility (immediate hide for inactive tabs). + if (_useOverlay) _paintVisibility.evaluate(); + + _handleCrossing(crossing); + + // Overlay entering compact outside a crossing frame (deep link, + // mode flip): the detail is a settled fact — show it fully open. + if (_useOverlay && !isExpanded && _controller.hasSelection) { + if (_slideController.value == 0 && !_slideController.isAnimating) { + _slideController.value = 1.0; + } + } + + if (_useOverlay) { + // Overlay mode: OverlayPortal wraps BOTH layout modes so it's + // always in the tree. The overlay child returns the sliding detail + // when compact, or SizedBox.shrink() when expanded. + final innerLayout = isExpanded || _emptyPaneRetreating + ? buildExpandedLayout(constraints) + : buildCompactOverlayList(); + return buildOverlayPortalWrapper( + child: PaintVisibilityObserver( + detector: _paintVisibility, + child: innerLayout, + ), + ); + } + + if (_useRoute) { + _paintVisibility.evaluate(); + _routeFrameEvaluated = true; + // One-shot: after THIS frame's paint, verify the layout actually + // painted. A build pass that ends unpainted means the layout is + // hidden (under the route, or in a keep-alive tab) — suppress + // the route and correct the stale-true notifier. Armed only by + // build passes, so clean idle frames never false-trigger. + _armRoutePaintCheck(); + if (isExpanded) { + _bridgeDetail = false; + _bridgeOffstage = false; + } else if (crossing.intoCompact && _controller.hasSelection) { + // Resize into compact with an open detail: the bridge holds + // the detail key until the push claims it. On a visible + // layout the route plays its REAL entrance (the app's + // PageTransitionsTheme) over the list, so the bridge goes + // offstage — alive for the handoff, invisible. On a hidden + // layout an entrance nobody watches is waste: visible bridge + // + instant push, so the re-show contract stays instant. + _bridgeDetail = true; + if (_paintVisibility.notifier.value) { + _bridgeOffstage = true; + _instantRoutePush = false; + } else { + _bridgeOffstage = false; + _instantRoutePush = true; } + } + _scheduleRouteSync(); + final innerLayout = isExpanded || _emptyPaneRetreating + ? buildExpandedLayout(constraints) + : buildCompactRouteList(); + return PaintVisibilityObserver( + detector: _paintVisibility, + child: innerLayout, + ); + } - return isExpanded - ? buildExpandedLayout(constraints) - : buildCompactLayout(); - }, - ); + return isExpanded || _emptyPaneRetreating + ? buildExpandedLayout(constraints) + : buildCompactLayout(); } } diff --git a/lib/src/core/list_detail/list_detail_layout_builders.dart b/lib/src/core/list_detail/list_detail_layout_builders.dart index 9795cf1..698450f 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -12,62 +12,226 @@ extension _LayoutBuilders on _ListDetailLayoutState { // EXPANDED LAYOUT // =========================================================================== - /// Expanded: side-by-side panes with draggable divider. + /// Expanded: side-by-side panes with draggable divider, rebuilt per + /// animation tick without setState listeners. Widget buildExpandedLayout(BoxConstraints constraints) { + return AnimatedBuilder( + animation: Listenable.merge([ + _expandEntryController, + _detailPaneController, + ]), + builder: (context, _) => _buildExpandedLayoutInner(constraints), + ); + } + + Widget _buildExpandedLayoutInner(BoxConstraints constraints) { final availableWidth = constraints.maxWidth; - _lastExpandedWidth = availableWidth; - final listWidth = _paneWidth.width(availableWidth); + // Not during an empty-pane retreat: that build runs at the COMPACT + // width and would corrupt the reference the crossing math uses. + if (_isExpanded) _lastExpandedWidth = availableWidth; + // Both animations scale SLOTS only — the width model is untouched, + // so dividers and anchors keep their real geometry when they settle. + // Parked panes don't dance: a collapsed START pane pins the entry + // (a 56px rail has no slide to perform) and any collapsed pane pins + // the detail-pane presence (the rail docks at its final position + // from frame one; only the list's slide-in participates). + final collapsed = _paneWidth.collapsed; + final entry = + collapsed != PaneSide.start && _expandEntryController.isAnimating + ? widget.compactConfig.curve.transform(_expandEntryController.value) + : 1.0; + final listOnly = + widget.expandedEmptyBehavior == ExpandedEmptyBehavior.listOnly; + // Detail-pane presence: 0 = list owns the full width. In listOnly it + // tracks the selection; in placeholder behavior it is pinned at 1 + // except while an empty-pane crossing animation (reveal or retreat) + // is in flight. + final pane = + collapsed == null && (listOnly || _detailPaneController.isAnimating) + ? widget.compactConfig.curve.transform(_detailPaneController.value) + : 1.0; + final finalListWidth = _paneWidth.width(availableWidth); + // One formula, three motions: pane=1 → the entry scaling; entry=1 → + // the pane reveal; both settled → the plain two-pane split. + final listWidth = + availableWidth - (availableWidth - finalListWidth * entry) * pane; final selectedId = _controller.selectedId; + final detailId = listOnly ? _visibleDetailId : selectedId; final dividerBuilder = widget.dividerBuilder; + Widget list = _keyedList(selectedId); + if (collapsed == PaneSide.start) { + list = _collapsedSlot( + pane: list, + paneLayoutWidth: widget.paneConfig.minListWidth, + railBuilder: widget.collapsedListBuilder, + alignment: AlignmentDirectional.centerStart, + ); + } else if (entry < 1.0 && + widget.paneConfig.entryStyle == ExpandedEntryStyle.reveal) { + // Reveal (default): the arriving list is laid out at its FINAL + // width and slides in clipped — its content never reflows during + // the entry. The yielding detail resizes live instead: its first + // frame is exactly the full-width layout compact just showed, so + // there is no jump, and native content panes reflow the same way + // under a sliding sidebar. `ExpandedEntryStyle.resize` skips the + // wrap: the list lays out at the animated slot width and reflows. + list = ClipRect( + child: OverflowBox( + minWidth: finalListWidth, + maxWidth: finalListWidth, + alignment: AlignmentDirectional.centerEnd, + child: list, + ), + ); + } + + // The end pane's floor mirrors the start pane's ceiling. + final minDetailWidth = + availableWidth * (1 - widget.paneConfig.maxListRatio); + + Widget detailSlot; + if (detailId != null) { + // While a route (active or exiting) holds the detail key, the + // pane leaves its slot empty — exactly one key holder per frame. + // The route still covers the screen for that frame, so the empty + // slot is never visible. + detailSlot = ValueListenableBuilder( + valueListenable: _detailRouted, + builder: (context, routed, _) => routed + ? const SizedBox.shrink() + : KeyedSubtree( + key: _detailKey, + child: widget.detailBuilder( + context, + detailId, + DetailLayoutMode.sideBySide, + _handleDismiss, + ), + ), + ); + if (collapsed == PaneSide.end) { + detailSlot = _collapsedSlot( + pane: detailSlot, + paneLayoutWidth: minDetailWidth, + railBuilder: widget.collapsedDetailBuilder, + alignment: AlignmentDirectional.centerEnd, + ); + } else if (listOnly && pane < 1.0) { + // The arriving pane reveals from the end edge laid at its FINAL + // width, clipped — same no-reflow discipline as the expand + // entry. Start-aligned: a rigid sheet sliding in from the end + // shows its leading portion first. + detailSlot = ClipRect( + child: OverflowBox( + minWidth: availableWidth - finalListWidth, + maxWidth: availableWidth - finalListWidth, + alignment: AlignmentDirectional.centerStart, + child: detailSlot, + ), + ); + } + } else if (listOnly) { + // Full-width list; the pane slot is zero-width and empty. + detailSlot = const SizedBox.shrink(); + } else { + detailSlot = + widget.emptyStateBuilder?.call(context) ?? const SizedBox.shrink(); + if (collapsed == PaneSide.end) { + // The empty placeholder collapses like content — rail slot or + // clip-at-floor, never squished raw into the parked width. + detailSlot = _collapsedSlot( + pane: detailSlot, + paneLayoutWidth: minDetailWidth, + railBuilder: widget.collapsedDetailBuilder, + alignment: AlignmentDirectional.centerEnd, + ); + } else if (pane < 1.0) { + // The empty pane rides its crossing animation with the same + // reveal discipline as content panes: laid at final width, + // clipped — no re-centering wobble while the slot animates. + detailSlot = ClipRect( + child: OverflowBox( + minWidth: availableWidth - finalListWidth, + maxWidth: availableWidth - finalListWidth, + alignment: AlignmentDirectional.centerStart, + child: detailSlot, + ), + ); + } + } + return Stack( children: [ - Row( - children: [ - SizedBox( - width: listWidth, - child: widget.listBuilder(context, selectedId, _handleSelect), - ), - Expanded( - child: selectedId != null - ? KeyedSubtree( - key: _detailKey, - child: widget.detailBuilder( - context, - selectedId, - DetailLayoutMode.sideBySide, - _handleDismiss, - ), - ) - : widget.emptyStateBuilder?.call(context) ?? - const SizedBox.shrink(), - ), - ], - ), - // Divider — visual (if builder provided) or invisible drag zone - PositionedDirectional( - start: listWidth - 12, // 24px hit area centered on the border - top: 0, - bottom: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => _handleDividerDragStart(), - onHorizontalDragUpdate: (d) => - _handleDividerDragUpdate(d.primaryDelta ?? 0), - onHorizontalDragEnd: (_) => _handleDividerDragEnd(), - child: SizedBox( - width: 24, - child: dividerBuilder != null - ? dividerBuilder( - context, - _isDividerDragging, - _isDividerSettling, - ) - : null, + if (collapsed == PaneSide.end && entry < 1.0) + // The rail docks at the end edge from frame one; the list + // slides in beside it. The gap between them is background — + // the parked pane doesn't replay a collapse it already did. + Row( + children: [ + SizedBox(width: listWidth, child: list), + const Expanded(child: SizedBox.shrink()), + SizedBox( + width: widget.paneConfig.collapsedSize, + child: detailSlot, + ), + ], + ) + else + Row( + children: [ + SizedBox(width: listWidth, child: list), + Expanded(child: detailSlot), + ], + ), + // Divider — visual (if builder provided) or invisible drag zone. + // In listOnly it exists whenever the pane does, riding the + // animated seam — appearing only after settle reads as a pop-in. + // When a pane is collapsed the divider parks at its edge, clamped + // fully on-screen so it stays grabbable. + if (!listOnly || detailId != null) + PositionedDirectional( + // Hit area centered on the pane border — the DOCKED boundary + // while a parked detail's crossing slide runs. + start: + ((collapsed == PaneSide.end && entry < 1.0 + ? finalListWidth + : listWidth) - + widget.paneConfig.dividerHitWidth / 2) + .clamp( + 0.0, + availableWidth - widget.paneConfig.dividerHitWidth, + ), + top: 0, + bottom: 0, + child: PaneDividerRegion( + hitWidth: widget.paneConfig.dividerHitWidth, + stateFor: (focused) => + _dividerState(availableWidth, isFocused: focused), + dividerBuilder: dividerBuilder, + onDragStart: _handleDividerDragStart, + onDragDelta: _handleDividerDragUpdate, + onDragEnd: _handleDividerDragEnd, + onStep: _handleDividerStep, + onToggleCollapse: _handleDividerToggleCollapse, + onJumpToMinimum: _handleDividerJumpToMinimum, + onJumpToMaximum: _handleDividerJumpToMaximum, + onReset: _handleDividerReset, + semanticsLabel: widget.paneConfig.dividerSemanticsLabel, + semanticsValue: _paneSharePercent(listWidth, 0, availableWidth), + semanticsIncreasedValue: _paneSharePercent( + listWidth, + PaneDividerRegion.keyboardStep, + availableWidth, + ), + semanticsDecreasedValue: _paneSharePercent( + listWidth, + -PaneDividerRegion.keyboardStep, + availableWidth, + ), ), ), - ), ], ); } @@ -89,9 +253,15 @@ extension _LayoutBuilders on _ListDetailLayoutState { Widget body = Stack( children: [ - // List — always rendered behind detail (visible during swipe peek) + // List — always rendered behind detail (visible during swipe peek), + // but hidden from assistive tech while covered: a screen reader + // must not traverse content under the open detail, exactly as it + // wouldn't under a pushed route. Positioned.fill( - child: widget.listBuilder(context, selectedId, _handleSelect), + child: ExcludeSemantics( + excluding: showDetail, + child: _keyedList(selectedId), + ), ), // Detail — slides over list, stays during dismiss animation if (showDetail) Positioned.fill(child: buildSlidingDetail()), @@ -132,8 +302,14 @@ extension _LayoutBuilders on _ListDetailLayoutState { return ValueListenableBuilder( valueListenable: _paintVisibility.notifier, builder: (_, visible, _) { - if (!visible) return const SizedBox.shrink(); - return buildSlidingDetail(); + if (!visible || _visibleDetailId == null) { + return const SizedBox.shrink(); + } + // The overlay covers the whole page — remove everything under + // it from the semantics tree, same primitive Drawer and + // ModalBarrier use. Guarded above: an ever-present blocker + // would silence the page with no detail open. + return BlockSemantics(child: buildSlidingDetail()); }, ); }, @@ -151,7 +327,7 @@ extension _LayoutBuilders on _ListDetailLayoutState { Widget buildCompactOverlayList() { final selectedId = _controller.selectedId; - Widget list = widget.listBuilder(context, selectedId, _handleSelect); + Widget list = _keyedList(selectedId); if (widget.compactConfig.handleBackGesture) { list = PopScope( canPop: selectedId == null, @@ -166,9 +342,130 @@ extension _LayoutBuilders on _ListDetailLayoutState { } // =========================================================================== - // SHARED SLIDING DETAIL (used by both compact modes) + // COMPACT ROUTE LIST (CompactDetailMode.route) // =========================================================================== + /// Compact layout for route mode: the list alone — the detail lives in a + /// real page route above. No [PopScope], no swipe machinery: the route + /// owns back and transitions. + Widget buildCompactRouteList() { + final selectedId = _controller.selectedId; + if (_bridgeDetail && !_detailRouted.value && selectedId != null) { + // The frame between a resize into compact (or a deep-link mount) and + // the push: render the detail inline under its key so the element + // stays alive for the handoff. The push claims the key post-frame. + final keyed = KeyedSubtree( + key: _detailKey, + child: widget.detailBuilder( + context, + selectedId, + DetailLayoutMode.stacked, + _handleDismiss, + ), + ); + if (_bridgeOffstage) { + // Discrete crossing: the route's real entrance animates over the + // LIST — the bridge only keeps the element alive, invisibly. + return Stack( + children: [ + Positioned.fill(child: _keyedList(selectedId)), + Offstage(child: keyed), + ], + ); + } + return keyed; + } + return _keyedList(selectedId); + } + + // =========================================================================== + // SHARED SLIDING DETAIL (used by inline and overlay compact modes) + // =========================================================================== + + /// A collapsed pane's slot. With a rail builder the rail lays out at + /// the REAL slot width while the pane parks offstage, state alive + /// (tickers paused) — the app renders a purpose-built icon rail. + /// Without one, the pane stays laid out at its floor width — its last + /// legal layout — and clips as the slot shrinks: content never + /// reflows below the floor the app designed for. + Widget _collapsedSlot({ + required Widget pane, + required double paneLayoutWidth, + required WidgetBuilder? railBuilder, + required AlignmentDirectional alignment, + }) { + // Mid-crossing the slot is far wider than the parked width — a + // 56px-designed rail stretched over it reads broken. Ride the + // shrink with real content instead: reflow live down to the floor + // width, rigid-and-clipped below it. The rail takes over on arrival. + Widget heldAtFloor() => ClipRect( + child: LayoutBuilder( + builder: (context, slot) { + final width = slot.maxWidth < paneLayoutWidth + ? paneLayoutWidth + : slot.maxWidth; + return OverflowBox( + minWidth: width, + maxWidth: width, + alignment: alignment, + child: pane, + ); + }, + ), + ); + if (railBuilder == null) return heldAtFloor(); + // StackFit.expand — the stack must take the slot's size, not the + // offstage child's zero size. + return Stack( + fit: StackFit.expand, + children: [ + Offstage( + child: TickerMode( + enabled: false, + child: OverflowBox( + minWidth: paneLayoutWidth, + maxWidth: paneLayoutWidth, + alignment: alignment, + child: pane, + ), + ), + ), + Builder(builder: railBuilder), + ], + ); + } + + /// Formats the start pane's share after [delta], clamped to the pane + /// limits, for the screen-reader value contract. + String _paneSharePercent(double width, double delta, double availableWidth) { + // Same min-wins guard as the width model: a narrow window (the + // empty-pane retreat renders expanded geometry at compact width) + // can push the ratio ceiling below the floor — clamp throws on + // inverted bounds. + final floor = widget.paneConfig.minListWidth; + final ceiling = availableWidth * widget.paneConfig.maxListRatio; + final clamped = ceiling <= floor + ? floor + : (width + delta).clamp(floor, ceiling); + return '${(clamped / availableWidth * 100).round()}%'; + } + + /// The divider's interaction state for [DividerBuilder]s. + DividerState _dividerState(double availableWidth, {bool isFocused = false}) { + final collapsed = _paneWidth.collapsed; + final width = _paneWidth.width(availableWidth); + final max = availableWidth * widget.paneConfig.maxListRatio; + return DividerState( + isDragging: _isDividerDragging, + isSettling: _isDividerSettling, + atMinimum: + collapsed == null && width <= widget.paneConfig.minListWidth + 0.5, + atMaximum: collapsed == null && width >= max - 0.5, + collapsed: collapsed, + isFocused: isFocused, + ); + } + /// The sliding detail pane with swipe-to-dismiss gesture. /// Used by both [buildCompactLayout] (inline) and /// `buildCompactOverlayLayout` (in the overlay). @@ -182,17 +479,37 @@ extension _LayoutBuilders on _ListDetailLayoutState { onHorizontalDragEnd: _handleCompactDragEnd, child: SlideTransition( position: _slideAnimation, - child: ColoredBox( - color: - widget.compactConfig.detailBackground ?? - Theme.of(context).colorScheme.surface, - child: KeyedSubtree( - key: _detailKey, - child: widget.detailBuilder( - context, - detailId, - DetailLayoutMode.stacked, - _handleDismiss, + // Route parity for assistive tech and keyboards: the open detail + // reads as a route boundary to screen readers, and DismissIntent + // (Escape, via WidgetsApp's default shortcuts) dismisses when the + // focus sits inside the detail — the same courtesy ModalRoute + // extends. Intents bubble from the focused node, so no focus is + // stolen on open. + child: Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + _handleDismiss(); + return null; + }, + ), + }, + child: ColoredBox( + color: + widget.compactConfig.detailBackground ?? + Theme.of(context).colorScheme.surface, + child: KeyedSubtree( + key: _detailKey, + child: widget.detailBuilder( + context, + detailId, + DetailLayoutMode.stacked, + _handleDismiss, + ), + ), ), ), ), diff --git a/lib/src/core/list_detail/paint_visibility_detector.dart b/lib/src/core/list_detail/paint_visibility_detector.dart index 49e704a..4c17421 100644 --- a/lib/src/core/list_detail/paint_visibility_detector.dart +++ b/lib/src/core/list_detail/paint_visibility_detector.dart @@ -60,6 +60,13 @@ class PaintVisibilityDetector { _wasPaintedLastFrame = false; } + /// Whether paint fired since the last [evaluate] reset. Route mode's + /// one-shot post-frame check peeks this AFTER a frame whose build ran + /// [evaluate]: the reset-then-paint ordering makes the flag a true + /// painted-this-frame signal for exactly that frame. Read-only — + /// [evaluate] owns the reset. + bool get paintedThisFrame => _wasPaintedLastFrame; + /// Called by [PaintVisibilityObserver] during paint. /// /// Marks this detector as painted. If transitioning from not-painted to diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart new file mode 100644 index 0000000..3a49f7f --- /dev/null +++ b/lib/src/core/modal/adaptive_modal.dart @@ -0,0 +1,449 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:adaptive_layouts/src/core/modal/modal_config.dart'; +import 'package:adaptive_layouts/src/core/modal/modal_layout_mode.dart'; +import 'package:adaptive_layouts/src/core/modal/modal_morph.dart'; +import 'package:adaptive_layouts/src/core/shared/adaptive_layout_config.dart'; + +/// Signature for building the modal's content. +/// +/// [mode] is the current presentation form. To keep state across a live +/// form change, return the same root widget type for both modes — the +/// content is moved between the two routes under a stable key, and a +/// different root type would defeat the move. +typedef AdaptiveModalBuilder = + Widget Function(BuildContext context, ModalLayoutMode mode); + +/// Shows a modal that presents as a real Material dialog on expanded +/// widths and a real Material bottom sheet on compact widths — and +/// live-swaps between the two when the window is resized across the +/// breakpoint, preserving the content widget's state. +/// +/// The two forms are Flutter's own [DialogRoute] and +/// [ModalBottomSheetRoute], so barrier semantics, theming +/// ([DialogThemeData], [BottomSheetThemeData]), drag-to-dismiss physics, +/// back handling, and accessibility labels are all Material's — not +/// re-implementations. On a resize across the breakpoint the active route +/// is atomically replaced (no exit/entrance animation) and the content +/// element is reparented into the new route in the same frame. +/// +/// The returned future completes with the pop result when the modal is +/// dismissed, no matter how many form swaps happened in between. +/// +/// ```dart +/// final choice = await showAdaptiveModal( +/// context: context, +/// builder: (context, mode) => SettingsSheet( +/// showCloseButton: mode == ModalLayoutMode.dialog, +/// ), +/// ); +/// ``` +/// +/// The dialog form wraps the content in a [Dialog]; the sheet form lets +/// [ModalBottomSheetRoute] provide its [BottomSheet]. The breakpoint +/// resolves at call time: [expandedBreakpoint] param > inherited +/// [AdaptiveLayoutConfig] > 720. +Future showAdaptiveModal({ + required BuildContext context, + required AdaptiveModalBuilder builder, + ModalConfig config = const ModalConfig(), + double? expandedBreakpoint, + bool useRootNavigator = true, + RouteSettings? routeSettings, +}) { + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + final session = _ModalSession( + navigator: navigator, + builder: builder, + config: config, + breakpoint: AdaptiveLayoutConfig.resolveBreakpoint( + context, + expandedBreakpoint, + ), + themes: InheritedTheme.capture(from: context, to: navigator.context), + settings: routeSettings, + ); + return session.open(MediaQuery.sizeOf(context).width); +} + +/// One open modal: owns the content key, the active route, and the +/// result future across route swaps. +class _ModalSession { + _ModalSession({ + required this.navigator, + required this.builder, + required this.config, + required this.breakpoint, + required this.themes, + required this.settings, + }); + + final NavigatorState navigator; + final AdaptiveModalBuilder builder; + final ModalConfig config; + final double breakpoint; + final CapturedThemes themes; + final RouteSettings? settings; + + /// Anchors the content element so it reparents between routes on swap. + final GlobalKey _contentKey = GlobalKey(); + + /// Proxies the pop result: the caller awaits this single future while + /// the underlying route may be replaced any number of times. + final Completer _result = Completer(); + + Route? _active; + ModalLayoutMode? _activeMode; + + /// True while the active sheet route is the chrome-less ghost a flight + /// lands on; the landing replaces it with a normally-chromed route. + bool _activeIsGhost = false; + bool _swapScheduled = false; + + /// The in-flight container transform, when a swap is morphing. + ModalMorphFlight? _flight; + + /// Drives the routes' content slot: placeholder while a flight holds + /// the content, the keyed content otherwise. + final ValueNotifier _morphing = ValueNotifier(false); + + Future open(double width) { + _push(_modeFor(width), animate: true); + return _result.future; + } + + ModalLayoutMode _modeFor(double width) => + width >= breakpoint ? ModalLayoutMode.dialog : ModalLayoutMode.sheet; + + /// Pushes the route for [mode]. On swap, [removeFirst] is the outgoing + /// route — removed AFTER `_active` points at the new route, so the old + /// route's completion is recognized as stale by the identity guard, and + /// BEFORE the push, so both changes land in one frame (the content's + /// reparent window). + void _push( + ModalLayoutMode mode, { + required bool animate, + Route? removeFirst, + bool ghost = false, + }) { + final route = _buildRoute(mode, animate: animate, ghost: ghost); + _active = route; + _activeMode = mode; + _activeIsGhost = ghost && mode == ModalLayoutMode.sheet; + unawaited( + route.popped.then((value) { + // Identity guard: a swapped-out route also completes (with null) + // when removed — only the currently-active route's completion is + // the user dismissing the modal. + if (identical(route, _active) && !_result.isCompleted) { + // A dismissal mid-flight lands the content into the exiting + // route immediately, so the exit animation shows it. + _endFlight(); + _result.complete(value); + } + }), + ); + if (removeFirst != null) navigator.removeRoute(removeFirst); + unawaited(navigator.push(route)); + } + + /// Called from the content's build when the window width no longer + /// matches the presented form. Navigation can't run during build, so + /// the swap is deferred to after the frame. + void _requestSwap() { + if (_swapScheduled) return; + _swapScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _swapScheduled = false; + if (_result.isCompleted || !navigator.mounted) return; + final active = _active; + if (active == null) return; + // Re-derive from the live width — it may have crossed back during + // the frame the swap waited on. + final target = _modeFor(MediaQuery.sizeOf(navigator.context).width); + if (target == _activeMode) return; + final morphing = config.morph && _beginOrRetargetFlight(target); + _push(target, animate: false, removeFirst: active, ghost: morphing); + }); + } + + /// The drag-handle decision for the REAL sheet route, mirroring + /// `BottomSheet`'s own resolution so ghost spacing and flight replica + /// match what lands. + bool _effectiveShowDragHandle(ThemeData theme) => + config.showDragHandle ?? + (config.enableDrag && (theme.bottomSheetTheme.showDragHandle ?? false)); + + /// The vertical offset between a form's surface and its content: the + /// drag-handle band for a handle-showing sheet, 0 otherwise. + double _contentInsetFor(ModalLayoutMode mode, ThemeData theme) => + mode == ModalLayoutMode.sheet && _effectiveShowDragHandle(theme) + ? kMinInteractiveDimension + : 0; + + /// Launches the container transform for a swap toward [target] — or, if + /// a flight is already in the air (the window crossed back mid-morph), + /// redirects it from its current visual state. Runs BEFORE the route + /// swap while `_activeMode` is still the outgoing form and the content + /// (in the outgoing route or the existing flight) is still measurable. + /// Returns whether a flight is airborne; false bails to the instant cut. + bool _beginOrRetargetFlight(ModalLayoutMode target) { + final overlay = navigator.overlay; + if (overlay == null) return false; + final theme = Theme.of(navigator.context); + final end = ModalFormVisuals.of( + theme, + target, + backgroundColor: config.backgroundColor, + ); + + final flight = _flight; + if (flight != null) { + flight.retarget( + end: end, + mode: target, + contentInsetEnd: _contentInsetFor(target, theme), + ); + return true; + } + + final outgoingMode = _activeMode; + final box = _contentKey.currentContext?.findRenderObject(); + final overlayBox = overlay.context.findRenderObject(); + if (outgoingMode == null) return false; + if (box is! RenderBox || overlayBox is! RenderBox) return false; + if (!box.attached || !box.hasSize) return false; + final startRect = + box.localToGlobal(Offset.zero, ancestor: overlayBox) & box.size; + + _flight = ModalMorphFlight( + overlay: overlay, + startRect: startRect, + start: ModalFormVisuals.of( + theme, + outgoingMode, + backgroundColor: config.backgroundColor, + ), + end: end, + duration: config.morphDuration, + curve: config.morphCurve, + targetMode: target, + contentInsetStart: _contentInsetFor(outgoingMode, theme), + contentInsetEnd: _contentInsetFor(target, theme), + handleColor: + theme.bottomSheetTheme.dragHandleColor ?? + theme.colorScheme.onSurfaceVariant, + handleSize: theme.bottomSheetTheme.dragHandleSize ?? const Size(32, 4), + // The Builder sits ABOVE the key: the subtree from the keyed node + // down must be identical in every host (routes and flight), or the + // reparent degrades into a rebuild and state dies. + contentBuilder: (context) => themes.wrap( + Builder( + builder: (context) { + final flight = _flight; + return KeyedSubtree( + key: _contentKey, + child: builder( + context, + flight == null ? target : flight.targetMode, + ), + ); + }, + ), + ), + // Land one frame after arrival so the placeholder has adopted the + // content's final reported size — the handoff is then pixel-clean. + onCompleted: () { + final flight = _flight; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (identical(flight, _flight)) _endFlight(landed: true); + }); + }, + ); + _morphing.value = true; + _flight!.insert(); + return true; + } + + /// Hands the content from the flight into the active route's slot and + /// tears the flight down — all in one synchronous block, so the element + /// moves in a single frame. A [landed] flight over a ghost sheet also + /// replaces the ghost with the normally-chromed route in that same + /// block, so the real chrome appears exactly under the flight's final, + /// identical-looking frame. + void _endFlight({bool landed = false}) { + final flight = _flight; + if (flight == null) return; + if (landed && _activeIsGhost) { + final ghost = _active; + _push(ModalLayoutMode.sheet, animate: false, removeFirst: ghost); + } + _morphing.value = false; + _flight = null; + flight.dispose(); + } + + Route _buildRoute( + ModalLayoutMode mode, { + required bool animate, + bool ghost = false, + }) { + // Swap pushes skip the entrance animation (the modal is already + // visually present) and must WIN over the consumer's style; first + // entrances and exits use the consumer's, else Material's timing. + final style = animate ? null : AnimationStyle(duration: Duration.zero); + switch (mode) { + case ModalLayoutMode.dialog: + return DialogRoute( + context: navigator.context, + builder: (context) => _ModalScope(session: this, mode: mode), + themes: themes, + barrierColor: config.barrierColor ?? Colors.black54, + barrierDismissible: config.barrierDismissible, + barrierLabel: config.barrierLabel, + useSafeArea: config.useSafeArea, + settings: settings, + anchorPoint: config.anchorPoint, + traversalEdgeBehavior: config.traversalEdgeBehavior, + requestFocus: config.requestFocus, + animationStyle: style ?? config.animationStyle, + ); + case ModalLayoutMode.sheet: + // The ghost variant a flight lands on: chrome-less (transparent + // surface, no handle, no drag) but with the REAL barrier and the + // real layout machinery, so scrim continuity and target tracking + // hold while nothing of the destination is visible before the + // flight becomes it. + return ModalBottomSheetRoute( + builder: (context) => _ModalScope(session: this, mode: mode), + capturedThemes: themes, + isScrollControlled: config.isScrollControlled, + modalBarrierColor: config.barrierColor, + isDismissible: config.barrierDismissible, + barrierLabel: config.barrierLabel, + enableDrag: ghost ? false : config.enableDrag, + showDragHandle: ghost ? false : config.showDragHandle, + backgroundColor: ghost ? Colors.transparent : config.backgroundColor, + elevation: ghost ? 0 : null, + clipBehavior: Clip.antiAlias, + constraints: config.constraints, + scrollControlDisabledMaxHeightRatio: + config.scrollControlDisabledMaxHeightRatio ?? 9.0 / 16.0, + settings: settings, + anchorPoint: config.anchorPoint, + requestFocus: config.requestFocus, + sheetAnimationStyle: style ?? config.sheetAnimationStyle, + ); + } + } +} + +/// Per-route content: watches the window width, requests a swap on a +/// breakpoint crossing, and mounts the user's content under the session's +/// stable key. +class _ModalScope extends StatelessWidget { + const _ModalScope({required this.session, required this.mode}); + + final _ModalSession session; + final ModalLayoutMode mode; + + @override + Widget build(BuildContext context) { + if (session._modeFor(MediaQuery.sizeOf(context).width) != mode) { + session._requestSwap(); + } + // While a flight holds the content, the route lays out a same-size + // placeholder instead — its live rect is the flight's landing target, + // and only one holder of the content key exists per frame. + return ValueListenableBuilder( + valueListenable: session._morphing, + builder: (context, morphing, _) { + final flight = session._flight; + final ghosting = morphing && flight != null; + final Widget content; + if (ghosting) { + // Placeholder width follows each form's convention — the slot + // decides for full-bleed sheets, the content decides for + // dialogs — so the landing target is width-correct from the + // first frame; only the height flows from the flight's + // measurement. The spacer stands in for the drag-handle band + // the ghost route omits, so the placeholder sits exactly where + // the content will land. + final inset = session._contentInsetFor(mode, Theme.of(context)); + final placeholder = ListenableBuilder( + listenable: Listenable.merge([ + flight.contentSize, + flight.sampleRevision, + ]), + builder: (context, _) { + final size = flight.contentSize.value; + // Width comes from the flight's one-time natural-width + // sample: self-sized content keeps its own width in either + // form; full-bleed content takes the slot's width. The live + // measure only supplies the height (the width is container- + // driven mid-flight — feeding it back here would be + // circular). + final natural = flight.naturalWidth; + final double width; + if (natural == null) { + width = mode == ModalLayoutMode.sheet + ? double.infinity + : size.width; + } else { + width = flight.isFullBleed ? double.infinity : natural; + } + return SizedBox( + key: flight.placeholderKey, + width: width, + height: size.height, + ); + }, + ); + // Padding, not a Column with a spacer: the real BottomSheet lays + // its content via Padding(top: band) INSIDE the bounded height, + // so constraint-filling content gets height − band. A stacked + // spacer would overflow the slot by exactly the band. + content = inset == 0 + ? placeholder + : Padding( + padding: EdgeInsets.only(top: inset), + child: placeholder, + ); + } else { + content = KeyedSubtree( + key: session._contentKey, + child: session.builder(context, mode), + ); + } + if (mode != ModalLayoutMode.dialog) return content; + // antiAlias (not Material's default Clip.none) so corner rendering + // at rest matches the flight's clipped surface — content painting + // near the corners would otherwise pop square at the handoff. The + // ghost variant is fully invisible: the flight IS the dialog until + // landing, when this same widget rebuilds with real chrome. + // The ValueKey flips the Dialog's identity at the ghost → real + // transition so its Material mounts fresh at full elevation — + // updating in place would implicitly tween the shadow over 200ms + // (a visible bloom after landing). The content survives the + // remount via its GlobalKey. + return ghosting + ? Dialog( + key: const ValueKey('ghost'), + backgroundColor: Colors.transparent, + elevation: 0, + shadowColor: Colors.transparent, + clipBehavior: Clip.antiAlias, + child: content, + ) + : Dialog( + key: const ValueKey('real'), + backgroundColor: session.config.backgroundColor, + clipBehavior: Clip.antiAlias, + child: content, + ); + }, + ); + } +} diff --git a/lib/src/core/modal/modal_config.dart b/lib/src/core/modal/modal_config.dart new file mode 100644 index 0000000..e660b48 --- /dev/null +++ b/lib/src/core/modal/modal_config.dart @@ -0,0 +1,132 @@ +import 'package:flutter/widgets.dart'; + +/// Configuration for `showAdaptiveModal`. +/// +/// Deliberately thin: the modal is presented by Flutter's own `DialogRoute` +/// and `ModalBottomSheetRoute`, so chrome, theming, drag physics, and +/// accessibility all come from Material. Most fields only forward to the +/// matching parameters on those routes; the `morph*` fields drive the +/// container transform played when a resize swaps the two forms. +/// +/// ```dart +/// showAdaptiveModal( +/// context: context, +/// config: const ModalConfig(showDragHandle: true), +/// builder: (context, mode) => MyModalContent(), +/// ) +/// ``` +class ModalConfig { + /// Creates a modal configuration. + const ModalConfig({ + this.backgroundColor, + this.barrierDismissible = true, + this.barrierColor, + this.barrierLabel, + this.useSafeArea = true, + this.isScrollControlled = true, + this.enableDrag = true, + this.showDragHandle, + this.constraints, + this.scrollControlDisabledMaxHeightRatio, + this.anchorPoint, + this.traversalEdgeBehavior, + this.requestFocus, + this.animationStyle, + this.sheetAnimationStyle, + this.morph = true, + this.morphDuration = const Duration(milliseconds: 350), + this.morphCurve = Curves.easeInOutCubicEmphasized, + }); + + /// Surface color for BOTH forms, overriding each form's theme + /// resolution — Material's defaults give dialogs and sheets different + /// surface tones (`surfaceContainerHigh` vs `surfaceContainerLow`); + /// set this when the modal should keep one color across the morph. + /// `null` keeps each form's own themed color (the flight crossfades + /// between them). + final Color? backgroundColor; + + /// Whether tapping the barrier dismisses the modal. Both forms. + final bool barrierDismissible; + + /// Color of the barrier behind the modal. Both forms. + /// `null` uses each route's Material default. + final Color? barrierColor; + + /// Whether the dialog form avoids system intrusions (status bar, notches). + /// Forwards to `DialogRoute.useSafeArea`. + final bool useSafeArea; + + /// Whether the sheet form may grow to the full window height. + /// Forwards to `ModalBottomSheetRoute.isScrollControlled`. + /// + /// Defaults to true (unlike `showModalBottomSheet`) so content gets the + /// same height freedom in both forms. + final bool isScrollControlled; + + /// Whether the sheet form can be dragged down to dismiss. + /// Forwards to `ModalBottomSheetRoute.enableDrag`. + final bool enableDrag; + + /// Whether the sheet form shows Material's drag handle. + /// Forwards to `ModalBottomSheetRoute.showDragHandle`; + /// `null` defers to `BottomSheetThemeData.showDragHandle`. + final bool? showDragHandle; + + // The fields below are pure passthroughs to the matching parameter on + // Flutter's own routes, under Flutter's own name — null always means + // "Flutter's default". Visual styling (shape, elevation, drag-handle + // look) is deliberately NOT here: it flows through DialogThemeData / + // BottomSheetThemeData, the channel that evolves with the platform. + // Also deliberately withheld: `clipBehavior` on the sheet (pinned to + // Clip.antiAlias — the container transform's landing is pixel-matched + // against it) and `transitionAnimationController` (the swap machinery + // owns the routes' lifecycles). + + /// Semantic label for the barrier, read by screen readers. + /// Forwards to both routes' `barrierLabel`. + final String? barrierLabel; + + /// Size constraints for the sheet form. + /// Forwards to `ModalBottomSheetRoute.constraints`. + final BoxConstraints? constraints; + + /// Max height ratio for the sheet form when [isScrollControlled] is + /// false. Forwards to + /// `ModalBottomSheetRoute.scrollControlDisabledMaxHeightRatio`. + final double? scrollControlDisabledMaxHeightRatio; + + /// Anchor for display-feature (fold / multi-screen) positioning. + /// Forwards to both routes' `anchorPoint`. + final Offset? anchorPoint; + + /// Focus-traversal behavior at the dialog's edges. + /// Forwards to `DialogRoute.traversalEdgeBehavior`. + final TraversalEdgeBehavior? traversalEdgeBehavior; + + /// Whether opening the modal moves focus into it. + /// Forwards to both routes' `requestFocus`. + final bool? requestFocus; + + /// Entrance/exit animation styling for the dialog form. Forwards to + /// `DialogRoute.animationStyle`. Applies to the FIRST entrance and + /// exits; breakpoint swaps keep their internal zero-duration override + /// (the modal is already visually present). + final AnimationStyle? animationStyle; + + /// Entrance/exit animation styling for the sheet form. Forwards to + /// `ModalBottomSheetRoute.sheetAnimationStyle`. Same swap override as + /// [animationStyle]. + final AnimationStyle? sheetAnimationStyle; + + /// Whether a form swap plays a container transform — the surface glides + /// and reshapes from one form's geometry to the other's, with the live + /// content inside. When false, the swap is an instant cut. + final bool morph; + + /// Duration of the container transform. + final Duration morphDuration; + + /// Curve of the container transform. + final Curve morphCurve; +} diff --git a/lib/src/core/modal/modal_layout_mode.dart b/lib/src/core/modal/modal_layout_mode.dart new file mode 100644 index 0000000..3c72a7b --- /dev/null +++ b/lib/src/core/modal/modal_layout_mode.dart @@ -0,0 +1,10 @@ +/// How the modal surface is presented. +enum ModalLayoutMode { + /// Bottom sheet sliding up from the bottom edge. + /// Used in compact layout. + sheet, + + /// Centered dialog. + /// Used in expanded layout. + dialog, +} diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart new file mode 100644 index 0000000..baafac3 --- /dev/null +++ b/lib/src/core/modal/modal_morph.dart @@ -0,0 +1,446 @@ +import 'dart:async'; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; + +import 'package:adaptive_layouts/src/core/modal/modal_layout_mode.dart'; + +/// The surface visuals of one modal form, resolved from the app theme so +/// the flight starts and ends looking like the real route chrome. +class ModalFormVisuals { + /// Creates a resolved set of surface visuals. + const ModalFormVisuals({ + required this.shape, + required this.color, + required this.elevation, + required this.shadowColor, + }); + + /// Resolves the visuals for [mode] from [theme], mirroring the Material + /// defaults `Dialog` and `BottomSheet` use when the theme sets nothing. + factory ModalFormVisuals.of( + ThemeData theme, + ModalLayoutMode mode, { + Color? backgroundColor, + }) { + switch (mode) { + case ModalLayoutMode.dialog: + return ModalFormVisuals( + shape: + theme.dialogTheme.shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + color: + backgroundColor ?? + theme.dialogTheme.backgroundColor ?? + theme.colorScheme.surfaceContainerHigh, + elevation: theme.dialogTheme.elevation ?? 6, + shadowColor: theme.dialogTheme.shadowColor ?? Colors.transparent, + ); + case ModalLayoutMode.sheet: + return ModalFormVisuals( + shape: + theme.bottomSheetTheme.shape ?? + const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + color: + backgroundColor ?? + theme.bottomSheetTheme.modalBackgroundColor ?? + theme.bottomSheetTheme.backgroundColor ?? + theme.colorScheme.surfaceContainerLow, + elevation: + theme.bottomSheetTheme.modalElevation ?? + theme.bottomSheetTheme.elevation ?? + 1, + shadowColor: theme.bottomSheetTheme.shadowColor ?? Colors.transparent, + ); + } + } + + /// Surface shape (all-corner radius for dialogs, top radius for sheets). + final ShapeBorder shape; + + /// Surface color. + final Color color; + + /// Surface elevation. + final double elevation; + + /// Shadow color — transparent by Material 3 default for both forms, so + /// the flight casts exactly the shadow the real chrome does (none, + /// unless the theme opts in). + final Color shadowColor; + + /// The visuals at fraction [t] between [a] and [b]. + static ModalFormVisuals lerp( + ModalFormVisuals a, + ModalFormVisuals b, + double t, + ) { + return ModalFormVisuals( + shape: ShapeBorder.lerp(a.shape, b.shape, t) ?? b.shape, + color: Color.lerp(a.color, b.color, t) ?? b.color, + elevation: lerpDouble(a.elevation, b.elevation, t) ?? b.elevation, + shadowColor: Color.lerp(a.shadowColor, b.shadowColor, t) ?? b.shadowColor, + ); + } +} + +/// One in-flight container transform between the modal's two forms. +/// +/// During a form swap the live content element mounts here — an overlay +/// entry above the routes — while the destination route lays out with a +/// same-size placeholder. The flight lerps a surface (rect, shape, color, +/// elevation) from the outgoing form's geometry to the placeholder's LIVE +/// geometry, tracked every frame so keyboard insets, route entrance +/// motion, and content reflow all steer the landing. On arrival the +/// content reparents into the route and the flight disposes. +/// +/// The sizing contract that makes the landing seamless without a +/// feedback loop: the content is laid out at the CONTAINER's lerped width +/// (so it reflows with the morph instead of being cropped by it), while +/// the placeholder's width follows each form's convention — the slot +/// decides for full-bleed sheets, the content decides for dialogs — and +/// only the HEIGHT flows from the flight's measurement ([contentSize]). +/// The placeholder's global rect — the true final geometry — is the +/// flight target throughout. +class ModalMorphFlight { + /// Starts a flight. The controller runs immediately. + ModalMorphFlight({ + required this.overlay, + required Rect startRect, + required ModalFormVisuals start, + required ModalFormVisuals end, + required Duration duration, + required this.curve, + required this.contentBuilder, + required VoidCallback onCompleted, + required this.targetMode, + required double contentInsetStart, + required double contentInsetEnd, + required this.handleColor, + required this.handleSize, + }) : _startRect = startRect, + _start = start, + _end = end, + _insetStart = contentInsetStart, + _insetEnd = contentInsetEnd, + contentSize = ValueNotifier(startRect.size) { + _controller = + AnimationController( + vsync: const _FlightTickerProvider(), + duration: duration, + ) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) onCompleted(); + }) + ..forward().ignore(); + } + + /// The navigator's overlay — flight host and coordinate space. + final OverlayState overlay; + + /// Builds the live content (the session's keyed subtree). + final WidgetBuilder contentBuilder; + + /// Flight timing curve. + final Curve curve; + + /// The form being flown toward. Updated on retarget. + ModalLayoutMode targetMode; + + /// Drag-handle replica visuals, resolved from the theme the way + /// `BottomSheet` resolves them. + final Color handleColor; + + /// See [handleColor]. + final Size handleSize; + + /// Marks the destination route's placeholder so its live rect can be + /// tracked as the flight target. + final GlobalKey placeholderKey = GlobalKey( + debugLabel: 'modal morph placeholder', + ); + + /// The content's laid-out size, reported from the flight one frame + /// behind layout. The destination placeholder mirrors it. + final ValueNotifier contentSize; + + /// The content's natural width, sampled ONCE from the first flight + /// layout (laid loose): a content narrower than its container decides + /// its own width; one that fills the container is full-bleed. After the + /// sample the content is laid TIGHT at the container's lerped width so + /// it reflows with the morph — the frozen sample (not the live, now + /// container-driven measure) feeds the placeholder's width, which is + /// what breaks the width feedback circle. + double? get naturalWidth => _naturalWidth; + double? _naturalWidth; + + /// Whether the sampled content fills whatever width it is given. + bool get isFullBleed => _isFullBleed; + bool _isFullBleed = false; + bool _widthSampled = false; + + /// Notifies when a natural-width sample lands, so the placeholder + /// rebuilds with it — the size channel alone can't carry this: the + /// tight-laid size often doesn't change when the sample does. + final ValueNotifier sampleRevision = ValueNotifier(0); + + /// Called synchronously from the measurer's layout pass; the + /// notification is deferred out of layout. + void _recordSample(Size natural, double containerWidth) { + _widthSampled = true; + _isFullBleed = natural.width >= containerWidth - 0.5; + _naturalWidth = natural.width; + WidgetsBinding.instance.addPostFrameCallback((_) { + sampleRevision.value++; + }); + } + + late final AnimationController _controller; + Rect _startRect; + ModalFormVisuals _start; + ModalFormVisuals _end; + + /// Vertical space between the surface's top edge and the content — the + /// drag-handle band (`kMinInteractiveDimension`) when that endpoint is a + /// handle-showing sheet, 0 otherwise. The tracked rects are CONTENT + /// rects; the painted surface extends above them by the lerped inset. + double _insetStart; + double _insetEnd; + Rect? _lastTargetRect; + OverlayEntry? _entry; + + double get _t => curve.transform(_controller.value); + + /// The surface rect at this moment of the flight. + Rect currentRect() { + final target = _placeholderRect() ?? _lastTargetRect ?? _startRect; + _lastTargetRect = target; + return Rect.lerp(_startRect, target, _t) ?? target; + } + + /// Inserts the flight into the overlay, above the routes. + void insert() { + final entry = OverlayEntry(builder: _build); + _entry = entry; + overlay.insert(entry); + } + + /// Redirects a flight already in the air: the current visual state + /// becomes the new start, and the controller restarts toward [end]. + void retarget({ + required ModalFormVisuals end, + required ModalLayoutMode mode, + required double contentInsetEnd, + }) { + _startRect = currentRect(); + _start = ModalFormVisuals.lerp(_start, _end, _t); + _insetStart = lerpDouble(_insetStart, _insetEnd, _t) ?? _insetEnd; + _insetEnd = contentInsetEnd; + _end = end; + targetMode = mode; + _lastTargetRect = null; + // The builder now produces the OTHER form's content, whose width + // behavior may differ — resample it (invisibly, in layout). + _widthSampled = false; + unawaited(_controller.forward(from: 0)); + _entry?.markNeedsBuild(); + } + + /// Stops the flight and removes it from the overlay. The caller flips + /// the session's morphing flag in the same synchronous block so the + /// content reparents into the destination route this frame. + void dispose() { + _controller + ..stop() + ..dispose(); + _entry?.remove(); + _entry?.dispose(); + _entry = null; + // contentSize is deliberately not disposed: the destination's + // placeholder may still be unsubscribing during the handoff frame. + } + + Rect? _placeholderRect() { + final box = placeholderKey.currentContext?.findRenderObject(); + final overlayBox = overlay.context.findRenderObject(); + if (box is! RenderBox || overlayBox is! RenderBox) return null; + if (!box.attached || !box.hasSize) return null; + return box.localToGlobal(Offset.zero, ancestor: overlayBox) & box.size; + } + + Widget _build(BuildContext context) { + final maxHeight = MediaQuery.sizeOf(context).height; + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final rect = currentRect(); + final t = _t; + final visuals = ModalFormVisuals.lerp(_start, _end, t); + final inset = lerpDouble(_insetStart, _insetEnd, t) ?? _insetEnd; + // The tracked rect is the CONTENT rect; the surface extends above + // it by the handle band of whichever endpoint(s) have one. + final surfaceRect = Rect.fromLTRB( + rect.left, + rect.top - inset, + rect.right, + rect.bottom, + ); + final handleOpacity = switch ((_insetStart > 0, _insetEnd > 0)) { + (true, true) => 1.0, + (false, true) => t, + (true, false) => 1.0 - t, + (false, false) => 0.0, + }; + return Stack( + children: [ + Positioned.fromRect( + rect: surfaceRect, + child: IgnorePointer( + child: Material( + // Material implicitly animates shape/elevation changes + // over ~200ms. The flight IS the animation — without + // zero here Material's internal tween lags the lerp and + // the corners pop at landing. + animationDuration: Duration.zero, + clipBehavior: Clip.antiAlias, + shape: visuals.shape, + color: visuals.color, + elevation: visuals.elevation, + shadowColor: visuals.shadowColor, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Padding( + padding: EdgeInsets.only(top: inset), + child: OverflowBox( + alignment: Alignment.topCenter, + // Always tight: the content shrinks/grows WITH + // the container instead of jumping to its own + // width. The natural-width sample is taken by + // the measurer in a loose pre-pass inside the + // same layout, so no loose frame ever paints. + minWidth: rect.width, + maxWidth: rect.width, + minHeight: 0, + // Mirror the destination's height math: the + // handle band eats into the bounded slot, so + // constraint-filling content must measure at + // height − band or the placeholder disagrees + // with what actually lands. + maxHeight: maxHeight - inset, + child: _MeasureSize( + sampleNeeded: !_widthSampled, + onSample: _recordSample, + onSize: (size) { + if (contentSize.value != size) { + contentSize.value = size; + } + }, + child: contentBuilder(context), + ), + ), + ), + if (handleOpacity > 0) + Opacity( + opacity: handleOpacity, + child: SizedBox( + height: kMinInteractiveDimension, + child: Center( + child: Container( + width: handleSize.width, + height: handleSize.height, + decoration: BoxDecoration( + color: handleColor, + borderRadius: BorderRadius.circular( + handleSize.height / 2, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// Standalone ticker source: the flight outlives any single route's State, +/// and tying the controller to the navigator's ticker would assert on app +/// teardown if a flight were mid-air. +class _FlightTickerProvider implements TickerProvider { + const _FlightTickerProvider(); + + @override + Ticker createTicker(TickerCallback onTick) => + Ticker(onTick, debugLabel: 'ModalMorphFlight'); +} + +/// Reports the child's laid-out size, deferred to post-frame (notifying +/// listeners during layout is illegal) — and, when [sampleNeeded], first +/// lays the child out LOOSE in the same layout pass to record its natural +/// width. The loose pass never paints, so sampling causes no visible +/// wrong-width frame. +class _MeasureSize extends SingleChildRenderObjectWidget { + const _MeasureSize({ + required this.sampleNeeded, + required this.onSample, + required this.onSize, + super.child, + }); + + final bool sampleNeeded; + final void Function(Size natural, double containerWidth) onSample; + final ValueChanged onSize; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderMeasureSize(sampleNeeded, onSample, onSize); + + @override + void updateRenderObject( + BuildContext context, + _RenderMeasureSize renderObject, + ) { + renderObject + ..sampleNeeded = sampleNeeded + ..onSample = onSample + ..onSize = onSize; + } +} + +class _RenderMeasureSize extends RenderProxyBox { + _RenderMeasureSize(this.sampleNeeded, this.onSample, this.onSize); + + bool sampleNeeded; + void Function(Size natural, double containerWidth) onSample; + ValueChanged onSize; + Size? _lastReported; + + @override + void performLayout() { + final child = this.child; + if (child != null && sampleNeeded && constraints.maxWidth.isFinite) { + child.layout(constraints.loosen(), parentUsesSize: true); + onSample(child.size, constraints.maxWidth); + sampleNeeded = false; + } + super.performLayout(); + if (size == _lastReported) return; + _lastReported = size; + final reported = size; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (attached) onSize(reported); + }); + } +} diff --git a/lib/src/core/shared/divider_builder.dart b/lib/src/core/shared/divider_builder.dart index a9d01d0..16b38b8 100644 --- a/lib/src/core/shared/divider_builder.dart +++ b/lib/src/core/shared/divider_builder.dart @@ -1,12 +1,72 @@ import 'package:flutter/widgets.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; + +/// The divider's full interaction state, handed to a [DividerBuilder] +/// every build. +/// +/// A single state object instead of positional flags so the contract can +/// grow without breaking every builder — the same discipline as +/// `PaneConfig`. +@immutable +class DividerState { + /// Creates a divider state snapshot. + const DividerState({ + this.isDragging = false, + this.isSettling = false, + this.atMinimum = false, + this.atMaximum = false, + this.collapsed, + this.isFocused = false, + }); + + /// True while the user is actively dragging. + final bool isDragging; + + /// True while the divider animates to an anchor point or a restore. + final bool isSettling; + + /// True when the start pane is pinned at its minimum width — the + /// divider cannot move further without collapsing (if allowed). + final bool atMinimum; + + /// True when the start pane is pinned at its maximum width. + final bool atMaximum; + + /// The collapsed pane, or null when both panes are visible. When set, + /// the divider is parked at that pane's edge — still draggable, and a + /// builder should render a restore affordance (a pull tab). + final PaneSide? collapsed; + + /// True when the divider has keyboard focus (arrow keys resize it). + final bool isFocused; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DividerState && + other.isDragging == isDragging && + other.isSettling == isSettling && + other.atMinimum == atMinimum && + other.atMaximum == atMaximum && + other.collapsed == collapsed && + other.isFocused == isFocused; + + @override + int get hashCode => Object.hash( + isDragging, + isSettling, + atMinimum, + atMaximum, + collapsed, + isFocused, + ); +} + /// Builder for a pane divider in expanded layout. /// -/// Shared across `ListDetailLayout`, `AdaptiveSplit`, and any future +/// Shared across `ListDetailLayout`, `SplitLayout`, and any future /// multi-pane layout widget. Use a shipped component /// (`MaterialDivider.builder`, `HandleDivider.builder`) or build your own. -/// -/// [isDragging] is true while the user is actively dragging. -/// [isSettling] is true while the divider animates to an anchor point. typedef DividerBuilder = - Widget Function(BuildContext context, bool isDragging, bool isSettling); + Widget Function(BuildContext context, DividerState state); diff --git a/lib/src/core/shared/expanded_entry_style.dart b/lib/src/core/shared/expanded_entry_style.dart new file mode 100644 index 0000000..e8556ea --- /dev/null +++ b/lib/src/core/shared/expanded_entry_style.dart @@ -0,0 +1,16 @@ +/// How the list pane arrives when a breakpoint crossing enters the +/// expanded layout with an open detail. +/// +/// The yielding detail resizes live in both styles: its first frame is +/// exactly the full-width layout compact just showed, so there is no +/// jump — the styles differ only in how the ARRIVING list is laid out. +enum ExpandedEntryStyle { + /// The list is laid out at its final width and slides in clipped — + /// its content never reflows during the entry. The way desktop + /// sidebars arrive (macOS Finder, Mail). + reveal, + + /// The list is laid out at the animated width each frame and reflows + /// as it grows into its pane. + resize, +} diff --git a/lib/src/core/shared/pane_anchor.dart b/lib/src/core/shared/pane_anchor.dart index 2a35090..673af48 100644 --- a/lib/src/core/shared/pane_anchor.dart +++ b/lib/src/core/shared/pane_anchor.dart @@ -67,13 +67,17 @@ class PaneAnchor { PaneAnchor.proportion(1.0), ]; + // NaN is the "unused" sentinel and NaN == NaN is false — plain field + // comparison would make every offset anchor unequal to itself. + static bool _sameField(double a, double b) => (a.isNaN && b.isNaN) || a == b; + @override bool operator ==(Object other) => identical(this, other) || other is PaneAnchor && runtimeType == other.runtimeType && - proportion == other.proportion && - startOffset == other.startOffset; + _sameField(proportion, other.proportion) && + _sameField(startOffset, other.startOffset); @override int get hashCode => Object.hash(proportion, startOffset); diff --git a/lib/src/core/shared/pane_collapse.dart b/lib/src/core/shared/pane_collapse.dart new file mode 100644 index 0000000..095d12b --- /dev/null +++ b/lib/src/core/shared/pane_collapse.dart @@ -0,0 +1,45 @@ +/// A side of a pane divider, in directional terms (start = left in LTR). +/// +/// In `ListDetailLayout` the start pane is the list and the end pane is +/// the detail. In `SplitLayout` the start pane is whichever pane sits +/// at the start per `SplitPrimaryPosition`. +enum PaneSide { + /// The pane on the directional start side of the divider. + start, + + /// The pane on the directional end side of the divider. + end, +} + +/// Which panes may snap-collapse when the divider is forced past their +/// size limit. +/// +/// The mechanics follow desktop split views: dragging past a pane's +/// limit by half its minimum size snaps it to `PaneConfig.collapsedSize` +/// with the pre-collapse width remembered; releasing short of that +/// springs back to the limit. The parked divider stays draggable — pull +/// it back out to restore. +enum PaneCollapsible { + /// Neither pane collapses; the limits are hard stops (default). + none, + + /// Only the start pane (the list) collapses. + start, + + /// Only the end pane (the detail) collapses. + end, + + /// Both panes collapse. + both, +} + +/// Convenience checks for [PaneCollapsible]. +extension PaneCollapsibleSides on PaneCollapsible { + /// Whether [side] is allowed to collapse. + bool allows(PaneSide side) => switch (this) { + PaneCollapsible.none => false, + PaneCollapsible.start => side == PaneSide.start, + PaneCollapsible.end => side == PaneSide.end, + PaneCollapsible.both => true, + }; +} diff --git a/lib/src/core/shared/pane_config.dart b/lib/src/core/shared/pane_config.dart index 75a0690..8db56e2 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -1,5 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:adaptive_layouts/src/core/shared/expanded_entry_style.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; import 'package:adaptive_layouts/src/core/shared/pane_anchor.dart'; import 'package:adaptive_layouts/src/core/shared/pane_resize_mode.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_width_memory.dart'; /// Expanded-layout pane configuration for `ListDetailLayout`. /// @@ -23,6 +29,14 @@ class PaneConfig { this.anchors = const [], this.initialAnchorIndex = 1, this.resizeMode = PaneResizeMode.ratio, + this.entryStyle = ExpandedEntryStyle.reveal, + this.widthMemory = PaneWidthMemory.persist, + this.settleDuration = const Duration(milliseconds: 220), + this.settleCurve = Curves.easeOutCubic, + this.dividerHitWidth = 24, + this.collapsible = PaneCollapsible.none, + this.collapsedSize = 0, + this.dividerSemanticsLabel = 'Pane divider', }); /// Default width of the list pane in logical pixels. @@ -48,9 +62,78 @@ class PaneConfig { /// How pane width is stored after dragging. final PaneResizeMode resizeMode; + /// How the list pane arrives when a breakpoint crossing enters the + /// expanded layout with an open detail. + final ExpandedEntryStyle entryStyle; + + /// Whether a dragged divider position survives compact spells. + final PaneWidthMemory widthMemory; + + /// Duration of the snap-to-anchor settle after a divider drag. + final Duration settleDuration; + + /// Curve of the snap-to-anchor settle after a divider drag. + final Curve settleCurve; + + /// Width of the divider's drag hit zone, centered on the pane border. + final double dividerHitWidth; + + /// Which panes snap-collapse when the divider is forced past their + /// limit by half the pane's minimum size. Collapsed panes park at + /// [collapsedSize], remember their width, and restore when the parked + /// divider is dragged back out. + final PaneCollapsible collapsible; + + /// Width of a collapsed pane. Zero hides it entirely; a small value + /// (e.g. 48) keeps an icon-rail sliver visible. + final double collapsedSize; + + /// Screen-reader label for the divider. Localize by passing your own. + final String dividerSemanticsLabel; + /// Sensible defaults for a standard list-detail layout. static const standard = PaneConfig(); /// Standard defaults with list-detail anchor points. static const withAnchors = PaneConfig(anchors: PaneAnchor.listDetail); + + // Value equality: layouts compare configs to decide whether to REBUILD + // their width model. Identity comparison resets the user's dragged + // divider on every rebuild for apps that construct the config inline. + @override + bool operator ==(Object other) => + identical(this, other) || + other is PaneConfig && + other.defaultListWidth == defaultListWidth && + other.minListWidth == minListWidth && + other.maxListRatio == maxListRatio && + listEquals(other.anchors, anchors) && + other.initialAnchorIndex == initialAnchorIndex && + other.resizeMode == resizeMode && + other.entryStyle == entryStyle && + other.widthMemory == widthMemory && + other.settleDuration == settleDuration && + other.settleCurve == settleCurve && + other.dividerHitWidth == dividerHitWidth && + other.collapsible == collapsible && + other.collapsedSize == collapsedSize && + other.dividerSemanticsLabel == dividerSemanticsLabel; + + @override + int get hashCode => Object.hash( + defaultListWidth, + minListWidth, + maxListRatio, + Object.hashAll(anchors), + initialAnchorIndex, + resizeMode, + entryStyle, + widthMemory, + settleDuration, + settleCurve, + dividerHitWidth, + collapsible, + collapsedSize, + dividerSemanticsLabel, + ); } diff --git a/lib/src/core/shared/pane_divider_region.dart b/lib/src/core/shared/pane_divider_region.dart new file mode 100644 index 0000000..3f2338c --- /dev/null +++ b/lib/src/core/shared/pane_divider_region.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:adaptive_layouts/src/core/shared/divider_builder.dart'; + +/// The divider's interactive hit region, shared by `ListDetailLayout` and +/// `SplitLayout`: drag gestures, double-click reset, keyboard resizing, +/// and screen-reader semantics — the WAI-ARIA window-splitter pattern +/// mapped to Flutter. +/// +/// - Arrow keys resize by 24 logical pixels per press. +/// - Enter toggles collapse/restore (when the config allows collapse). +/// - Home / End jump to the minimum / maximum. +/// - Double click resets: restore when collapsed, default width otherwise. +/// - Screen readers see an adjustable element (increase/decrease). +class PaneDividerRegion extends StatefulWidget { + /// Creates the divider hit region. + const PaneDividerRegion({ + super.key, + required this.hitWidth, + required this.stateFor, + required this.dividerBuilder, + required this.onDragStart, + required this.onDragDelta, + required this.onDragEnd, + required this.onStep, + required this.onToggleCollapse, + required this.onJumpToMinimum, + required this.onJumpToMaximum, + required this.onReset, + required this.semanticsLabel, + required this.semanticsValue, + required this.semanticsIncreasedValue, + required this.semanticsDecreasedValue, + }); + + /// Physical pixels one keyboard arrow press (or screen-reader + /// increase/decrease) resizes by. + static const double keyboardStep = 24; + + /// Width of the hit zone. + final double hitWidth; + + /// Builds the [DividerState] for the current frame, given keyboard + /// focus (owned here). + final DividerState Function(bool isFocused) stateFor; + + /// The consumer's divider visual; null renders an invisible zone. + final DividerBuilder? dividerBuilder; + + /// Drag lifecycle, physical deltas (callee corrects for RTL). + final VoidCallback onDragStart; + + /// Applies a physical horizontal drag delta. + final ValueChanged onDragDelta; + + /// Ends the drag gesture. + final VoidCallback onDragEnd; + + /// Keyboard resize by a physical delta (arrow keys, screen readers). + final ValueChanged onStep; + + /// Enter: collapse the pane, or restore it when collapsed. + final VoidCallback onToggleCollapse; + + /// Home: pane to its minimum. + final VoidCallback onJumpToMinimum; + + /// End: pane to its maximum. + final VoidCallback onJumpToMaximum; + + /// Double click: restore when collapsed, else reset to the default. + final VoidCallback onReset; + + /// Screen-reader label for the divider. + final String semanticsLabel; + + /// Screen-reader value (the pane's share, e.g. "36%"). + final String semanticsValue; + + /// The value after one increase step (required by the semantics + /// contract whenever increase/decrease actions exist). + final String semanticsIncreasedValue; + + /// The value after one decrease step. + final String semanticsDecreasedValue; + + @override + State createState() => _PaneDividerRegionState(); +} + +class _PaneDividerRegionState extends State { + bool _focused = false; + + static const double _keyboardStep = PaneDividerRegion.keyboardStep; + + @override + Widget build(BuildContext context) { + final state = widget.stateFor(_focused); + + return FocusableActionDetector( + onFocusChange: (focused) => setState(() => _focused = focused), + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowLeft): _StepIntent( + -_keyboardStep, + ), + SingleActivator(LogicalKeyboardKey.arrowRight): _StepIntent( + _keyboardStep, + ), + SingleActivator(LogicalKeyboardKey.enter): _ToggleCollapseIntent(), + SingleActivator(LogicalKeyboardKey.home): _JumpIntent(toMinimum: true), + SingleActivator(LogicalKeyboardKey.end): _JumpIntent(toMinimum: false), + }, + actions: { + _StepIntent: CallbackAction<_StepIntent>( + onInvoke: (intent) { + widget.onStep(intent.delta); + return null; + }, + ), + _ToggleCollapseIntent: CallbackAction<_ToggleCollapseIntent>( + onInvoke: (_) { + widget.onToggleCollapse(); + return null; + }, + ), + _JumpIntent: CallbackAction<_JumpIntent>( + onInvoke: (intent) { + intent.toMinimum + ? widget.onJumpToMinimum() + : widget.onJumpToMaximum(); + return null; + }, + ), + }, + child: Semantics( + container: true, + label: widget.semanticsLabel, + value: widget.semanticsValue, + increasedValue: widget.semanticsIncreasedValue, + decreasedValue: widget.semanticsDecreasedValue, + onIncrease: () => widget.onStep(_keyboardStep), + onDecrease: () => widget.onStep(-_keyboardStep), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => widget.onDragStart(), + onHorizontalDragUpdate: (d) => + widget.onDragDelta(d.primaryDelta ?? 0), + onHorizontalDragEnd: (_) => widget.onDragEnd(), + onDoubleTap: widget.onReset, + child: SizedBox( + width: widget.hitWidth, + child: widget.dividerBuilder?.call(context, state), + ), + ), + ), + ); + } +} + +class _StepIntent extends Intent { + const _StepIntent(this.delta); + final double delta; +} + +class _ToggleCollapseIntent extends Intent { + const _ToggleCollapseIntent(); +} + +class _JumpIntent extends Intent { + const _JumpIntent({required this.toMinimum}); + final bool toMinimum; +} diff --git a/lib/src/core/shared/pane_scope.dart b/lib/src/core/shared/pane_scope.dart new file mode 100644 index 0000000..dd5cd25 --- /dev/null +++ b/lib/src/core/shared/pane_scope.dart @@ -0,0 +1,78 @@ +import 'package:flutter/widgets.dart'; + +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; + +/// Pane-system state and actions, readable by any descendant of a pane — +/// list content, detail content, custom dividers. +/// +/// The delivery mechanism for pane signals: instead of growing every +/// builder signature per signal, descendants read the scope. The +/// canonical use is a restore affordance inside the surviving pane: +/// +/// ```dart +/// final scope = PaneScope.maybeOf(context); +/// if (scope?.collapsed == PaneSide.start && scope!.collapsedSize == 0) +/// IconButton( +/// icon: const Icon(Icons.view_sidebar_outlined), +/// onPressed: scope.restore, +/// ) +/// ``` +@immutable +class PaneScopeData { + /// Creates a scope snapshot with its actions. + const PaneScopeData({ + required this.collapsed, + required this.isExpanded, + required this.collapsedSize, + required this.collapse, + required this.restore, + }); + + /// The collapsed pane, or null when both are visible. Always null in + /// compact layout — collapse is expanded-only view state. + final PaneSide? collapsed; + + /// Whether the layout is currently in its expanded (side-by-side) form. + final bool isExpanded; + + /// The width a collapsed pane keeps on screen + /// (`PaneConfig.collapsedSize`). Zero means fully hidden — the + /// surviving pane should offer its own restore affordance (the + /// hamburger recipe). Non-zero means a rail is visible and already + /// carries the expand control — don't duplicate it. + final double collapsedSize; + + /// Collapses [PaneSide] programmatically. No-op when the config + /// disallows it or the layout is compact. + final void Function(PaneSide side) collapse; + + /// Restores a collapsed pane to its remembered width. No-op when + /// nothing is collapsed. + final VoidCallback restore; +} + +/// Hosts a [PaneScopeData] for a pane-based layout's subtree. +class PaneScope extends InheritedWidget { + /// Creates the scope host. + const PaneScope({super.key, required this.data, required super.child}); + + /// The current pane state + actions. + final PaneScopeData data; + + /// The nearest scope, or null outside a pane-based layout. + static PaneScopeData? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.data; + + /// The nearest scope. Throws outside a pane-based layout. + static PaneScopeData of(BuildContext context) { + final data = maybeOf(context); + assert(data != null, 'PaneScope.of called outside a pane-based layout'); + return data!; + } + + @override + bool updateShouldNotify(PaneScope oldWidget) => + data.collapsed != oldWidget.data.collapsed || + data.isExpanded != oldWidget.data.isExpanded || + data.collapsedSize != oldWidget.data.collapsedSize; +} diff --git a/lib/src/core/shared/pane_width_memory.dart b/lib/src/core/shared/pane_width_memory.dart new file mode 100644 index 0000000..8bfe58f --- /dev/null +++ b/lib/src/core/shared/pane_width_memory.dart @@ -0,0 +1,12 @@ +/// What happens to a user-dragged divider position when the expanded +/// layout goes away and comes back (a compact spell, a tab trip). +enum PaneWidthMemory { + /// The dragged position is remembered — proportionally in + /// `PaneResizeMode.ratio`, absolutely in `PaneResizeMode.pixels`. + persist, + + /// Every return to the expanded layout starts fresh from + /// `PaneConfig.defaultListWidth` (or the initial anchor). Drags apply + /// only within one expanded spell. + resetOnReentry, +} diff --git a/lib/src/core/shared/pane_width_model.dart b/lib/src/core/shared/pane_width_model.dart index 74a5b54..4b654c0 100644 --- a/lib/src/core/shared/pane_width_model.dart +++ b/lib/src/core/shared/pane_width_model.dart @@ -1,9 +1,10 @@ +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; import 'package:adaptive_layouts/src/core/shared/pane_config.dart'; import 'package:adaptive_layouts/src/core/shared/pane_resize_mode.dart'; /// Pure width/drag/snap logic for a draggable pane divider. /// -/// Shared by `ListDetailLayout` and `AdaptiveSplit` so both widgets resize, +/// Shared by `ListDetailLayout` and `SplitLayout` so both widgets resize, /// clamp, and anchor-snap identically. Owns no animation — the widget drives /// the settle animation and feeds interpolated widths back via [setWidth]. /// @@ -17,11 +18,21 @@ import 'package:adaptive_layouts/src/core/shared/pane_resize_mode.dart'; /// an anchor outside the clamp range settles at the clamp boundary. class PaneWidthModel { /// Creates a width model for [config]. - PaneWidthModel(this.config, {required this.referenceWidth}); + PaneWidthModel( + this.config, { + required this.referenceWidth, + PaneCollapsible? collapsible, + }) : collapsible = collapsible ?? config.collapsible; /// The pane configuration this model applies. final PaneConfig config; + /// Which sides may collapse, in MODEL space (start = the pane this + /// model's width measures). Layouts whose measured pane isn't on the + /// directional start (`SplitLayout` with an end-positioned primary) + /// pass a flipped value so [PaneConfig.collapsible] stays directional. + final PaneCollapsible collapsible; + /// The width [PaneConfig.defaultListWidth] is converted against in ratio /// mode — the layout's expanded breakpoint (the minimum expanded width). /// On wider windows the pane scales up proportionally from there. @@ -74,24 +85,151 @@ class PaneWidthModel { return widthPx.clamp(config.minListWidth, max); } + // --------------------------------------------------------------------------- + // Collapse (desktop split-view semantics) + // + // A collapsible pane snaps to `config.collapsedSize` when the divider is + // forced past its limit by half the pane's minimum size, live during the + // drag and reversible mid-drag. The pre-collapse width is cached so a + // restore returns exactly where the user was. The parked divider stays + // draggable. + // --------------------------------------------------------------------------- + + /// The collapsed pane, or null when both panes are visible. + PaneSide? get collapsed => _collapsed; + PaneSide? _collapsed; + + /// Pixel width to restore to when un-collapsing. + double? _cachedWidth; + + /// The un-clamped drag position of the current gesture, in start-pane + /// pixels. Null when no drag is active. + double? _rawDragWidth; + + /// The maximum start-pane width for [availableWidth]. + double _maxWidth(double availableWidth) { + final max = availableWidth * config.maxListRatio; + return max <= config.minListWidth ? config.minListWidth : max; + } + + /// Overshoot needed past a limit before the pane snaps collapsed — + /// half the pane's minimum size (the desktop split-view threshold). + double _collapseThreshold(PaneSide side, double availableWidth) { + final paneMin = side == PaneSide.start + ? config.minListWidth + : availableWidth - _maxWidth(availableWidth); + return paneMin / 2; + } + /// The pane's current width in pixels, clamped for [availableWidth]. + /// + /// When a pane is collapsed the width is parked: `collapsedSize` for a + /// collapsed start pane, `availableWidth - collapsedSize` for a + /// collapsed end pane. The clamps do not apply to parked widths. double width(double availableWidth) { - _ensureInitialized(availableWidth); - final raw = _isRatioMode ? availableWidth * _ratio! : _pixels!; - return _clamp(raw, availableWidth); + switch (_collapsed) { + case PaneSide.start: + return config.collapsedSize; + case PaneSide.end: + return availableWidth - config.collapsedSize; + case null: + _ensureInitialized(availableWidth); + final raw = _isRatioMode ? availableWidth * _ratio! : _pixels!; + return _clamp(raw, availableWidth); + } + } + + /// Starts a drag gesture: caches the width a restore should return to + /// and seeds the un-clamped drag position. + void dragStart(double availableWidth) { + _rawDragWidth = width(availableWidth); + if (_collapsed == null) _cachedWidth = _rawDragWidth; } /// Applies a horizontal drag delta (already direction-corrected for RTL). + /// + /// Tracks the un-clamped position so a collapsible pane can snap + /// collapsed when forced past its limit — and snap back when the drag + /// returns, exactly like desktop split views. void drag(double delta, double availableWidth) { _ensureInitialized(availableWidth); + // Overshoot accumulates only within a started gesture ([dragStart]). + // A bare drag() call is its own micro-gesture measured from the + // current width — repeated calls never carry clamp overshoot over. + final gestureActive = _rawDragWidth != null; + final raw = (_rawDragWidth ?? width(availableWidth)) + delta; + if (gestureActive) _rawDragWidth = raw; + final min = config.minListWidth; + final max = _maxWidth(availableWidth); + + if (collapsible.allows(PaneSide.start) && + raw < min - _collapseThreshold(PaneSide.start, availableWidth)) { + _collapsed = PaneSide.start; + return; + } + if (collapsible.allows(PaneSide.end) && + raw > max + _collapseThreshold(PaneSide.end, availableWidth)) { + _collapsed = PaneSide.end; + return; + } + _collapsed = null; + if (_isRatioMode) { - _ratio = (_ratio! + delta / availableWidth).clamp( - config.minListWidth / availableWidth, + _ratio = (raw / availableWidth).clamp( + min / availableWidth, config.maxListRatio, ); } else { - _pixels = _clamp(_pixels! + delta, availableWidth); + _pixels = _clamp(raw, availableWidth); + } + } + + /// Ends a drag gesture. + void dragEnd() { + _rawDragWidth = null; + } + + /// Collapses [side] programmatically, caching the current width for + /// [restoreTarget]. No-op when [collapsible] disallows it. + void collapse(PaneSide side, double availableWidth) { + if (!collapsible.allows(side) || _collapsed == side) return; + _cachedWidth = width(availableWidth); + _collapsed = side; + } + + /// Un-collapses without animating. The widget usually animates to + /// [restoreTarget] instead and calls this when the settle lands. + void restore(double availableWidth) { + if (_collapsed == null) return; + _collapsed = null; + setWidth( + _clamp(_cachedWidth ?? config.defaultListWidth, availableWidth), + availableWidth, + ); + } + + /// The width a restore animation should settle to. + double restoreTarget(double availableWidth) => + _clamp(_cachedWidth ?? config.defaultListWidth, availableWidth); + + /// The configured default width (the divider's double-click reset + /// target): the initial anchor when anchors are configured, else + /// [PaneConfig.defaultListWidth] under the resize-mode conversion. + double defaultWidth(double availableWidth) { + if (config.anchors.isNotEmpty) { + final index = config.initialAnchorIndex.clamp( + 0, + config.anchors.length - 1, + ); + return _clamp( + config.anchors[index].resolve(availableWidth), + availableWidth, + ); } + final raw = _isRatioMode + ? availableWidth * (config.defaultListWidth / referenceWidth) + : config.defaultListWidth; + return _clamp(raw, availableWidth); } /// Sets the width to an exact pixel value (used by the settle animation). diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart deleted file mode 100644 index c68062a..0000000 --- a/lib/src/core/split/adaptive_split.dart +++ /dev/null @@ -1,346 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:adaptive_layouts/src/core/shared/adaptive_layout_config.dart'; -import 'package:adaptive_layouts/src/core/shared/divider_builder.dart'; -import 'package:adaptive_layouts/src/core/shared/pane_config.dart'; -import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; - -// ============================================================================= -// BUILDER TYPEDEFS -// ============================================================================= - -/// Builder for a pane in an [AdaptiveSplit]. -/// -/// [isExpanded] is true when in side-by-side expanded layout, -/// false when in compact (stacked) layout. -typedef SplitPaneBuilder = - Widget Function(BuildContext context, bool isExpanded); - -// ============================================================================= -// ENUMS -// ============================================================================= - -/// Where the primary pane appears in expanded layout. -enum SplitPrimaryPosition { - /// Primary on the start side (left in LTR, right in RTL). - start, - - /// Primary on the end side (right in LTR, left in RTL). - end, -} - -/// How the secondary pane behaves in compact layout. -enum SplitCompactBehavior { - /// Both panes stacked vertically (primary on top). - stack, - - /// Secondary is hidden in compact layout. - hidden, -} - -// ============================================================================= -// WIDGET -// ============================================================================= - -/// Adaptive two-pane split layout that morphs between compact and expanded. -/// -/// - **Compact** (below [expandedBreakpoint]): vertical stack or hidden secondary -/// - **Expanded** (at or above [expandedBreakpoint]): side-by-side with -/// draggable divider -/// -/// ## State Preservation -/// -/// Both panes use internal [GlobalKey]s via [KeyedSubtree], so widget state -/// is automatically preserved across compact ↔ expanded transitions. -/// Same pattern as `ListDetailLayout`. -/// -/// ## Usage -/// -/// ```dart -/// AdaptiveSplit( -/// primaryBuilder: (context, isExpanded) => AlbumArt(), -/// secondaryBuilder: (context, isExpanded) => QueueOrVisualizer(), -/// dividerBuilder: HandleDivider.builder, -/// paneConfig: PaneConfig( -/// defaultListWidth: 400, -/// maxListRatio: 0.55, -/// ), -/// ) -/// ``` -class AdaptiveSplit extends StatefulWidget { - /// Creates an adaptive two-pane split layout. - const AdaptiveSplit({ - super.key, - required this.primaryBuilder, - required this.secondaryBuilder, - this.primaryPosition = SplitPrimaryPosition.start, - this.compactBehavior = SplitCompactBehavior.stack, - this.expandedBreakpoint, - this.paneConfig = const PaneConfig(), - this.dividerBuilder, - this.compactSpacing = 0, - }); - - /// Builder for the primary content pane. - final SplitPaneBuilder primaryBuilder; - - /// Builder for the secondary content pane. - final SplitPaneBuilder secondaryBuilder; - - /// Where the primary pane appears in expanded layout. - /// In compact layout, primary is always on top. - final SplitPrimaryPosition primaryPosition; - - /// How the secondary pane behaves in compact layout. - final SplitCompactBehavior compactBehavior; - - /// Width breakpoint for compact ↔ expanded switch. - /// If null, reads from [AdaptiveLayoutConfig] ancestor, then falls back to 720. - final double? expandedBreakpoint; - - /// Expanded-layout pane configuration (widths, anchors, resize behavior). - /// [PaneConfig.defaultListWidth] and friends size the PRIMARY pane here. - final PaneConfig paneConfig; - - /// Builder for the expanded-layout pane divider. - /// Null by default (invisible drag zone). Use a shipped component - /// (`HandleDivider.builder`, `MaterialDivider.builder`) or your own — - /// same [DividerBuilder] contract as `ListDetailLayout`. - final DividerBuilder? dividerBuilder; - - /// Spacing between panes in compact (stacked) layout. - final double compactSpacing; - - @override - State createState() => _AdaptiveSplitState(); -} - -// ============================================================================= -// STATE -// ============================================================================= - -class _AdaptiveSplitState extends State - with SingleTickerProviderStateMixin { - // --------------------------------------------------------------------------- - // Divider drag state — width/clamp/snap logic lives in the shared model; - // this state owns only the gesture flags and the settle animation. - // --------------------------------------------------------------------------- - - late PaneWidthModel _paneWidth; - bool _isDividerDragging = false; - late AnimationController _settleController; - bool get _isDividerSettling => _settleController.isAnimating; - - /// Width of the expanded layout, captured every build — divider gestures - /// can only fire after it has been set. - double _lastExpandedWidth = 0; - - // --------------------------------------------------------------------------- - // State preservation across compact ↔ expanded transitions. - // GlobalKey causes Flutter to reparent (move) the widget instead of - // destroying and recreating it when the layout mode changes. - // Same technique as ListDetailLayout._detailKey. - // --------------------------------------------------------------------------- - - final GlobalKey _primaryKey = GlobalKey(); - final GlobalKey _secondaryKey = GlobalKey(); - - // =========================================================================== - // LIFECYCLE - // =========================================================================== - - @override - void initState() { - super.initState(); - _paneWidth = PaneWidthModel( - widget.paneConfig, - referenceWidth: _referenceWidth, - ); - _settleController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 220), - ); - } - - @override - void didUpdateWidget(AdaptiveSplit oldWidget) { - super.didUpdateWidget(oldWidget); - if (!identical(widget.paneConfig, oldWidget.paneConfig)) { - _settleController.stop(); - _paneWidth = PaneWidthModel( - widget.paneConfig, - referenceWidth: _referenceWidth, - ); - } - } - - /// Reference width for ratio conversion — the expanded breakpoint, since - /// that is the minimum width the expanded layout can have. - double get _referenceWidth => - widget.expandedBreakpoint ?? - AdaptiveLayoutConfig.defaultExpandedBreakpoint; - - @override - void dispose() { - _settleController.dispose(); - super.dispose(); - } - - // =========================================================================== - // DIVIDER DRAG - // =========================================================================== - - void _handleDividerDragStart() { - _settleController.stop(); - setState(() => _isDividerDragging = true); - } - - void _handleDividerDragEnd() { - setState(() => _isDividerDragging = false); - _settleToNearestAnchor(); - } - - void _handleDividerDragUpdate(double delta) { - final isRtl = Directionality.of(context) == TextDirection.rtl; - final effectiveDelta = isRtl ? -delta : delta; - // A start-positioned primary grows when dragging towards the end; - // an end-positioned primary grows the other way. - final directed = widget.primaryPosition == SplitPrimaryPosition.start - ? effectiveDelta - : -effectiveDelta; - setState(() => _paneWidth.drag(directed, _lastExpandedWidth)); - } - - /// Animates the divider to the nearest anchor. No-op without anchors. - void _settleToNearestAnchor() { - final availableWidth = _lastExpandedWidth; - final target = _paneWidth.snapTarget(availableWidth); - if (target == null) return; - - final begin = _paneWidth.width(availableWidth); - final curve = CurvedAnimation( - parent: _settleController, - curve: Curves.easeOutCubic, - ); - void tick() { - _paneWidth.setWidth( - begin + (target - begin) * curve.value, - availableWidth, - ); - if (mounted) setState(() {}); - } - - _settleController - ..reset() - ..addListener(tick); - _settleController.forward().whenCompleteOrCancel(() { - _settleController.removeListener(tick); - if (mounted) setState(() {}); - }); - } - - // =========================================================================== - // BUILD - // =========================================================================== - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final breakpoint = AdaptiveLayoutConfig.resolveBreakpoint( - context, - widget.expandedBreakpoint, - ); - final isExpanded = constraints.maxWidth >= breakpoint; - - return isExpanded - ? _buildExpandedLayout(constraints) - : _buildCompactLayout(); - }, - ); - } - - /// Expanded: side-by-side panes with draggable divider. - Widget _buildExpandedLayout(BoxConstraints constraints) { - final availableWidth = constraints.maxWidth; - _lastExpandedWidth = availableWidth; - final primaryWidth = _paneWidth.width(availableWidth); - - final primaryPane = SizedBox( - width: primaryWidth, - child: KeyedSubtree( - key: _primaryKey, - child: widget.primaryBuilder(context, true), - ), - ); - - final secondaryPane = Expanded( - child: KeyedSubtree( - key: _secondaryKey, - child: widget.secondaryBuilder(context, true), - ), - ); - - final isStart = widget.primaryPosition == SplitPrimaryPosition.start; - final first = isStart ? primaryPane : secondaryPane; - final second = isStart ? secondaryPane : primaryPane; - final dividerBuilder = widget.dividerBuilder; - - return Stack( - children: [ - Row(children: [first, second]), - // Divider — visual (if builder provided) or invisible drag zone. - PositionedDirectional( - start: isStart - ? primaryWidth - - 12 // 24px hit area centered on the border - : availableWidth - primaryWidth - 12, - top: 0, - bottom: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => _handleDividerDragStart(), - onHorizontalDragUpdate: (d) => - _handleDividerDragUpdate(d.primaryDelta ?? 0), - onHorizontalDragEnd: (_) => _handleDividerDragEnd(), - child: SizedBox( - width: 24, - child: dividerBuilder != null - ? dividerBuilder( - context, - _isDividerDragging, - _isDividerSettling, - ) - : null, - ), - ), - ), - ], - ); - } - - /// Compact: vertical stack or hidden secondary. - Widget _buildCompactLayout() { - final primary = KeyedSubtree( - key: _primaryKey, - child: widget.primaryBuilder(context, false), - ); - - if (widget.compactBehavior == SplitCompactBehavior.hidden) { - return primary; - } - - return Column( - children: [ - Expanded(child: primary), - if (widget.compactSpacing > 0) SizedBox(height: widget.compactSpacing), - Expanded( - child: KeyedSubtree( - key: _secondaryKey, - child: widget.secondaryBuilder(context, false), - ), - ), - ], - ); - } -} diff --git a/lib/src/core/split/split_layout.dart b/lib/src/core/split/split_layout.dart new file mode 100644 index 0000000..2e1f480 --- /dev/null +++ b/lib/src/core/split/split_layout.dart @@ -0,0 +1,637 @@ +import 'package:flutter/material.dart'; + +import 'package:adaptive_layouts/src/core/shared/adaptive_layout_config.dart'; +import 'package:adaptive_layouts/src/core/shared/divider_builder.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_config.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_divider_region.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_scope.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_width_memory.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; + +// ============================================================================= +// BUILDER TYPEDEFS +// ============================================================================= + +/// Builder for a pane in an [SplitLayout]. +/// +/// [isExpanded] is true when in side-by-side expanded layout, +/// false when in compact (stacked) layout. +typedef SplitPaneBuilder = + Widget Function(BuildContext context, bool isExpanded); + +// ============================================================================= +// ENUMS +// ============================================================================= + +/// Where the primary pane appears in expanded layout. +enum SplitPrimaryPosition { + /// Primary on the start side (left in LTR, right in RTL). + start, + + /// Primary on the end side (right in LTR, left in RTL). + end, +} + +/// How the secondary pane behaves in compact layout. +enum SplitCompactBehavior { + /// Both panes stacked vertically (primary on top). + stack, + + /// Secondary is hidden in compact layout. + hidden, +} + +// ============================================================================= +// WIDGET +// ============================================================================= + +/// Adaptive two-pane split layout that morphs between compact and expanded. +/// +/// - **Compact** (below [expandedBreakpoint]): vertical stack or hidden secondary +/// - **Expanded** (at or above [expandedBreakpoint]): side-by-side with +/// draggable divider +/// +/// ## State Preservation +/// +/// Both panes use internal [GlobalKey]s via [KeyedSubtree], so widget state +/// is automatically preserved across compact ↔ expanded transitions. +/// Same pattern as `ListDetailLayout`. +/// +/// ## Usage +/// +/// ```dart +/// SplitLayout( +/// primaryBuilder: (context, isExpanded) => AlbumArt(), +/// secondaryBuilder: (context, isExpanded) => QueueOrVisualizer(), +/// dividerBuilder: HandleDivider.builder, +/// paneConfig: PaneConfig( +/// defaultListWidth: 400, +/// maxListRatio: 0.55, +/// ), +/// ) +/// ``` +class SplitLayout extends StatefulWidget { + /// Creates an adaptive two-pane split layout. + const SplitLayout({ + super.key, + required this.primaryBuilder, + required this.secondaryBuilder, + this.primaryPosition = SplitPrimaryPosition.start, + this.compactBehavior = SplitCompactBehavior.stack, + this.expandedBreakpoint, + this.paneConfig = const PaneConfig(), + this.dividerBuilder, + this.compactSpacing = 0, + this.collapsedPrimaryBuilder, + this.collapsedSecondaryBuilder, + }); + + /// Builder for the primary content pane. + final SplitPaneBuilder primaryBuilder; + + /// Builder for the secondary content pane. + final SplitPaneBuilder secondaryBuilder; + + /// Where the primary pane appears in expanded layout. + /// In compact layout, primary is always on top. + final SplitPrimaryPosition primaryPosition; + + /// How the secondary pane behaves in compact layout. + final SplitCompactBehavior compactBehavior; + + /// Width breakpoint for compact ↔ expanded switch. + /// If null, reads from [AdaptiveLayoutConfig] ancestor, then falls back to 720. + final double? expandedBreakpoint; + + /// Expanded-layout pane configuration (widths, anchors, resize behavior). + /// [PaneConfig.defaultListWidth] and friends size the PRIMARY pane here. + final PaneConfig paneConfig; + + /// Builder for the expanded-layout pane divider. + /// Null by default (invisible drag zone). Use a shipped component + /// (`HandleDivider.builder`, `MaterialDivider.builder`) or your own — + /// same [DividerBuilder] contract as `ListDetailLayout`. + final DividerBuilder? dividerBuilder; + + /// Spacing between panes in compact (stacked) layout. + final double compactSpacing; + + /// What a collapsed primary pane shows, laid out at the REAL slot + /// width (`PaneConfig.collapsedSize`) — the icon-rail slot. While it + /// shows, the primary parks offstage with its state alive. Null keeps + /// the default: the pane clipped at its floor width. The builder's + /// context sits under [PaneScope] for the restore affordance. + final WidgetBuilder? collapsedPrimaryBuilder; + + /// Same slot for a collapsed secondary pane. + final WidgetBuilder? collapsedSecondaryBuilder; + + @override + State createState() => _SplitLayoutState(); +} + +// ============================================================================= +// STATE +// ============================================================================= + +class _SplitLayoutState extends State + with SingleTickerProviderStateMixin { + // --------------------------------------------------------------------------- + // Divider drag state — width/clamp/snap logic lives in the shared model; + // this state owns only the gesture flags and the settle animation. + // --------------------------------------------------------------------------- + + late PaneWidthModel _paneWidth; + bool _isDividerDragging = false; + late AnimationController _settleController; + bool get _isDividerSettling => _settleController.isAnimating; + + /// Width of the expanded layout, captured every build — divider gestures + /// can only fire after it has been set. + double _lastExpandedWidth = 0; + + // --------------------------------------------------------------------------- + // State preservation across compact ↔ expanded transitions. + // GlobalKey causes Flutter to reparent (move) the widget instead of + // destroying and recreating it when the layout mode changes. + // Same technique as ListDetailLayout._detailKey. + // --------------------------------------------------------------------------- + + final GlobalKey _primaryKey = GlobalKey(); + final GlobalKey _secondaryKey = GlobalKey(); + + // =========================================================================== + // LIFECYCLE + // =========================================================================== + + @override + void initState() { + super.initState(); + _paneWidth = PaneWidthModel( + widget.paneConfig, + referenceWidth: _referenceWidth, + collapsible: _modelCollapsible, + ); + _settleController = AnimationController( + vsync: this, + duration: widget.paneConfig.settleDuration, + ); + } + + /// Crossing bookkeeping for [PaneWidthMemory.resetOnReentry]. A first + /// build is never a crossing. + bool _wasExpandedLastBuild = false; + bool _hasBuiltOnce = false; + + /// Whether the last build laid out side-by-side. Gates collapse actions + /// (collapse is expanded-only view state). + bool _isExpanded = false; + + @override + void didUpdateWidget(SplitLayout oldWidget) { + super.didUpdateWidget(oldWidget); + // Value comparison, not identity: apps construct configs inline in + // build, and resetting the model on every rebuild would erase the + // user's dragged divider position. + if (widget.paneConfig != oldWidget.paneConfig) { + _settleController.stop(); + _settleController.duration = widget.paneConfig.settleDuration; + _paneWidth = PaneWidthModel( + widget.paneConfig, + referenceWidth: _referenceWidth, + collapsible: _modelCollapsible, + ); + } + } + + /// Reference width for ratio conversion — the expanded breakpoint, since + /// that is the minimum width the expanded layout can have. + double get _referenceWidth => + widget.expandedBreakpoint ?? + AdaptiveLayoutConfig.defaultExpandedBreakpoint; + + @override + void dispose() { + _settleController.dispose(); + super.dispose(); + } + + // =========================================================================== + // MODEL <-> DIRECTIONAL TRANSLATION + // =========================================================================== + + // The width model measures the PRIMARY pane. When the primary sits at + // the directional end, model-space sides are the mirror of the + // directional sides the public API speaks. These helpers flip at the + // boundary so `PaneSide` / `PaneCollapsible` stay directional + // everywhere consumers see them. + + bool get _primaryAtStart => + widget.primaryPosition == SplitPrimaryPosition.start; + + PaneCollapsible get _modelCollapsible { + final collapsible = widget.paneConfig.collapsible; + if (_primaryAtStart) return collapsible; + return switch (collapsible) { + PaneCollapsible.start => PaneCollapsible.end, + PaneCollapsible.end => PaneCollapsible.start, + _ => collapsible, + }; + } + + PaneSide _flipSide(PaneSide side) => + side == PaneSide.start ? PaneSide.end : PaneSide.start; + + PaneSide? get _visualCollapsed { + final collapsed = _paneWidth.collapsed; + if (collapsed == null || _primaryAtStart) return collapsed; + return _flipSide(collapsed); + } + + // =========================================================================== + // DIVIDER DRAG + // =========================================================================== + + void _handleDividerDragStart() { + _settleController.stop(); + _paneWidth.dragStart(_lastExpandedWidth); + setState(() => _isDividerDragging = true); + } + + void _handleDividerDragEnd() { + _paneWidth.dragEnd(); + setState(() => _isDividerDragging = false); + // A collapsed pane parks — anchor snapping applies only to visible + // panes. + if (_paneWidth.collapsed == null) _settleToNearestAnchor(); + } + + void _handleDividerDragUpdate(double delta) { + final isRtl = Directionality.of(context) == TextDirection.rtl; + final effectiveDelta = isRtl ? -delta : delta; + // A start-positioned primary grows when dragging towards the end; + // an end-positioned primary grows the other way. + final directed = widget.primaryPosition == SplitPrimaryPosition.start + ? effectiveDelta + : -effectiveDelta; + setState(() => _paneWidth.drag(directed, _lastExpandedWidth)); + } + + /// Animates the divider to the nearest anchor. No-op without anchors. + void _settleToNearestAnchor() { + final target = _paneWidth.snapTarget(_lastExpandedWidth); + if (target != null) _settleToWidth(target); + } + + /// Animates the divider to [target] using the settle knobs. + void _settleToWidth(double target) { + final availableWidth = _lastExpandedWidth; + final begin = _paneWidth.width(availableWidth); + if ((target - begin).abs() < 0.5) return; + final curve = CurvedAnimation( + parent: _settleController, + curve: widget.paneConfig.settleCurve, + ); + void tick() { + _paneWidth.setWidth( + begin + (target - begin) * curve.value, + availableWidth, + ); + if (mounted) setState(() {}); + } + + _settleController + ..reset() + ..addListener(tick); + _settleController.forward().whenCompleteOrCancel(() { + _settleController.removeListener(tick); + if (mounted) setState(() {}); + }); + } + + // =========================================================================== + // DIVIDER KEYBOARD / COLLAPSE ACTIONS + // =========================================================================== + + /// Keyboard step: one micro-drag through the normal resize path, so RTL + /// and primary-position correction apply identically to pointer drags. + void _handleDividerStep(double delta) { + if (_paneWidth.collapsed != null) return; + _settleController.stop(); + _handleDividerDragUpdate(delta); + } + + /// Collapses the directional [side] instantly. Programmatic collapse + /// (keyboard, app buttons) snaps — the drag path is the only animated + /// route in 1b. + void _collapsePane(PaneSide side) { + if (!_isExpanded || _paneWidth.collapsed != null) return; + if (!widget.paneConfig.collapsible.allows(side)) return; + _settleController.stop(); + final modelSide = _primaryAtStart ? side : _flipSide(side); + setState(() => _paneWidth.collapse(modelSide, _lastExpandedWidth)); + } + + /// Restores a collapsed pane to its remembered width instantly. + void _restorePane() { + if (_paneWidth.collapsed == null) return; + setState(() => _paneWidth.restore(_lastExpandedWidth)); + } + + /// Enter on the focused divider: restore when collapsed, else collapse + /// the first directional side the config allows. + void _handleDividerToggleCollapse() { + if (_paneWidth.collapsed != null) { + _restorePane(); + return; + } + final collapsible = widget.paneConfig.collapsible; + if (collapsible.allows(PaneSide.start)) { + _collapsePane(PaneSide.start); + } else if (collapsible.allows(PaneSide.end)) { + _collapsePane(PaneSide.end); + } + } + + /// Double-click / double-tap: back to the configured default width. + /// Restores first when collapsed. + void _handleDividerReset() { + if (_paneWidth.collapsed != null) { + _restorePane(); + return; + } + _settleToWidth(_paneWidth.defaultWidth(_lastExpandedWidth)); + } + + // Home/End move the DIVIDER to its directional start/end. In model + // space that's the primary's minimum/maximum — mirrored when the + // primary sits at the directional end. + + void _handleDividerJumpToMinimum() { + if (_paneWidth.collapsed != null) _restorePane(); + _settleToWidth( + _primaryAtStart + ? widget.paneConfig.minListWidth + : _lastExpandedWidth * widget.paneConfig.maxListRatio, + ); + } + + void _handleDividerJumpToMaximum() { + if (_paneWidth.collapsed != null) _restorePane(); + _settleToWidth( + _primaryAtStart + ? _lastExpandedWidth * widget.paneConfig.maxListRatio + : widget.paneConfig.minListWidth, + ); + } + + /// The scope data descendants read via [PaneScope]. + PaneScopeData _paneScopeData() => PaneScopeData( + collapsed: _isExpanded ? _visualCollapsed : null, + isExpanded: _isExpanded, + collapsedSize: widget.paneConfig.collapsedSize, + collapse: _collapsePane, + restore: _restorePane, + ); + + // =========================================================================== + // BUILD + // =========================================================================== + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final breakpoint = AdaptiveLayoutConfig.resolveBreakpoint( + context, + widget.expandedBreakpoint, + ); + final isExpanded = constraints.maxWidth >= breakpoint; + _isExpanded = isExpanded; + if (_hasBuiltOnce && + !_wasExpandedLastBuild && + isExpanded && + widget.paneConfig.widthMemory == PaneWidthMemory.resetOnReentry) { + // Fresh divider on every re-entry — the opt-out from the + // default persistent memory. + _settleController.stop(); + _paneWidth = PaneWidthModel( + widget.paneConfig, + referenceWidth: _referenceWidth, + collapsible: _modelCollapsible, + ); + } + _wasExpandedLastBuild = isExpanded; + _hasBuiltOnce = true; + + return PaneScope( + data: _paneScopeData(), + child: isExpanded + ? _buildExpandedLayout(constraints) + : _buildCompactLayout(), + ); + }, + ); + } + + /// A collapsed pane's slot. With a rail builder the rail lays out at + /// the REAL slot width while the pane parks offstage, state alive + /// (tickers paused). Without one, the pane stays laid out at its + /// floor width and clips as the slot shrinks. + Widget _collapsedSlot({ + required Widget pane, + required double paneLayoutWidth, + required WidgetBuilder? railBuilder, + required AlignmentDirectional alignment, + }) { + // Reflow live down to the floor width, rigid-and-clipped below it — + // content never reflows below the floor the app designed for. + Widget heldAtFloor() => ClipRect( + child: LayoutBuilder( + builder: (context, slot) { + final width = slot.maxWidth < paneLayoutWidth + ? paneLayoutWidth + : slot.maxWidth; + return OverflowBox( + minWidth: width, + maxWidth: width, + alignment: alignment, + child: pane, + ); + }, + ), + ); + if (railBuilder == null) return heldAtFloor(); + // StackFit.expand — the stack must take the slot's size, not the + // offstage child's zero size. + return Stack( + fit: StackFit.expand, + children: [ + Offstage( + child: TickerMode( + enabled: false, + child: OverflowBox( + minWidth: paneLayoutWidth, + maxWidth: paneLayoutWidth, + alignment: alignment, + child: pane, + ), + ), + ), + Builder(builder: railBuilder), + ], + ); + } + + /// Formats the directional-start pane's share after a model-space + /// [delta], clamped to the pane limits, for the screen-reader value + /// contract. + String _paneSharePercent(double width, double delta, double availableWidth) { + // Min-wins guard: a narrow window can invert the floor/ceiling and + // clamp throws on inverted bounds. + final floor = widget.paneConfig.minListWidth; + final ceiling = availableWidth * widget.paneConfig.maxListRatio; + final clamped = ceiling <= floor + ? floor + : (width + delta).clamp(floor, ceiling); + final startShare = _primaryAtStart ? clamped : availableWidth - clamped; + return '${(startShare / availableWidth * 100).round()}%'; + } + + /// The divider's interaction state for [DividerBuilder]s, in + /// DIRECTIONAL terms: with an end-positioned primary the model's limits + /// mirror, so at-minimum/at-maximum and the collapsed side all flip. + DividerState _dividerState(double availableWidth, {bool isFocused = false}) { + final collapsed = _paneWidth.collapsed; + final width = _paneWidth.width(availableWidth); + final max = availableWidth * widget.paneConfig.maxListRatio; + final atModelMin = + collapsed == null && width <= widget.paneConfig.minListWidth + 0.5; + final atModelMax = collapsed == null && width >= max - 0.5; + return DividerState( + isDragging: _isDividerDragging, + isSettling: _isDividerSettling, + atMinimum: _primaryAtStart ? atModelMin : atModelMax, + atMaximum: _primaryAtStart ? atModelMax : atModelMin, + collapsed: _visualCollapsed, + isFocused: isFocused, + ); + } + + /// Expanded: side-by-side panes with draggable divider. + Widget _buildExpandedLayout(BoxConstraints constraints) { + final availableWidth = constraints.maxWidth; + _lastExpandedWidth = availableWidth; + final primaryWidth = _paneWidth.width(availableWidth); + final collapsed = _paneWidth.collapsed; + + // A collapsed pane's content stays laid out at its minimum width — + // its last legal layout — and clips as the slot shrinks below it. + Widget primaryContent = KeyedSubtree( + key: _primaryKey, + child: widget.primaryBuilder(context, true), + ); + if (collapsed == PaneSide.start) { + primaryContent = _collapsedSlot( + pane: primaryContent, + paneLayoutWidth: widget.paneConfig.minListWidth, + railBuilder: widget.collapsedPrimaryBuilder, + alignment: AlignmentDirectional.centerStart, + ); + } + final primaryPane = SizedBox(width: primaryWidth, child: primaryContent); + + Widget secondaryContent = KeyedSubtree( + key: _secondaryKey, + child: widget.secondaryBuilder(context, true), + ); + if (collapsed == PaneSide.end) { + secondaryContent = _collapsedSlot( + pane: secondaryContent, + paneLayoutWidth: availableWidth * (1 - widget.paneConfig.maxListRatio), + railBuilder: widget.collapsedSecondaryBuilder, + alignment: AlignmentDirectional.centerEnd, + ); + } + final secondaryPane = Expanded(child: secondaryContent); + + final isStart = widget.primaryPosition == SplitPrimaryPosition.start; + final first = isStart ? primaryPane : secondaryPane; + final second = isStart ? secondaryPane : primaryPane; + final dividerBuilder = widget.dividerBuilder; + + return Stack( + children: [ + Row(children: [first, second]), + // Divider — visual (if builder provided) or invisible drag zone. + PositionedDirectional( + // Hit area centered on the pane border. + start: + (isStart + ? primaryWidth - widget.paneConfig.dividerHitWidth / 2 + : availableWidth - + primaryWidth - + widget.paneConfig.dividerHitWidth / 2) + .clamp( + 0.0, + availableWidth - widget.paneConfig.dividerHitWidth, + ), + top: 0, + bottom: 0, + child: PaneDividerRegion( + hitWidth: widget.paneConfig.dividerHitWidth, + stateFor: (focused) => + _dividerState(availableWidth, isFocused: focused), + dividerBuilder: dividerBuilder, + onDragStart: _handleDividerDragStart, + onDragDelta: _handleDividerDragUpdate, + onDragEnd: _handleDividerDragEnd, + onStep: _handleDividerStep, + onToggleCollapse: _handleDividerToggleCollapse, + onJumpToMinimum: _handleDividerJumpToMinimum, + onJumpToMaximum: _handleDividerJumpToMaximum, + onReset: _handleDividerReset, + semanticsLabel: widget.paneConfig.dividerSemanticsLabel, + semanticsValue: _paneSharePercent(primaryWidth, 0, availableWidth), + semanticsIncreasedValue: _paneSharePercent( + primaryWidth, + PaneDividerRegion.keyboardStep, + availableWidth, + ), + semanticsDecreasedValue: _paneSharePercent( + primaryWidth, + -PaneDividerRegion.keyboardStep, + availableWidth, + ), + ), + ), + ], + ); + } + + /// Compact: vertical stack or hidden secondary. + Widget _buildCompactLayout() { + final primary = KeyedSubtree( + key: _primaryKey, + child: widget.primaryBuilder(context, false), + ); + + if (widget.compactBehavior == SplitCompactBehavior.hidden) { + return primary; + } + + return Column( + children: [ + Expanded(child: primary), + if (widget.compactSpacing > 0) SizedBox(height: widget.compactSpacing), + Expanded( + child: KeyedSubtree( + key: _secondaryKey, + child: widget.secondaryBuilder(context, false), + ), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2d262e8..5d7c36e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.10.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + sha256: "5bbf32bc9e518d41ec49718e2931cd4527292c9b0c6d2dffcf7fe6b9a8a8cf72" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.0" characters: dependency: transitive description: @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: "8e36feea6de5ea69f2199f29cf42a450a855738c498b57c0b980e2d3cca9c362" + url: "https://pub.dev" + source: hosted + version: "1.2.0" clock: dependency: transitive description: @@ -71,10 +79,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: @@ -95,10 +103,10 @@ packages: dependency: transitive description: name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.0.0" matcher: dependency: transitive description: @@ -140,10 +148,10 @@ packages: dependency: transitive description: name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + sha256: f4b22294de9a549967d0033d4f30fcad4f0afc200f4bf58e82d815725feec70c url: "https://pub.dev" source: hosted - version: "1.10.2" + version: "1.8.0" stack_trace: dependency: transitive description: @@ -164,18 +172,18 @@ packages: dependency: transitive description: name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.1.0" term_glyph: dependency: transitive description: name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.0" test_api: dependency: transitive description: @@ -196,10 +204,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "11.10.0" sdks: dart: ">=3.11.0 <4.0.0" flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 79d9ae6..75a92b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,9 @@ name: adaptive_layouts version: 0.0.0 description: >- - Adaptive list-detail and split layouts that morph between phone and - desktop forms — slide-over details that cover nav bars, draggable - anchored dividers, and pane state that survives window resizes. + Adaptive layout widgets that morph between phone and desktop forms — + list-detail, split panes, dialog-to-bottom-sheet modals, and pane + state that survives window resizes. homepage: https://github.com/whuppi/adaptive_layouts repository: https://github.com/whuppi/adaptive_layouts issue_tracker: https://github.com/whuppi/adaptive_layouts/issues diff --git a/test/components/dividers/handle_divider_test.dart b/test/components/dividers/handle_divider_test.dart index b30f860..0be844d 100644 --- a/test/components/dividers/handle_divider_test.dart +++ b/test/components/dividers/handle_divider_test.dart @@ -11,8 +11,10 @@ void main() { width: 24, height: 400, child: Builder( - builder: (context) => - HandleDivider.builder(context, isDragging, isSettling), + builder: (context) => HandleDivider.builder( + context, + DividerState(isDragging: isDragging, isSettling: isSettling), + ), ), ), ), diff --git a/test/components/dividers/material_divider_test.dart b/test/components/dividers/material_divider_test.dart index 2114f0d..bb69e73 100644 --- a/test/components/dividers/material_divider_test.dart +++ b/test/components/dividers/material_divider_test.dart @@ -10,8 +10,10 @@ void main() { width: 24, height: 400, child: Builder( - builder: (context) => - MaterialDivider.builder(context, isDragging, isSettling), + builder: (context) => MaterialDivider.builder( + context, + DividerState(isDragging: isDragging, isSettling: isSettling), + ), ), ), ), diff --git a/test/core/list_detail/breakpoint_motion_test.dart b/test/core/list_detail/breakpoint_motion_test.dart new file mode 100644 index 0000000..60ff328 --- /dev/null +++ b/test/core/list_detail/breakpoint_motion_test.dart @@ -0,0 +1,230 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +Widget _layout(CompactDetailMode mode) { + return Material( + child: ListDetailLayout( + compactDetailMode: mode, + listBuilder: (context, selectedId, onSelect) => Column( + children: [ + for (final id in const ['a', 'b']) + TextButton(onPressed: () => onSelect(id), child: Text('item-$id')), + ], + ), + detailBuilder: (context, id, mode, onDismiss) => Material( + child: Column( + children: [ + Text('detail-$id (${mode.name})'), + const Expanded(child: CounterPane()), + ], + ), + ), + emptyStateBuilder: (context) => const Text('empty'), + ), + ); +} + +final _stacked = find.text('detail-a (stacked)'); +final _sideBySide = find.text('detail-a (sideBySide)'); + +/// A crossing delivered as many small per-frame deltas — a window drag. +/// The panes must animate their re-arrangement at the threshold exactly +/// like they do for a one-frame jump; only the tracking of pane geometry +/// within an arrangement follows the hand without motion. +Future _draggedResize(WidgetTester tester, double from, double to) async { + var width = from; + while (width != to) { + width = from > to + ? (width - 30).clamp(to, from) + : (width + 30).clamp(from, to); + tester.view.physicalSize = Size(width, 800); + await tester.pump(); + } +} + +void main() { + for (final mode in [CompactDetailMode.inline, CompactDetailMode.overlay]) { + for (final (label, cross) in [ + ( + 'one-frame jump', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + }, + ), + ( + 'window drag', + (WidgetTester tester) => _draggedResize(tester, 1000, 700), + ), + ]) { + testWidgets('${mode.name}: $label grows the detail out of its pane', ( + tester, + ) async { + await pumpApp(tester, _layout(mode), size: const Size(1000, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + // Divider fraction before crossing (CounterPane stretches to the + // pane, so its left edge IS the divider). + final fraction = tester.getTopLeft(find.byType(CounterPane)).dx / 1000; + + await cross(tester); + // First compact frame: the detail's leading edge sits at the + // divider's FRACTIONAL position in the NEW width — never seeded + // from old pixels, which on a jump can start it off-screen. + final newWidth = tester.getSize(find.byType(Material).first).width; + expect( + tester.getTopLeft(find.byType(CounterPane)).dx, + closeTo(fraction * newWidth, 2), + ); + await tester.pump(const Duration(milliseconds: 40)); + + final midMorph = tester.getTopLeft(_stacked).dx; + await tester.pumpAndSettle(); + final settled = tester.getTopLeft(_stacked).dx; + + expect(midMorph, greaterThan(settled)); // slid in from the pane edge + expect(find.text('count: 2'), findsOneWidget); // state rode the morph + }); + } + + testWidgets('${mode.name}: crossing into expanded slides the list in', ( + tester, + ) async { + await pumpApp(tester, _layout(mode), size: const Size(500, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + // Mid-entry the detail is wider than its final pane: its leading + // edge sits closer to the window edge than the settled divider. + final midEntry = tester.getTopLeft(_sideBySide).dx; + await tester.pumpAndSettle(); + final settled = tester.getTopLeft(_sideBySide).dx; + + expect(midEntry, lessThan(settled)); + expect(find.text('count: 2'), findsOneWidget); + }); + } + + for (final (label, cross) in [ + ( + 'one-frame jump', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + }, + ), + ('window drag', (WidgetTester tester) => _draggedResize(tester, 1000, 700)), + ]) { + testWidgets('route: $label plays the real route entrance', (tester) async { + await pumpApp( + tester, + _layout(CompactDetailMode.route), + size: const Size(1000, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + await cross(tester); // crossing frame: list + offstage bridge + await tester.pump(const Duration(milliseconds: 16)); // overlay inserts + await tester.pump(const Duration(milliseconds: 16)); // route content live + + // Entrance under way — and the LIST sits beneath it, not the bridge. + expect(find.text('item-a'), findsOneWidget); + final route = ModalRoute.of(tester.element(_stacked))!; + expect(route.animation!.status, AnimationStatus.forward); + + await tester.pumpAndSettle(); + expect(route.animation!.status, AnimationStatus.completed); + expect(find.text('count: 2'), findsOneWidget); + }); + } + + for (final (style, expectation) in [ + (ExpandedEntryStyle.reveal, 'final width throughout — no reflow'), + (ExpandedEntryStyle.resize, 'growing widths — reflows as it arrives'), + ]) { + testWidgets('${style.name} entry gives the list $expectation', ( + tester, + ) async { + final widths = []; + await pumpApp( + tester, + Material( + child: ListDetailLayout( + paneConfig: PaneConfig(entryStyle: style), + listBuilder: (context, selectedId, onSelect) => LayoutBuilder( + builder: (context, constraints) { + widths.add(constraints.maxWidth); + return TextButton( + onPressed: () => onSelect('a'), + child: const Text('item-a'), + ); + }, + ), + detailBuilder: (context, id, mode, onDismiss) => + Text('detail-$id (${mode.name})'), + emptyStateBuilder: (context) => const Text('empty'), + ), + ), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + widths.clear(); + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + await tester.pump(const Duration(milliseconds: 40)); + await tester.pumpAndSettle(); + + final finalWidth = widths.last; + final reflowed = widths.any((w) => w < finalWidth - 0.01); + expect(reflowed, style == ExpandedEntryStyle.resize); + }); + } + + testWidgets('route: crossing into expanded slides the list in seamlessly', ( + tester, + ) async { + await pumpApp( + tester, + _layout(CompactDetailMode.route), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final midEntry = tester.getTopLeft(_sideBySide).dx; + await tester.pumpAndSettle(); + final settled = tester.getTopLeft(_sideBySide).dx; + + expect(midEntry, lessThan(settled)); + final navigator = tester.state(find.byType(Navigator)); + expect(navigator.canPop(), isFalse); // the route is gone + expect(find.text('count: 2'), findsOneWidget); + }); +} diff --git a/test/core/list_detail/divider_interaction_test.dart b/test/core/list_detail/divider_interaction_test.dart new file mode 100644 index 0000000..290245e --- /dev/null +++ b/test/core/list_detail/divider_interaction_test.dart @@ -0,0 +1,459 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +/// Stage 1b divider interactivity: keyboard resizing, Enter collapse, +/// Home/End jumps, double-click reset, PaneScope actions, and the +/// screen-reader value contract. The WAI-ARIA window-splitter pattern. +void main() { + Widget buildLayout({ + PaneConfig paneConfig = const PaneConfig(), + DividerBuilder? dividerBuilder, + Widget Function(BuildContext context)? detailExtra, + }) { + return ListDetailLayout( + controller: ListDetailController(initialSelection: 'a'), + paneConfig: paneConfig, + dividerBuilder: dividerBuilder, + listBuilder: (context, selectedId, onSelect) => + const ColoredBox(key: Key('list'), color: Color(0xFF111111)), + detailBuilder: (context, id, mode, onDismiss) => ColoredBox( + key: const Key('detail'), + color: const Color(0xFF333333), + child: detailExtra?.call(context) ?? const SizedBox(), + ), + ); + } + + const expanded = Size(1000, 800); + double listWidth(WidgetTester tester) => + tester.getSize(find.byKey(const Key('list'))).width; + + Future focusDivider(WidgetTester tester) async { + // Test content has no other focusables — one Tab lands on the divider. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + } + + group('keyboard', () { + testWidgets('Tab focuses the divider; DividerState reports it', ( + tester, + ) async { + final states = []; + await pumpApp( + tester, + buildLayout( + dividerBuilder: (context, state) { + states.add(state); + return const SizedBox.expand(); + }, + ), + size: expanded, + ); + expect(states.last.isFocused, isFalse); + + await focusDivider(tester); + expect(states.last.isFocused, isTrue); + }); + + testWidgets('arrow keys resize by the keyboard step', (tester) async { + // Pixel mode, mid-range start: the ratio default sits exactly at the + // maxListRatio cap, where growing would clamp to a no-op. + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig( + defaultListWidth: 300, + resizeMode: PaneResizeMode.pixels, + ), + ), + size: expanded, + ); + final before = listWidth(tester); + expect(before, 300); + + await focusDivider(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(listWidth(tester), before + PaneDividerRegion.keyboardStep); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(listWidth(tester), before - PaneDividerRegion.keyboardStep); + }); + + testWidgets('Enter collapses the allowed side and restores', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig(collapsible: PaneCollapsible.start), + ), + size: expanded, + ); + final before = listWidth(tester); + + await focusDivider(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + // Collapsed slot is zero-wide; content stays laid out at minimum + // inside the clip. + expect(listWidth(tester), const PaneConfig().minListWidth); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(listWidth(tester), before); + }); + + testWidgets('Enter is a no-op when collapse is disallowed', (tester) async { + await pumpApp(tester, buildLayout(), size: expanded); + final before = listWidth(tester); + + await focusDivider(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(listWidth(tester), before); + }); + + testWidgets('Home and End settle to the pane limits', (tester) async { + await pumpApp(tester, buildLayout(), size: expanded); + + await focusDivider(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.pumpAndSettle(); + expect(listWidth(tester), const PaneConfig().minListWidth); + + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.pumpAndSettle(); + expect(listWidth(tester), 1000 * const PaneConfig().maxListRatio); + }); + }); + + group('double-click reset', () { + testWidgets('returns a dragged divider to the default width', ( + tester, + ) async { + await pumpApp(tester, buildLayout(), size: expanded); + final defaultWidth = listWidth(tester); + + await tester.dragFrom(Offset(defaultWidth, 400), const Offset(-150, 0)); + await tester.pumpAndSettle(); + final dragged = listWidth(tester); + expect(dragged, lessThan(defaultWidth)); + + final dividerCenter = Offset(dragged, 400); + await tester.tapAt(dividerCenter); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(dividerCenter); + await tester.pumpAndSettle(); + expect(listWidth(tester), defaultWidth); + }); + + testWidgets('restores a collapsed pane', (tester) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig(collapsible: PaneCollapsible.start), + ), + size: expanded, + ); + final before = listWidth(tester); + + // Force-drag past the collapse threshold. + await tester.dragFrom(Offset(before, 400), const Offset(-600, 0)); + await tester.pumpAndSettle(); + expect(listWidth(tester), const PaneConfig().minListWidth); + + // Double-click the parked divider (at the start edge). + const parked = Offset(6, 400); + await tester.tapAt(parked); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(parked); + await tester.pumpAndSettle(); + expect(listWidth(tester), before); + }); + }); + + group('PaneScope', () { + testWidgets('detail pane sees the collapse and restores via action', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig(collapsible: PaneCollapsible.start), + detailExtra: (context) { + final scope = PaneScope.of(context); + if (scope.collapsed != PaneSide.start) return const SizedBox(); + return IconButton( + key: const Key('hamburger'), + icon: const Icon(Icons.menu), + onPressed: scope.restore, + ); + }, + ), + size: expanded, + ); + final before = listWidth(tester); + expect(find.byKey(const Key('hamburger')), findsNothing); + + await tester.dragFrom(Offset(before, 400), const Offset(-600, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('hamburger')), findsOneWidget); + + await tester.tap(find.byKey(const Key('hamburger'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('hamburger')), findsNothing); + expect(listWidth(tester), before); + }); + + testWidgets('collapse action snaps a pane closed programmatically', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig(collapsible: PaneCollapsible.start), + detailExtra: (context) => IconButton( + key: const Key('collapse-list'), + icon: const Icon(Icons.chevron_left), + onPressed: () => PaneScope.of(context).collapse(PaneSide.start), + ), + ), + size: expanded, + ); + + await tester.tap(find.byKey(const Key('collapse-list'))); + await tester.pumpAndSettle(); + expect(listWidth(tester), const PaneConfig().minListWidth); + }); + + testWidgets('collapse action refuses a disallowed side', (tester) async { + await pumpApp( + tester, + buildLayout( + detailExtra: (context) => IconButton( + key: const Key('collapse-list'), + icon: const Icon(Icons.chevron_left), + onPressed: () => PaneScope.of(context).collapse(PaneSide.start), + ), + ), + size: expanded, + ); + final before = listWidth(tester); + + await tester.tap(find.byKey(const Key('collapse-list'))); + await tester.pumpAndSettle(); + expect(listWidth(tester), before); + }); + }); + + group('collapsed icon rail', () { + Widget buildRailLayout() { + return ListDetailLayout( + controller: ListDetailController(initialSelection: 'a'), + paneConfig: const PaneConfig( + collapsible: PaneCollapsible.start, + collapsedSize: 56, + ), + listBuilder: (context, selectedId, onSelect) => + const CounterPane(label: 'list'), + detailBuilder: (context, id, mode, onDismiss) => + const ColoredBox(key: Key('detail'), color: Color(0xFF333333)), + collapsedListBuilder: (context) { + // The scope tells rail/pane content how collapsed "collapsed" + // is — non-zero means this rail is the expand affordance. + expect(PaneScope.of(context).collapsedSize, 56); + return ColoredBox( + key: const Key('rail'), + color: const Color(0xFF444444), + child: IconButton( + key: const Key('rail-expand'), + icon: const Icon(Icons.menu), + onPressed: PaneScope.of(context).restore, + ), + ); + }, + ); + } + + testWidgets('rail lays out at the real slot width; list parks alive', ( + tester, + ) async { + await pumpApp(tester, buildRailLayout(), size: expanded); + + // Mutate list state, then collapse. + await tester.tap(find.text('list: 0')); + await tester.pump(); + + await tester.dragFrom(const Offset(500, 400), const Offset(-600, 0)); + await tester.pumpAndSettle(); + + // The rail owns the 56px slot — laid at 56, not clipped-at-minimum. + expect(tester.getSize(find.byKey(const Key('rail'))).width, 56); + // The list is parked offstage, still mounted. + expect(find.text('list: 1', skipOffstage: false), findsOneWidget); + expect(find.text('list: 1'), findsNothing); + }); + + testWidgets('rail restore brings the same list instance back', ( + tester, + ) async { + await pumpApp(tester, buildRailLayout(), size: expanded); + await tester.tap(find.text('list: 0')); + await tester.pump(); + final before = tester.getSize(find.text('list: 1')).width; + expect(before, greaterThan(0)); + + await tester.dragFrom(const Offset(500, 400), const Offset(-600, 0)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-expand'))); + await tester.pumpAndSettle(); + // Same live instance — the count survived the round trip. + expect(find.text('list: 1'), findsOneWidget); + expect(find.byKey(const Key('rail')), findsNothing); + }); + + testWidgets('empty placeholder collapses into the rail slot too', ( + tester, + ) async { + await pumpApp( + tester, + ListDetailLayout( + paneConfig: const PaneConfig( + collapsible: PaneCollapsible.end, + collapsedSize: 56, + ), + listBuilder: (context, selectedId, onSelect) => + const ColoredBox(key: Key('list'), color: Color(0xFF111111)), + detailBuilder: (context, id, mode, onDismiss) => + const ColoredBox(color: Color(0xFF333333)), + emptyStateBuilder: (_) => const Center(child: Text('pick one')), + collapsedDetailBuilder: (context) => + const ColoredBox(key: Key('rail'), color: Color(0xFF444444)), + ), + size: expanded, + ); + + // Force-collapse the (empty) detail pane. + await tester.dragFrom(const Offset(500, 400), const Offset(600, 0)); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byKey(const Key('rail'))).width, 56); + // The placeholder is parked offstage, not squished on screen. + expect(find.text('pick one'), findsNothing); + expect(find.text('pick one', skipOffstage: false), findsOneWidget); + }); + + testWidgets('collapsed empty pane survives a resize into compact', ( + tester, + ) async { + // Repro: both + 56px rail, collapse the empty detail, shrink the + // window. The retreat renders expanded geometry at COMPACT width, + // where the ratio ceiling drops below the min floor — the share + // strings must not throw on inverted clamp bounds. + Widget layout() => ListDetailLayout( + paneConfig: const PaneConfig( + collapsible: PaneCollapsible.both, + collapsedSize: 56, + ), + listBuilder: (context, selectedId, onSelect) => + const ColoredBox(key: Key('list'), color: Color(0xFF111111)), + detailBuilder: (context, id, mode, onDismiss) => + const ColoredBox(color: Color(0xFF333333)), + emptyStateBuilder: (_) => const Center(child: Text('pick one')), + collapsedDetailBuilder: (context) => + const ColoredBox(key: Key('rail'), color: Color(0xFF444444)), + ); + await pumpApp(tester, layout(), size: expanded); + + await tester.dragFrom(const Offset(500, 400), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('rail')), findsOneWidget); + + await pumpApp(tester, layout(), size: const Size(390, 800)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byKey(const Key('list'))).width, 390); + + await pumpApp(tester, layout(), size: expanded); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('parked detail arrives docked — no detail-side morph', ( + tester, + ) async { + // Collapsed detail + selection, resize compact -> expanded: the + // rail docks at the end edge at its parked width from frame one; + // only the list plays its slide-in. The parked pane doesn't + // replay a collapse it already did. + Widget layout() => ListDetailLayout( + controller: ListDetailController(initialSelection: 'a'), + paneConfig: const PaneConfig( + collapsible: PaneCollapsible.end, + collapsedSize: 56, + ), + listBuilder: (context, selectedId, onSelect) => + const ColoredBox(key: Key('list'), color: Color(0xFF111111)), + detailBuilder: (context, id, mode, onDismiss) => + const ColoredBox(key: Key('detail'), color: Color(0xFF333333)), + collapsedDetailBuilder: (context) => + const ColoredBox(key: Key('rail'), color: Color(0xFF444444)), + ); + await pumpApp(tester, layout(), size: expanded); + await tester.dragFrom(const Offset(500, 400), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('rail')), findsOneWidget); + + await pumpApp(tester, layout(), size: const Size(390, 800)); + await tester.pumpAndSettle(); + + // Back to expanded — sample the crossing mid-flight. + await pumpApp(tester, layout(), size: expanded); + await tester.pump(const Duration(milliseconds: 100)); + final rail = find.byKey(const Key('rail')); + expect(rail, findsOneWidget); + expect(tester.getSize(rail).width, 56); + expect(tester.getTopRight(rail).dx, 1000); + // Mid-slide the list is still arriving: the reveal discipline + // lays it at FINAL width clipped, end-aligned — so its leading + // edge sits off-screen to the start while the slot grows. + expect(tester.getTopLeft(find.byKey(const Key('list'))).dx, lessThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, 1000 - 56); + expect(tester.getSize(rail).width, 56); + }); + }); + + group('semantics', () { + testWidgets('divider is an adjustable element with a share value', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpApp(tester, buildLayout(), size: expanded); + + final semantics = tester.getSemantics( + find.bySemanticsLabel('Pane divider'), + ); + expect(semantics.value, '50%'); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.increase), + isTrue, + ); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.decrease), + isTrue, + ); + handle.dispose(); + }); + }); +} diff --git a/test/core/list_detail/list_detail_empty_behavior_test.dart b/test/core/list_detail/list_detail_empty_behavior_test.dart new file mode 100644 index 0000000..b3e63af --- /dev/null +++ b/test/core/list_detail/list_detail_empty_behavior_test.dart @@ -0,0 +1,190 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +Widget _layout({ + ListDetailController? controller, + ExpandedEmptyBehavior behavior = ExpandedEmptyBehavior.listOnly, +}) { + return Material( + child: ListDetailLayout( + controller: controller, + expandedEmptyBehavior: behavior, + listBuilder: (context, selectedId, onSelect) => ColoredBox( + key: const Key('list'), + color: const Color(0xFF111111), + child: Center( + child: TextButton( + onPressed: () => onSelect('a'), + child: const Text('item-a'), + ), + ), + ), + detailBuilder: (context, id, mode, onDismiss) => ColoredBox( + key: const Key('detail'), + color: const Color(0xFF333333), + child: Column( + children: [ + Text('detail-$id'), + TextButton(onPressed: onDismiss, child: const Text('close')), + const Expanded(child: CounterPane()), + ], + ), + ), + emptyStateBuilder: (context) => const Text('empty state'), + ), + ); +} + +void main() { + const expanded = Size(1000, 800); + + testWidgets('listOnly: the list owns the full width until a selection', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: expanded); + + expect(tester.getSize(find.byKey(const Key('list'))).width, 1000); + expect(find.text('empty state'), findsNothing); + expect(find.byKey(const Key('detail')), findsNothing); + }); + + testWidgets('listOnly: selecting reveals the pane; dismissing returns it', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: expanded); + + await tester.tap(find.text('item-a')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + // Mid-reveal: the pane is arriving, list yielding. + final midList = tester.getSize(find.byKey(const Key('list'))).width; + expect(midList, lessThan(1000)); + // Reveal discipline: the arriving detail is laid at its FINAL width. + expect(tester.getSize(find.byKey(const Key('detail'))).width, 500); + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, 500); + expect(find.text('detail-a'), findsOneWidget); + + await tester.tap(find.text('close')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + // Mid-exit the outgoing detail still rides the retreating pane. + expect(find.text('detail-a'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, 1000); + expect(find.byKey(const Key('detail')), findsNothing); + }); + + testWidgets('listOnly: crossings without a selection are a non-event', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: const Size(500, 800)); + expect(tester.getSize(find.byKey(const Key('list'))).width, 500); + + await resizeWindow(tester, expanded); + // Full-width list on both sides of the breakpoint — no pane flash, + // no empty state, nothing animating. + expect(tester.getSize(find.byKey(const Key('list'))).width, 1000); + expect(find.text('empty state'), findsNothing); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('listOnly: crossing with a selection lands both panes', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: const Size(500, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + await resizeWindow(tester, expanded); + expect(tester.getSize(find.byKey(const Key('list'))).width, 500); + expect(find.text('detail-a'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); // state survived + + await resizeWindow(tester, const Size(500, 800)); + expect(find.text('count: 2'), findsOneWidget); + }); + + for (final mode in CompactDetailMode.values) { + testWidgets( + 'placeholder (${mode.name}): empty-pane crossings animate both ways', + (tester) async { + await pumpApp( + tester, + Material( + child: ListDetailLayout( + compactDetailMode: mode, + listBuilder: (context, selectedId, onSelect) => + const ColoredBox(key: Key('list'), color: Color(0xFF111111)), + detailBuilder: (context, id, mode, onDismiss) => Text('d-$id'), + emptyStateBuilder: (context) => const Text('empty state'), + ), + ), + size: const Size(500, 800), + ); + + // Into expanded: the empty pane REVEALS — mid-flight the list has + // yielded partially and the placeholder is already on screen. + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + final midList = tester.getSize(find.byKey(const Key('list'))).width; + expect(midList, lessThan(1000)); + expect(midList, greaterThan(500)); + expect(find.text('empty state'), findsOneWidget); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, 500); + + // Into compact: the empty pane RETREATS — the expanded geometry + // stays alive at the compact width until the retreat lands. + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('empty state'), findsOneWidget); // still riding out + expect( + tester.getSize(find.byKey(const Key('list'))).width, + lessThan(500), + ); + await tester.pumpAndSettle(); + expect(find.text('empty state'), findsNothing); + expect(tester.getSize(find.byKey(const Key('list'))).width, 500); + }, + ); + } + + testWidgets('live flip between behaviors lands the settled layout', ( + tester, + ) async { + final behavior = ValueNotifier( + ExpandedEmptyBehavior.placeholder, + ); + addTearDown(behavior.dispose); + await pumpApp( + tester, + ValueListenableBuilder( + valueListenable: behavior, + builder: (context, b, _) => _layout(behavior: b), + ), + size: expanded, + ); + expect(find.text('empty state'), findsOneWidget); + + behavior.value = ExpandedEmptyBehavior.listOnly; + await tester.pumpAndSettle(); + expect(find.text('empty state'), findsNothing); + expect(tester.getSize(find.byKey(const Key('list'))).width, 1000); + + behavior.value = ExpandedEmptyBehavior.placeholder; + await tester.pumpAndSettle(); + expect(find.text('empty state'), findsOneWidget); + }); +} diff --git a/test/core/list_detail/list_detail_layout_test.dart b/test/core/list_detail/list_detail_layout_test.dart index 70f87cf..b4df6de 100644 --- a/test/core/list_detail/list_detail_layout_test.dart +++ b/test/core/list_detail/list_detail_layout_test.dart @@ -107,8 +107,10 @@ void main() { closeTo(before - 100, 30), ); - // Dragging far past maxListRatio clamps. - await tester.dragFrom(Offset(before - 100, 400), const Offset(900, 0)); + // Dragging far past maxListRatio clamps. Grab the divider where it + // is now — touch slop shifts the settled position a little. + final mid = tester.getSize(find.byKey(const Key('list'))).width; + await tester.dragFrom(Offset(mid, 400), const Offset(900, 0)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(const Key('list'))).width, @@ -116,6 +118,186 @@ void main() { ); }); + testWidgets( + 'divider position survives compact spells and equal-value config ' + 'rebuilds', + (tester) async { + // Non-const configs: the inline-construction shape every app has. + await pumpApp( + tester, + buildLayout(paneConfig: PaneConfig()), + size: expanded, + ); + final before = tester.getSize(find.byKey(const Key('list'))).width; + await tester.dragFrom(Offset(before, 400), const Offset(-100, 0)); + await tester.pumpAndSettle(); + final dragged = tester.getSize(find.byKey(const Key('list'))).width; + expect(dragged, isNot(before)); + + // Rebuild with a NEW but value-equal config instance — must NOT + // reset the width model (identity comparison did). + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: buildLayout(paneConfig: PaneConfig()), + ), + ), + ); + await resizeWindow(tester, compact); + await resizeWindow(tester, expanded); + + expect( + tester.getSize(find.byKey(const Key('list'))).width, + closeTo(dragged, 1), + ); + }, + ); + + testWidgets('settle knobs: custom duration and curve are honored', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig( + minListWidth: 100, + maxListRatio: 0.9, + anchors: [PaneAnchor.fromStart(240), PaneAnchor.fromStart(500)], + initialAnchorIndex: 0, + settleDuration: Duration(seconds: 1), + ), + ), + size: expanded, + ); + + await tester.dragFrom(const Offset(240, 400), const Offset(200, 0)); + await tester.pump(const Duration(milliseconds: 400)); + // Default 220ms settle would already be done — the 1s one is not. + final mid = tester.getSize(find.byKey(const Key('list'))).width; + expect(mid, isNot(500)); + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, 500); + }); + + testWidgets('dividerHitWidth: a wide zone accepts far-off drags', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout(paneConfig: const PaneConfig(dividerHitWidth: 120)), + size: expanded, + ); + final before = tester.getSize(find.byKey(const Key('list'))).width; + + // 50px from the border — outside the default 24px zone. + await tester.dragFrom(Offset(before - 50, 400), const Offset(-100, 0)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const Key('list'))).width, + lessThan(before), + ); + }); + + testWidgets('force-drag past min collapses the list; drag-out restores', ( + tester, + ) async { + final states = []; + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig(collapsible: PaneCollapsible.start), + dividerBuilder: (context, state) { + states.add(state); + return const SizedBox(); + }, + ), + size: expanded, + ); + final before = tester.getSize(find.byKey(const Key('list'))).width; + + // Pin at min without crossing the collapse threshold — springs back. + await tester.dragFrom(Offset(before, 400), const Offset(-350, 0)); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, 200); + expect(states.last.atMinimum, isTrue); + expect(states.last.collapsed, isNull); + + // Force past min by more than half the min — snap collapse. The + // content stays laid at min (clip, never squish); the slot is 0 so + // the empty pane starts at the window edge. + await tester.dragFrom(const Offset(200, 400), const Offset(-320, 0)); + await tester.pumpAndSettle(); + expect(states.last.collapsed, PaneSide.start); + expect(tester.getSize(find.byKey(const Key('list'))).width, 200); + expect(tester.getTopLeft(find.text('empty state')).dx, lessThan(500)); + + // The parked divider is still draggable — pull it back out. + await tester.dragFrom(const Offset(10, 400), const Offset(320, 0)); + await tester.pumpAndSettle(); + expect(states.last.collapsed, isNull); + expect( + tester.getSize(find.byKey(const Key('list'))).width, + greaterThanOrEqualTo(200), + ); + }); + + testWidgets('collapsedSize keeps an icon-rail sliver of the list', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig( + collapsible: PaneCollapsible.start, + collapsedSize: 48, + ), + ), + size: expanded, + ); + final before = tester.getSize(find.byKey(const Key('list'))).width; + await tester.dragFrom(Offset(before, 400), const Offset(-450, 0)); + await tester.pumpAndSettle(); + + // Slot is 48 wide; content laid at min and clipped to the sliver. + expect(tester.getSize(find.byKey(const Key('list'))).width, 200); + final clip = tester.getSize( + find.ancestor( + of: find.byKey(const Key('list')), + matching: find.byType(ClipRect), + ), + ); + expect(clip.width, 48); + }); + + testWidgets('widthMemory.resetOnReentry forgets the drag on re-entry', ( + tester, + ) async { + await pumpApp( + tester, + buildLayout( + paneConfig: const PaneConfig( + widthMemory: PaneWidthMemory.resetOnReentry, + ), + ), + size: expanded, + ); + final before = tester.getSize(find.byKey(const Key('list'))).width; + await tester.dragFrom(Offset(before, 400), const Offset(-100, 0)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const Key('list'))).width, + isNot(before), + ); + + await resizeWindow(tester, compact); + await resizeWindow(tester, expanded); + + // Fresh divider: back to the default width, drag forgotten. + expect(tester.getSize(find.byKey(const Key('list'))).width, before); + }); + testWidgets('divider settles to the nearest anchor after a drag', ( tester, ) async { @@ -129,8 +311,8 @@ void main() { anchors: [PaneAnchor.fromStart(240), PaneAnchor.fromStart(500)], initialAnchorIndex: 0, ), - dividerBuilder: (context, isDragging, isSettling) { - settleSeen.add(isSettling); + dividerBuilder: (context, state) { + settleSeen.add(state.isSettling); return const SizedBox(); }, ), diff --git a/test/core/list_detail/list_detail_route_test.dart b/test/core/list_detail/list_detail_route_test.dart new file mode 100644 index 0000000..fdbcf7d --- /dev/null +++ b/test/core/list_detail/list_detail_route_test.dart @@ -0,0 +1,368 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +Widget _layout({ListDetailController? controller}) { + return Material( + child: ListDetailLayout( + controller: controller, + compactDetailMode: CompactDetailMode.route, + listBuilder: (context, selectedId, onSelect) => Column( + children: [ + for (final id in const ['a', 'b']) + TextButton(onPressed: () => onSelect(id), child: Text('item-$id')), + ], + ), + detailBuilder: (context, id, mode, onDismiss) => Material( + child: Column( + children: [ + Text('detail-$id (${mode.name})'), + TextButton(onPressed: onDismiss, child: const Text('close')), + const Expanded(child: CounterPane()), + ], + ), + ), + emptyStateBuilder: (context) => const Text('empty'), + ), + ); +} + +NavigatorState _navigator(WidgetTester tester) => + tester.state(find.byType(Navigator)); + +void main() { + testWidgets('selecting pushes a REAL route with its entrance animation', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: const Size(500, 800)); + expect(_navigator(tester).canPop(), isFalse); + + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + expect(find.text('detail-a (stacked)'), findsOneWidget); + expect(_navigator(tester).canPop(), isTrue); + }); + + testWidgets('system back pops the route and clears the selection', ( + tester, + ) async { + final controller = ListDetailController(); + await pumpApp( + tester, + _layout(controller: controller), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(controller.hasSelection, isFalse); + expect(find.text('detail-a (stacked)'), findsNothing); + expect(find.text('item-a'), findsOneWidget); + expect(_navigator(tester).canPop(), isFalse); + }); + + testWidgets('controller.dismiss pops with a real exit animation', ( + tester, + ) async { + final controller = ListDetailController(); + await pumpApp( + tester, + _layout(controller: controller), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + controller.dismiss(); + await tester.pump(); + await tester.pump(); // sync fires post-frame, pop starts + await tester.pump(const Duration(milliseconds: 50)); + + // Mid-exit: the detail rides the route out; isDetailVisible holds. + expect(find.text('detail-a (stacked)'), findsOneWidget); + expect(controller.isDetailVisible, isTrue); + + await tester.pumpAndSettle(); + expect(find.text('detail-a (stacked)'), findsNothing); + expect(controller.isDetailVisible, isFalse); + }); + + testWidgets('the close button inside the detail dismisses', (tester) async { + final controller = ListDetailController(); + await pumpApp( + tester, + _layout(controller: controller), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('close')); + await tester.pumpAndSettle(); + + expect(controller.hasSelection, isFalse); + expect(_navigator(tester).canPop(), isFalse); + }); + + testWidgets('detail state survives compact ↔ expanded resizes', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: const Size(500, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + expect(find.text('count: 2'), findsOneWidget); + + await resizeWindow(tester, const Size(1000, 800)); + expect(find.text('detail-a (sideBySide)'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); + expect(_navigator(tester).canPop(), isFalse); // pane, not a route + + await resizeWindow(tester, const Size(500, 800)); + expect(find.text('detail-a (stacked)'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); + expect(_navigator(tester).canPop(), isTrue); // routed again + }); + + testWidgets('expanded layout never pushes a route', (tester) async { + await pumpApp(tester, _layout(), size: const Size(1000, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + expect(find.text('detail-a (sideBySide)'), findsOneWidget); + expect(_navigator(tester).canPop(), isFalse); + }); + + testWidgets('deep-link mount shows the detail from the first frame', ( + tester, + ) async { + final controller = ListDetailController(initialSelection: 'b'); + tester.view.physicalSize = const Size(500, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(MaterialApp(home: _layout(controller: controller))); + // First frame — before any post-frame push: the bridge shows it. + expect(find.text('detail-b (stacked)'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('detail-b (stacked)'), findsOneWidget); + expect(_navigator(tester).canPop(), isTrue); + }); + + testWidgets('re-selecting during the exit animation lands cleanly', ( + tester, + ) async { + await pumpApp(tester, _layout(), size: const Size(500, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + await tester.binding.handlePopRoute(); + await tester.pump(const Duration(milliseconds: 40)); // exit under way + await tester.tap(find.text('item-b'), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.text('detail-b (stacked)'), findsOneWidget); + expect(find.text('detail-a (stacked)'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('selection change while open swaps content in the same route', ( + tester, + ) async { + final controller = ListDetailController(); + await pumpApp( + tester, + _layout(controller: controller), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + controller.select('b'); + await tester.pumpAndSettle(); + + expect(find.text('detail-b (stacked)'), findsOneWidget); + _navigator(tester).pop(); + await tester.pumpAndSettle(); + expect(_navigator(tester).canPop(), isFalse); // it was a single route + }); + + testWidgets('live flip into route mode wires the re-show chain', ( + tester, + ) async { + final controller = ListDetailController(); + final mode = ValueNotifier(CompactDetailMode.overlay); + final tab = ValueNotifier(0); + addTearDown(mode.dispose); + addTearDown(tab.dispose); + + // Children built ONCE and reused: a tab switch then only repaints the + // re-shown child — like a kept-alive tab router — so the re-show must + // be woken by PAINT, not by a rebuild that happens to come along. + final tickets = ValueListenableBuilder( + valueListenable: mode, + builder: (context, m, _) => Material( + child: ListDetailLayout( + controller: controller, + compactDetailMode: m, + listBuilder: (context, selectedId, onSelect) => Column( + children: [ + for (final id in const ['a', 'b']) + TextButton( + onPressed: () => onSelect(id), + child: Text('item-$id'), + ), + ], + ), + detailBuilder: (context, id, detailMode, onDismiss) => Material( + child: Column( + children: [ + Text('detail-$id (${detailMode.name})'), + TextButton(onPressed: onDismiss, child: const Text('close')), + const Expanded(child: CounterPane()), + ], + ), + ), + emptyStateBuilder: (context) => const Text('empty'), + ), + ), + ); + const other = Material(child: Center(child: Text('other tab'))); + + await pumpApp( + tester, + ValueListenableBuilder( + valueListenable: tab, + builder: (context, index, _) => + IndexedStack(index: index, children: [tickets, other]), + ), + size: const Size(1000, 800), + ); + + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + expect(find.text('detail-a (sideBySide)'), findsOneWidget); + + // The user's step: flip the mode on the LIVE layout, at expanded. + mode.value = CompactDetailMode.route; + await tester.pumpAndSettle(); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + tab.value = 1; + await tester.pumpAndSettle(); + await resizeWindow(tester, const Size(500, 800)); + expect(_navigator(tester).canPop(), isFalse); // never over the other tab + + tab.value = 0; + await tester.pumpAndSettle(); + + expect(_navigator(tester).canPop(), isTrue); // routed, not inline + expect(find.text('detail-a (stacked)'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); // state survived + }); + + testWidgets('resize to compact while hidden: return pushes the route', ( + tester, + ) async { + final controller = ListDetailController(); + final tab = ValueNotifier(0); + addTearDown(tab.dispose); + await pumpApp( + tester, + ValueListenableBuilder( + valueListenable: tab, + builder: (context, index, _) => IndexedStack( + index: index, + children: [ + _layout(controller: controller), + const Material(child: Center(child: Text('other tab'))), + ], + ), + ), + size: const Size(1000, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + expect(find.text('detail-a (sideBySide)'), findsOneWidget); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + tab.value = 1; + await tester.pumpAndSettle(); + + // The breakpoint crossing happens while the tab is hidden. + await resizeWindow(tester, const Size(500, 800)); + expect(_navigator(tester).canPop(), isFalse); // never over the other tab + expect(find.text('other tab'), findsOneWidget); + + tab.value = 0; + // ONE frame: the repaint's post-frame flush must push the route right + // there (paintedThisFrame), not frames later via the deferred notifier + // — on an idle device those later frames never come. + await tester.pump(); + expect(_navigator(tester).canPop(), isTrue); // routed, not inline + + await tester.pump(); + expect(find.text('detail-a (stacked)'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); // state survived the trip + await tester.pumpAndSettle(); + }); + + testWidgets('hidden tab removes its route, keeps selection, re-shows', ( + tester, + ) async { + final controller = ListDetailController(); + final tab = ValueNotifier(0); + addTearDown(tab.dispose); + await pumpApp( + tester, + ValueListenableBuilder( + valueListenable: tab, + builder: (context, index, _) => IndexedStack( + index: index, + children: [ + _layout(controller: controller), + const Material(child: Center(child: Text('other tab'))), + ], + ), + ), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + expect(find.text('detail-a (stacked)'), findsOneWidget); + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + tab.value = 1; + await tester.pumpAndSettle(); + + expect(find.text('detail-a (stacked)'), findsNothing); + expect(_navigator(tester).canPop(), isFalse); + expect(controller.hasSelection, isTrue); // selection survives the hide + + tab.value = 0; + await tester.pumpAndSettle(); + + expect(find.text('detail-a (stacked)'), findsOneWidget); + expect(_navigator(tester).canPop(), isTrue); + // The bridge re-homed the detail while its route was suppressed — + // the element (and its state) never died. + expect(find.text('count: 2'), findsOneWidget); + }); +} diff --git a/test/core/list_detail/list_detail_semantics_test.dart b/test/core/list_detail/list_detail_semantics_test.dart new file mode 100644 index 0000000..d74085d --- /dev/null +++ b/test/core/list_detail/list_detail_semantics_test.dart @@ -0,0 +1,161 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +Widget _layout(CompactDetailMode mode, {Widget? detailExtra}) { + return Material( + child: ListDetailLayout( + compactDetailMode: mode, + listBuilder: (context, selectedId, onSelect) => Column( + children: [ + for (final id in const ['a', 'b']) + TextButton(onPressed: () => onSelect(id), child: Text('item-$id')), + ], + ), + detailBuilder: (context, id, mode, onDismiss) => Material( + child: Column( + children: [ + Text('detail-$id'), + TextButton(onPressed: onDismiss, child: const Text('close')), + if (detailExtra != null) detailExtra, + ], + ), + ), + emptyStateBuilder: (context) => const Text('empty'), + ), + ); +} + +SemanticsNode _rootNode(WidgetTester tester) { + // The view's pipeline owner holds semantics — reach it through any + // attached render object. + return tester + .renderObject(find.byType(Material).first) + .owner! + .semanticsOwner! + .rootSemanticsNode!; +} + +/// Every label reachable in the semantics tree. +Set _semanticLabels(WidgetTester tester) { + final labels = {}; + void walk(SemanticsNode node) { + if (node.label.isNotEmpty) labels.add(node.label); + node.visitChildren((child) { + walk(child); + return true; + }); + } + + walk(_rootNode(tester)); + return labels; +} + +void main() { + for (final mode in [CompactDetailMode.inline, CompactDetailMode.overlay]) { + testWidgets('${mode.name}: covered content leaves the semantics tree', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpApp(tester, _layout(mode), size: const Size(500, 800)); + + expect(_semanticLabels(tester), contains('item-a')); + + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + // Route parity: a screen reader must not traverse the list under + // the open detail. + final open = _semanticLabels(tester); + expect(open, contains('detail-a')); + expect(open, isNot(contains('item-a'))); + + await tester.tap(find.text('close')); + await tester.pumpAndSettle(); + expect(_semanticLabels(tester), contains('item-a')); + + handle.dispose(); + }); + + testWidgets('${mode.name}: the open detail scopes as a route', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpApp(tester, _layout(mode), size: const Size(500, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + bool scoped = false; + void walk(SemanticsNode node) { + if (node.flagsCollection.scopesRoute) scoped = true; + node.visitChildren((child) { + walk(child); + return true; + }); + } + + walk(_rootNode(tester)); + expect(scoped, isTrue); + + handle.dispose(); + }); + + testWidgets('${mode.name}: Escape dismisses when focus is in the detail', ( + tester, + ) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await pumpApp( + tester, + _layout( + mode, + detailExtra: Focus(focusNode: focus, child: const Text('focusable')), + ), + size: const Size(500, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + focus.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('detail-a'), findsNothing); + expect(find.text('item-a'), findsOneWidget); + }); + } + + testWidgets('overlay: content OUTSIDE the layout is blocked while open', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + // A bottom-nav-like sibling below the layout — overlay mode covers it + // visually, so it must leave the semantics tree too. + await pumpApp( + tester, + Column( + children: [ + Expanded(child: _layout(CompactDetailMode.overlay)), + const Material(child: Text('bottom-nav')), + ], + ), + size: const Size(500, 800), + ); + expect(_semanticLabels(tester), contains('bottom-nav')); + + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + expect(_semanticLabels(tester), isNot(contains('bottom-nav'))); + + await tester.tap(find.text('close')); + await tester.pumpAndSettle(); + expect(_semanticLabels(tester), contains('bottom-nav')); + + handle.dispose(); + }); +} diff --git a/test/core/modal/adaptive_modal_test.dart b/test/core/modal/adaptive_modal_test.dart new file mode 100644 index 0000000..646e7d7 --- /dev/null +++ b/test/core/modal/adaptive_modal_test.dart @@ -0,0 +1,285 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +/// Pumps an app with a button that opens an adaptive modal hosting a +/// [CounterPane]. Returns a record with the collected modes and results. +class _ModalHarness { + final List modes = []; + T? result() => _result as T?; + Object? _result; + bool completed = false; + + Widget build({ + ModalConfig config = const ModalConfig(), + double? expandedBreakpoint, + }) { + return Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + final value = await showAdaptiveModal( + context: context, + config: config, + expandedBreakpoint: expandedBreakpoint, + builder: (context, mode) { + modes.add(mode); + return const SizedBox( + width: 300, + height: 200, + child: CounterPane(), + ); + }, + ); + _result = value; + completed = true; + }, + child: const Text('open'), + ), + ), + ); + } +} + +Future _open(WidgetTester tester) async { + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + group('form selection', () { + testWidgets('wide window presents a real Dialog', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(harness.modes.last, ModalLayoutMode.dialog); + }); + + testWidgets('narrow window presents a real BottomSheet', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(500, 800)); + await _open(tester); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(harness.modes.last, ModalLayoutMode.sheet); + }); + + testWidgets('explicit breakpoint param wins', (tester) async { + final harness = _ModalHarness(); + await pumpApp( + tester, + harness.build(expandedBreakpoint: 1200), + size: const Size(1000, 800), + ); + await _open(tester); + + expect(find.byType(BottomSheet), findsOneWidget); + }); + + testWidgets('inherited AdaptiveLayoutConfig breakpoint applies', ( + tester, + ) async { + final harness = _ModalHarness(); + await pumpApp( + tester, + AdaptiveLayoutConfig(expandedBreakpoint: 1200, child: harness.build()), + size: const Size(1000, 800), + ); + await _open(tester); + + expect(find.byType(BottomSheet), findsOneWidget); + }); + }); + + group('live swap', () { + testWidgets('dialog → sheet on shrink, state preserved', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + expect(find.byType(Dialog), findsOneWidget); + + await tester.tap(find.byType(CounterPane)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + expect(find.text('count: 2'), findsOneWidget); + + await resizeWindow(tester, const Size(500, 800)); + + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.text('count: 2'), findsOneWidget); + expect(harness.modes.last, ModalLayoutMode.sheet); + }); + + testWidgets('sheet → dialog on grow, state preserved', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(500, 800)); + await _open(tester); + expect(find.byType(BottomSheet), findsOneWidget); + + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + await resizeWindow(tester, const Size(1000, 800)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('count: 1'), findsOneWidget); + }); + + testWidgets('round trip keeps state', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + await resizeWindow(tester, const Size(500, 800)); + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + await resizeWindow(tester, const Size(1000, 800)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); + }); + + testWidgets('swap does not complete the caller future', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + + await resizeWindow(tester, const Size(500, 800)); + + expect(harness.completed, isFalse); + }); + }); + + group('dismissal and results', () { + testWidgets('pop returns the value to the original caller', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + + Navigator.of(tester.element(find.byType(CounterPane))).pop('picked'); + await tester.pumpAndSettle(); + + expect(harness.completed, isTrue); + expect(harness.result(), 'picked'); + }); + + testWidgets('pop after a swap still returns the value', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + await resizeWindow(tester, const Size(500, 800)); + + Navigator.of(tester.element(find.byType(CounterPane))).pop('after-swap'); + await tester.pumpAndSettle(); + + expect(harness.result(), 'after-swap'); + }); + + testWidgets('barrier tap dismisses with null', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsNothing); + expect(harness.completed, isTrue); + expect(harness.result(), isNull); + }); + + testWidgets('barrierDismissible: false blocks barrier tap', (tester) async { + final harness = _ModalHarness(); + await pumpApp( + tester, + harness.build(config: const ModalConfig(barrierDismissible: false)), + size: const Size(1000, 800), + ); + await _open(tester); + + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + expect(harness.completed, isFalse); + }); + + testWidgets('sheet drags down to dismiss (Material physics)', ( + tester, + ) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(500, 800)); + await _open(tester); + + await tester.fling(find.byType(CounterPane), const Offset(0, 300), 1000); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + expect(harness.completed, isTrue); + }); + + testWidgets('enableDrag: false keeps the sheet up', (tester) async { + final harness = _ModalHarness(); + await pumpApp( + tester, + harness.build(config: const ModalConfig(enableDrag: false)), + size: const Size(500, 800), + ); + await _open(tester); + + await tester.fling(find.byType(CounterPane), const Offset(0, 300), 1000); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + }); + }); + + group('chrome forwarding', () { + testWidgets('showDragHandle: true shows Material drag handle', ( + tester, + ) async { + final harness = _ModalHarness(); + await pumpApp( + tester, + harness.build(config: const ModalConfig(showDragHandle: true)), + size: const Size(500, 800), + ); + await _open(tester); + + // Material's drag handle is a Semantics-labeled grab area inside + // the BottomSheet. + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(Semantics), + ), + findsWidgets, + ); + final sheet = tester.widget(find.byType(BottomSheet)); + expect(sheet.showDragHandle, isTrue); + }); + + testWidgets('builder mode reflects the presented form', (tester) async { + final harness = _ModalHarness(); + await pumpApp(tester, harness.build(), size: const Size(1000, 800)); + await _open(tester); + expect(harness.modes.last, ModalLayoutMode.dialog); + + await resizeWindow(tester, const Size(500, 800)); + expect(harness.modes.last, ModalLayoutMode.sheet); + + await resizeWindow(tester, const Size(1000, 800)); + expect(harness.modes.last, ModalLayoutMode.dialog); + }); + }); +} diff --git a/test/core/modal/modal_config_forwarding_test.dart b/test/core/modal/modal_config_forwarding_test.dart new file mode 100644 index 0000000..e2cd04d --- /dev/null +++ b/test/core/modal/modal_config_forwarding_test.dart @@ -0,0 +1,127 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +/// The native-passthrough contract: every `ModalConfig` field that mirrors +/// a parameter on Flutter's own routes arrives there verbatim. +void main() { + const config = ModalConfig( + barrierLabel: 'close the thing', + anchorPoint: Offset(7, 11), + traversalEdgeBehavior: TraversalEdgeBehavior.leaveFlutterView, + constraints: BoxConstraints(maxWidth: 333), + scrollControlDisabledMaxHeightRatio: 0.4, + isScrollControlled: false, + sheetAnimationStyle: AnimationStyle(duration: Duration(milliseconds: 5)), + ); + + Widget host() { + return Material( + child: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showAdaptiveModal( + context: context, + config: config, + builder: (context, mode) => const SizedBox( + width: 200, + height: 100, + child: Text('modal-content'), + ), + ), + child: const Text('open'), + ), + ), + ), + ); + } + + testWidgets('sheet form receives the native params verbatim', (tester) async { + await pumpApp(tester, host(), size: const Size(500, 800)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final route = + ModalRoute.of(tester.element(find.text('modal-content')))! + as ModalBottomSheetRoute; + expect(route.barrierLabel, 'close the thing'); + expect(route.anchorPoint, const Offset(7, 11)); + expect(route.constraints, const BoxConstraints(maxWidth: 333)); + expect(route.scrollControlDisabledMaxHeightRatio, 0.4); + expect(route.isScrollControlled, isFalse); + expect( + route.sheetAnimationStyle, + const AnimationStyle(duration: Duration(milliseconds: 5)), + ); + }); + + testWidgets('dialog form receives the native params verbatim', ( + tester, + ) async { + await pumpApp(tester, host(), size: const Size(1000, 800)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final route = + ModalRoute.of(tester.element(find.text('modal-content')))! + as DialogRoute; + expect(route.barrierLabel, 'close the thing'); + expect(route.anchorPoint, const Offset(7, 11)); + expect(route.traversalEdgeBehavior, TraversalEdgeBehavior.leaveFlutterView); + }); + + testWidgets('swap pushes stay instant even with a slow consumer style', ( + tester, + ) async { + await pumpApp( + tester, + Material( + child: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showAdaptiveModal( + context: context, + config: const ModalConfig( + morph: false, + animationStyle: AnimationStyle( + duration: Duration(seconds: 5), + ), + sheetAnimationStyle: AnimationStyle( + duration: Duration(seconds: 5), + ), + ), + builder: (context, mode) => const SizedBox( + width: 200, + height: 100, + child: Text('modal-content'), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + size: const Size(1000, 800), + ); + await tester.tap(find.text('open')); + // First entrance honors the consumer's 5s style — far from settled. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final entrance = ModalRoute.of(tester.element(find.text('modal-content')))!; + expect(entrance.animation!.status, AnimationStatus.forward); + await tester.pump(const Duration(seconds: 5)); + + // The breakpoint swap overrides it to zero — settled within frames, + // not seconds. + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + await tester.pump(); + await tester.pump(); + final swapped = ModalRoute.of(tester.element(find.text('modal-content')))!; + expect(swapped, isA>()); + expect(swapped.animation!.status, AnimationStatus.completed); + await tester.pumpAndSettle(); + }); +} diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart new file mode 100644 index 0000000..e4d577a --- /dev/null +++ b/test/core/modal/modal_morph_test.dart @@ -0,0 +1,439 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +Widget _opener({ModalConfig config = const ModalConfig()}) { + return Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => showAdaptiveModal( + context: context, + config: config, + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 300 : double.infinity, + height: 200, + child: const CounterPane(), + ), + ), + child: const Text('open'), + ), + ), + ); +} + +Future _open(WidgetTester tester) async { + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +/// Resizes and pumps just far enough for the swap to fire and the flight +/// to be mid-air — no settling. +Future _resizeIntoFlight(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); +} + +bool _contentIsInsideSheet(WidgetTester tester) => find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(CounterPane), + ) + .evaluate() + .isNotEmpty; + +void main() { + testWidgets('the swap flies: content leaves the routes, then lands', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + await _resizeIntoFlight(tester, const Size(500, 800)); + + // Mid-air: the destination sheet is up (with its placeholder), the + // live content is in the flight — visible, stateful, in neither route. + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(CounterPane), findsOneWidget); + expect(_contentIsInsideSheet(tester), isFalse); + expect(find.text('count: 1'), findsOneWidget); + + // Landing: content hands off into the sheet, flight gone, state kept. + await tester.pumpAndSettle(); + expect(_contentIsInsideSheet(tester), isTrue); + expect(find.text('count: 1'), findsOneWidget); + }); + + testWidgets('dismissing mid-flight lands the content and completes', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + await _resizeIntoFlight(tester, const Size(500, 800)); + expect(_contentIsInsideSheet(tester), isFalse); + + Navigator.of(tester.element(find.byType(CounterPane))).pop('mid-flight'); + await tester.pumpAndSettle(); + + expect(find.byType(CounterPane), findsNothing); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('crossing back mid-flight retargets and still keeps state', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + + await tester.tap(find.byType(CounterPane)); + await tester.pump(); + + await _resizeIntoFlight(tester, const Size(500, 800)); + await _resizeIntoFlight(tester, const Size(1000, 800)); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('count: 1'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('morph: false swaps as an instant cut', (tester) async { + await pumpApp( + tester, + _opener(config: const ModalConfig(morph: false)), + size: const Size(1000, 800), + ); + await _open(tester); + + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + await tester.pump(); + + // No flight: the content is already inside the sheet. + expect(_contentIsInsideSheet(tester), isTrue); + }); + + testWidgets('the sheet lands at its final width — no post-landing resize', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + await tester.pump(); + // Let the width handshake propagate (report → flight relayout → size + // back), as continuous frames would on a device... + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + // ...then jump near flight end: the destination sheet must already sit + // at its true final geometry — the placeholder is laid out at the + // width the sheet offers, not at the outgoing dialog's width. + await tester.pump(const Duration(milliseconds: 250)); + final sheetSurface = find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Material), + ) + .first; + final flightEndRect = tester.getRect(sheetSurface); + + await tester.pumpAndSettle(); + expect(tester.getRect(sheetSurface), flightEndRect); + expect(flightEndRect.width, 500); + }); + + testWidgets('both resting forms clip like the flight does', (tester) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + + final dialog = tester.widget(find.byType(Dialog)); + expect(dialog.clipBehavior, Clip.antiAlias); + + await resizeWindow(tester, const Size(500, 800)); + final sheet = tester.widget(find.byType(BottomSheet)); + expect(sheet.clipBehavior, Clip.antiAlias); + }); + + testWidgets('the destination is a ghost until the flight becomes it', ( + tester, + ) async { + await pumpApp( + tester, + _opener(config: const ModalConfig(showDragHandle: true)), + size: const Size(1000, 800), + ); + await _open(tester); + + await _resizeIntoFlight(tester, const Size(500, 800)); + + // Mid-flight the sheet route exists for layout and barrier, but shows + // nothing: transparent surface, no elevation, no handle. + final ghostSheet = tester.widget(find.byType(BottomSheet)); + expect(ghostSheet.backgroundColor, Colors.transparent); + expect(ghostSheet.elevation, 0); + expect(ghostSheet.showDragHandle, isFalse); + + // Landing replaces the ghost with the normally-chromed route. + await tester.pumpAndSettle(); + final realSheet = tester.widget(find.byType(BottomSheet)); + expect(realSheet.backgroundColor, isNot(Colors.transparent)); + expect(realSheet.showDragHandle, isTrue); + expect(_contentIsInsideSheet(tester), isTrue); + }); + + testWidgets('outbound dialog is a ghost too', (tester) async { + await pumpApp(tester, _opener(), size: const Size(500, 800)); + await _open(tester); + + await _resizeIntoFlight(tester, const Size(1000, 800)); + final ghostDialog = tester.widget(find.byType(Dialog)); + expect(ghostDialog.backgroundColor, Colors.transparent); + expect(ghostDialog.elevation, 0); + + await tester.pumpAndSettle(); + final realDialog = tester.widget(find.byType(Dialog)); + expect(realDialog.backgroundColor, isNull); + expect(find.text('count: 0'), findsOneWidget); + }); + + testWidgets('landing under a drag handle does not jump', (tester) async { + await pumpApp( + tester, + _opener(config: const ModalConfig(showDragHandle: true)), + size: const Size(1000, 800), + ); + await _open(tester); + + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + await tester.pump(); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pump(const Duration(milliseconds: 250)); + final flightEnd = tester.getRect(find.byType(CounterPane)); + + await tester.pumpAndSettle(); + final landed = tester.getRect(find.byType(CounterPane)); + + // The ghost's placeholder spacer stands in for the handle band, so + // the content lands exactly where the flight left it — a wrong spacer + // metric shows up here as a ~48px vertical jump. + expect(landed.top, closeTo(flightEnd.top, 2)); + expect(landed.left, closeTo(flightEnd.left, 2)); + expect(landed.width, closeTo(flightEnd.width, 2)); + }); + + testWidgets('content reflows with the container — no destination-width ' + 'chop', (tester) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + final dialogWidth = tester.getRect(find.byType(CounterPane)).width; + + await _resizeIntoFlight(tester, const Size(500, 800)); + + // Mid-flight the content is laid at the container's lerped width — + // strictly between the two forms. Jumping straight to the sheet's + // width would mean the narrower container is cropping it. + final midWidth = tester.getRect(find.byType(CounterPane)).width; + expect(midWidth, greaterThan(dialogWidth)); + expect(midWidth, lessThan(500)); + + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(CounterPane)).width, 500); + }); + + testWidgets('content shrinks with the container on sheet → dialog', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(500, 800)); + await _open(tester); + expect(tester.getRect(find.byType(CounterPane)).width, 500); + + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(); + // Let the natural-width sample + placeholder handshake propagate as + // continuous frames would on a device... + for (var i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pump(const Duration(milliseconds: 90)); + + // Mid-flight the content is laid tight at the container's lerped + // width — narrowing gradually from the sheet's last width (640: the + // M3 sheet max at the grown window) toward the dialog's 300. Jumping + // straight to 300 at takeoff is the bug this pins. + final midWidth = tester.getRect(find.byType(CounterPane)).width; + expect(midWidth, lessThan(640)); + expect(midWidth, greaterThan(300)); + + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(CounterPane)).width, 300); + }); + + testWidgets('backgroundColor knob colors both forms', (tester) async { + const knob = Color(0xFF123456); + await pumpApp( + tester, + _opener(config: const ModalConfig(backgroundColor: knob)), + size: const Size(1000, 800), + ); + await _open(tester); + expect(tester.widget(find.byType(Dialog)).backgroundColor, knob); + + await resizeWindow(tester, const Size(500, 800)); + expect( + tester.widget(find.byType(BottomSheet)).backgroundColor, + knob, + ); + }); + + testWidgets('fast back-and-forth still lands the sheet at full width', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + + // Flip narrow → wide → narrow while flights are mid-air: each + // retarget switches the target form, and the natural-width sample + // must be retaken for the new form's content — a stale sample makes + // the sheet land at the dialog's width, then snap wide. + await _resizeIntoFlight(tester, const Size(500, 800)); + await _resizeIntoFlight(tester, const Size(1000, 800)); + await _resizeIntoFlight(tester, const Size(500, 800)); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pump(const Duration(milliseconds: 250)); + final sheetSurface = find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Material), + ) + .first; + final flightEndRect = tester.getRect(sheetSurface); + + await tester.pumpAndSettle(); + expect(tester.getRect(sheetSurface), flightEndRect); + expect(flightEndRect.width, 500); + }); + + testWidgets('no narrow flash at takeoff — sampling never paints', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(500, 800)); + await _open(tester); + + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Immediately after takeoff the content must still be near the + // sheet's last width — a painted loose-sample frame shows up here as + // the dialog-form content flashing to its own 300. + expect(tester.getRect(find.byType(CounterPane)).width, greaterThan(500)); + + await tester.pumpAndSettle(); // land the flight; free its ticker + }); + + testWidgets('constraint-filling content survives a handled-sheet morph', ( + tester, + ) async { + // Tall scrollable content (like a settings panel) fills whatever + // height the slot offers. The ghost's handle band must reduce the + // content's available height the way the real BottomSheet does — + // stacking it on top instead overflows the slot by the band height. + await pumpApp( + tester, + Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => showAdaptiveModal( + context: context, + config: const ModalConfig(showDragHandle: true), + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 300 : double.infinity, + child: SingleChildScrollView( + child: Column( + children: [for (var i = 0; i < 60; i++) Text('row $i')], + ), + ), + ), + ), + child: const Text('open'), + ), + ), + ), + size: const Size(1000, 800), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await resizeWindow(tester, const Size(500, 800)); + + expect(tester.takeException(), isNull); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.text('row 0'), findsOneWidget); + + await resizeWindow(tester, const Size(1000, 800)); + expect(tester.takeException(), isNull); + expect(find.byType(Dialog), findsOneWidget); + }); + + testWidgets('the flight paints exactly like the chrome it becomes', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + await _resizeIntoFlight(tester, const Size(500, 800)); + + final flightMaterial = tester + .widgetList(find.byType(Material)) + .singleWhere( + (m) => m.clipBehavior == Clip.antiAlias && m.elevation > 0, + ); + // Material implicitly animates shape/elevation over ~200ms — the + // flight must opt out or its paint lags the lerp and pops at landing. + expect(flightMaterial.animationDuration, Duration.zero); + // M3 dialogs and sheets default to a transparent shadow; the flight + // must match or its shadow vanishes at the handoff. + expect(flightMaterial.shadowColor, Colors.transparent); + + await tester.pumpAndSettle(); // land the flight; free its ticker + }); + + testWidgets('flight surface morphs geometry between the forms', ( + tester, + ) async { + await pumpApp(tester, _opener(), size: const Size(1000, 800)); + await _open(tester); + + final dialogRect = tester.getRect(find.byType(CounterPane)); + + await _resizeIntoFlight(tester, const Size(500, 800)); + final midRect = tester.getRect(find.byType(CounterPane)); + + await tester.pumpAndSettle(); + final sheetRect = tester.getRect(find.byType(CounterPane)); + + // The sheet form is wider than the dialog form and sits lower; the + // mid-flight rect is between the two on both axes. + expect(sheetRect.width, greaterThan(dialogRect.width)); + expect(midRect.width, greaterThanOrEqualTo(dialogRect.width)); + expect(midRect.width, lessThanOrEqualTo(sheetRect.width)); + expect(midRect.top, greaterThanOrEqualTo(dialogRect.top)); + expect(midRect.top, lessThanOrEqualTo(sheetRect.top)); + }); +} diff --git a/test/core/shared/pane_anchor_test.dart b/test/core/shared/pane_anchor_test.dart index b368e7e..caa05f7 100644 --- a/test/core/shared/pane_anchor_test.dart +++ b/test/core/shared/pane_anchor_test.dart @@ -37,6 +37,20 @@ void main() { ); }); + test('equality holds for runtime instances (NaN sentinel)', () { + // Non-const: const canonicalization would short-circuit through + // `identical` and mask a NaN != NaN field comparison. + double n(double v) => v; + expect(PaneAnchor.fromStart(n(240)), PaneAnchor.fromStart(n(240))); + expect(PaneAnchor.fromEnd(n(240)), PaneAnchor.fromEnd(n(240))); + expect(PaneAnchor.proportion(n(0.5)), PaneAnchor.proportion(n(0.5))); + expect(PaneAnchor.fromStart(n(240)), isNot(PaneAnchor.fromStart(n(241)))); + expect( + PaneConfig(anchors: [PaneAnchor.fromStart(n(240))]), + PaneConfig(anchors: [PaneAnchor.fromStart(n(240))]), + ); + }); + test('listDetail preset spans collapsed to fully expanded', () { expect(PaneAnchor.listDetail, hasLength(5)); expect(PaneAnchor.listDetail.first.resolve(1000), 0); diff --git a/test/core/shared/pane_width_model_test.dart b/test/core/shared/pane_width_model_test.dart index e829187..7532044 100644 --- a/test/core/shared/pane_width_model_test.dart +++ b/test/core/shared/pane_width_model_test.dart @@ -166,4 +166,89 @@ void main() { expect(model.width(1000), closeTo(371.5, 0.001)); }); }); + + group('collapse', () { + PaneWidthModel model([PaneConfig? config]) => PaneWidthModel( + config ?? + const PaneConfig( + collapsible: PaneCollapsible.both, + defaultListWidth: 360, + ), + referenceWidth: 720, + ); + + test('force past min by half the min collapses the start pane', () { + final m = model(); + m.dragStart(1000); + m.drag(-450, 1000); // 500 → raw 50, min 200, threshold 100 + expect(m.collapsed, PaneSide.start); + expect(m.width(1000), 0); + }); + + test('short of the threshold stays pinned at min', () { + final m = model(); + m.dragStart(1000); + m.drag(-350, 1000); // raw 150 — past min, short of 100 floor + expect(m.collapsed, isNull); + expect(m.width(1000), 200); + }); + + test('dragging back out mid-gesture un-collapses live', () { + final m = model(); + m.dragStart(1000); + m.drag(-450, 1000); + expect(m.collapsed, PaneSide.start); + m.drag(300, 1000); // raw 350 — back above the threshold + expect(m.collapsed, isNull); + expect(m.width(1000), 350); + }); + + test('force past max collapses the end pane', () { + final m = model(); + m.dragStart(1000); + // max = 500; end-pane min = 500, threshold 250 → raw must exceed 750. + m.drag(200, 1000); // raw 700 + expect(m.collapsed, isNull); + m.drag(100, 1000); // raw 800 + expect(m.collapsed, PaneSide.end); + expect(m.width(1000), 1000); + }); + + test('collapsedSize keeps an icon-rail sliver', () { + final m = model( + const PaneConfig(collapsible: PaneCollapsible.start, collapsedSize: 48), + ); + m.dragStart(1000); + m.drag(-450, 1000); + expect(m.width(1000), 48); + }); + + test('restore returns to the cached pre-collapse width', () { + final m = model(); + m.dragStart(1000); + m.drag(-450, 1000); + m.dragEnd(); + expect(m.restoreTarget(1000), 500); // width at drag start + m.restore(1000); + expect(m.collapsed, isNull); + expect(m.width(1000), 500); + }); + + test('collapsible none keeps the hard stop', () { + final m = model(const PaneConfig()); + m.dragStart(1000); + m.drag(-900, 1000); + expect(m.collapsed, isNull); + expect(m.width(1000), 200); + }); + + test('programmatic collapse respects the config', () { + final m = model(const PaneConfig(collapsible: PaneCollapsible.start)); + m.collapse(PaneSide.end, 1000); + expect(m.collapsed, isNull); // end not allowed + m.collapse(PaneSide.start, 1000); + expect(m.collapsed, PaneSide.start); + expect(m.restoreTarget(1000), 500); + }); + }); } diff --git a/test/core/split/adaptive_split_test.dart b/test/core/split/split_layout_test.dart similarity index 56% rename from test/core/split/adaptive_split_test.dart rename to test/core/split/split_layout_test.dart index 36f8ccd..241a117 100644 --- a/test/core/split/adaptive_split_test.dart +++ b/test/core/split/split_layout_test.dart @@ -1,5 +1,6 @@ import 'package:adaptive_layouts/adaptive_layouts.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers.dart'; @@ -11,7 +12,7 @@ void main() { PaneConfig paneConfig = const PaneConfig(), DividerBuilder? dividerBuilder, }) { - return AdaptiveSplit( + return SplitLayout( primaryPosition: primaryPosition, compactBehavior: compactBehavior, paneConfig: paneConfig, @@ -47,6 +48,32 @@ void main() { expect(find.text('primary exp'), findsOneWidget); }); + testWidgets('widthMemory.resetOnReentry forgets the drag on re-entry', ( + tester, + ) async { + await pumpApp( + tester, + buildSplit( + paneConfig: const PaneConfig( + widthMemory: PaneWidthMemory.resetOnReentry, + ), + ), + size: expanded, + ); + final before = tester.getRect(find.byKey(const Key('primary'))).width; + await tester.dragFrom(Offset(before, 400), const Offset(-100, 0)); + await tester.pumpAndSettle(); + expect( + tester.getRect(find.byKey(const Key('primary'))).width, + isNot(before), + ); + + await resizeWindow(tester, compact); + await resizeWindow(tester, expanded); + + expect(tester.getRect(find.byKey(const Key('primary'))).width, before); + }); + testWidgets('divider drag resizes the primary pane', (tester) async { await pumpApp(tester, buildSplit(), size: expanded); final before = tester.getRect(find.byKey(const Key('primary'))).width; @@ -125,6 +152,103 @@ void main() { }); }); + group('directional collapse (primary at end)', () { + testWidgets('collapsing the directional end pane collapses the primary', ( + tester, + ) async { + await pumpApp( + tester, + buildSplit( + primaryPosition: SplitPrimaryPosition.end, + paneConfig: const PaneConfig(collapsible: PaneCollapsible.end), + ), + size: expanded, + ); + // Primary sits at the directional end at the model width. + final before = tester.getSize(find.byKey(const Key('primary'))).width; + + // Enter on the focused divider collapses the allowed (end) side — + // which IS the primary here, despite being model-space start. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect( + tester.getSize(find.byKey(const Key('primary'))).width, + const PaneConfig().minListWidth, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(tester.getSize(find.byKey(const Key('primary'))).width, before); + }); + + testWidgets('DividerState reports the collapsed side directionally', ( + tester, + ) async { + final states = []; + await pumpApp( + tester, + buildSplit( + primaryPosition: SplitPrimaryPosition.end, + paneConfig: const PaneConfig(collapsible: PaneCollapsible.end), + dividerBuilder: (context, state) { + states.add(state); + return const SizedBox.expand(); + }, + ), + size: expanded, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + // The model collapsed its start (the primary), but consumers see + // the DIRECTIONAL side: end. + expect(states.last.collapsed, PaneSide.end); + }); + + testWidgets('PaneScope actions speak directional sides', (tester) async { + await pumpApp( + tester, + SplitLayout( + primaryPosition: SplitPrimaryPosition.end, + paneConfig: const PaneConfig(collapsible: PaneCollapsible.end), + primaryBuilder: (context, isExpanded) => + const ColoredBox(key: Key('primary'), color: Color(0xFF111111)), + secondaryBuilder: (context, isExpanded) => Center( + child: Builder( + builder: (context) { + final scope = PaneScope.of(context); + return IconButton( + key: const Key('toggle'), + icon: const Icon(Icons.menu), + onPressed: scope.collapsed == null + ? () => scope.collapse(PaneSide.end) + : scope.restore, + ); + }, + ), + ), + ), + size: expanded, + ); + final before = tester.getSize(find.byKey(const Key('primary'))).width; + + await tester.tap(find.byKey(const Key('toggle'))); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const Key('primary'))).width, + const PaneConfig().minListWidth, + ); + + await tester.tap(find.byKey(const Key('toggle'))); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('primary'))).width, before); + }); + }); + group('compact layout', () { testWidgets('stack behavior shows both panes vertically', (tester) async { await pumpApp(tester, buildSplit(), size: compact);