From 705f6d7e3467c4bfc4484eaa6681188d649c88e8 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 16:24:17 +0530 Subject: [PATCH 01/41] docs: frame the package as a growing family of adaptive layouts --- README.md | 2 +- docs/ARCHITECTURE.md | 5 +++-- pubspec.yaml | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d4ecf51..935ce10 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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. -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. +Two widgets carry the package today: `ListDetailLayout` (list + selected detail, the messaging-app shape) and `AdaptiveSplit` (two always-present panes, the player shape). Every layout that joins them follows the same rules: router-agnostic, state-management-agnostic, and a plain `ChangeNotifier` controller as the whole integration surface. > **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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ba0018b..3e31df6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,8 +5,9 @@ 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). diff --git a/pubspec.yaml b/pubspec.yaml index 79d9ae6..8db35d1 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, slide-over details, draggable anchored + dividers, 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 From ed4d13ef4c27d04d7f1cfbdea044a20f49d3566f Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 18:16:09 +0530 Subject: [PATCH 02/41] =?UTF-8?q?feat:=20adaptive=20modal=20=E2=80=94=20re?= =?UTF-8?q?al=20Material=20routes=20with=20a=20live=20dialog-sheet=20swap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + CHANGELOG.pre.md | 1 + README.md | 29 +- docs/ARCHITECTURE.md | 22 ++ docs/CAPABILITY_ROADMAP.md | 13 + docs/UPDATING.md | 35 +- example/lib/main.dart | 421 ++-------------------- example/lib/main.gr.dart | 68 ---- example/test/journeys/journeys_test.dart | 33 ++ lib/adaptive_layouts.dart | 3 + lib/src/core/modal/adaptive_modal.dart | 206 +++++++++++ lib/src/core/modal/modal_config.dart | 54 +++ lib/src/core/modal/modal_layout_mode.dart | 10 + pubspec.yaml | 4 +- test/core/modal/adaptive_modal_test.dart | 285 +++++++++++++++ 15 files changed, 713 insertions(+), 472 deletions(-) create mode 100644 lib/src/core/modal/adaptive_modal.dart create mode 100644 lib/src/core/modal/modal_config.dart create mode 100644 lib/src/core/modal/modal_layout_mode.dart create mode 100644 test/core/modal/adaptive_modal_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ce42100..e34440a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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 live-swaps between the two real routes on resize, preserving content 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. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/CHANGELOG.pre.md b/CHANGELOG.pre.md index 95368d9..688e12a 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -66,6 +66,7 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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. - **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 live-swaps between the two real routes on resize, preserving content 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. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/README.md b/README.md index 935ce10..1d3da69 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ 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. -Two widgets carry the package today: `ListDetailLayout` (list + selected detail, the messaging-app shape) and `AdaptiveSplit` (two always-present panes, the player shape). Every layout that joins them follows the same rules: router-agnostic, state-management-agnostic, and a plain `ChangeNotifier` controller as the whole integration surface. +Three layouts carry the package today: `ListDetailLayout` (list + selected detail, the messaging-app shape), `AdaptiveSplit` (two always-present panes, the player shape), and `showAdaptiveModal` (a real Material dialog on wide windows that is a real Material bottom sheet on narrow ones). Every layout that joins them follows the same rules: router-agnostic, state-management-agnostic, and the smallest possible integration surface — a plain `ChangeNotifier` controller, or just an awaited future. -> **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. +> **The guarantee that makes this package exist:** pane and modal content *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. @@ -30,6 +30,7 @@ Two widgets carry the package today: `ListDetailLayout` (list + selected detail, - [Covering the bottom nav (overlay mode)](#covering-the-bottom-nav-overlay-mode) - [Sizing the panes](#sizing-the-panes) - [Two panes without a selection](#two-panes-without-a-selection) + - [A modal that swaps between dialog and bottom sheet](#a-modal-that-swaps-between-dialog-and-bottom-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) @@ -178,6 +179,23 @@ AdaptiveSplit( 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. +### A modal that swaps between dialog and bottom sheet + +`showAdaptiveModal` presents a real Material dialog on wide windows and a real Material bottom sheet on narrow ones — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply. Resize across the breakpoint while it is open and the form swaps live, keeping the content widget's state — a half-typed form field survives. + +```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(), + ), +); +``` + +The returned future completes with the pop result no matter how many form swaps happened while the modal was open. 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. + ### One breakpoint for the whole app 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. @@ -209,6 +227,13 @@ An `OverlayPortal` paints in the Overlay, outside its parent — so a parent tha +
+🧩 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 (`DialogRoute`, `ModalBottomSheetRoute`), so theming, drag-to-dismiss 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 example app diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e31df6..b7f1e36 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,6 +34,10 @@ lib/ │ ├── 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 ├── shared/ │ ├── adaptive_layout_config.dart # inherited breakpoint config │ ├── divider_builder.dart # shared DividerBuilder typedef @@ -79,6 +83,18 @@ 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` only forwards parameters to the two routes. + ### `ListDetailController` `ChangeNotifier`, `ScrollController`-style: omit it and the widget creates @@ -158,6 +174,11 @@ 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`, @@ -199,6 +220,7 @@ slide direction, swipe-dismiss direction, and divider drag all flip. | 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. | +| 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..f8e0b12 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -23,6 +23,7 @@ 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 (real push/pop, preserved instance) | PLANNED | Reuses the modal's route-swap primitive; restores predictive back + platform transitions; the hard part is the pop-direction handoff (predictive-back cancel) | | Empty state builder (expanded, no selection) | DONE | Nullable; `IconMessageEmpty` shipped as a convenience | | RTL support | DONE | Slide, swipe, and divider drag are direction-aware | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | @@ -36,6 +37,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 | barrier, safe area, scroll control, drag, drag handle | +| Animated cross-form morph | WONT_DO | A route swap is a cut by design; real route semantics were chosen over a custom animatable chrome | + ## Pane system (shared) | Capability | Status | Notes | diff --git a/docs/UPDATING.md b/docs/UPDATING.md index d0f0a36..12f582f 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -64,7 +64,28 @@ 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. + +## §4 — 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` @@ -77,7 +98,7 @@ JUMPS to 1.0 (no re-animation) — the detail was already visible. (idle / dragging / settling for dividers). 6. Row in `CAPABILITY_ROADMAP.md`. -## §4 — Adding a config field +## §5 — Adding a config field 1. Add the field to the right pure-data class (`PaneConfig`, `CompactConfig`) with a default that preserves current behavior. @@ -91,7 +112,7 @@ JUMPS to 1.0 (no re-animation) — the detail was already visible. lie — this package once shipped `anchors` / `resizeMode` / `isSettling` unimplemented; they're real now. Don't regress the standard. -## §5 — Adding a layout widget +## §6 — Adding a layout widget 1. New folder under `src/core//` (peer of `list_detail/`, `split/`). 2. Reuse the shared vocabulary: `AdaptiveLayoutConfig.resolveBreakpoint`, @@ -102,10 +123,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 +## §7 — 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. diff --git a/example/lib/main.dart b/example/lib/main.dart index 3f421c1..0003acd 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 @@ -24,7 +24,7 @@ // 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 +33,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'; @@ -66,7 +65,7 @@ class _ExampleAppState extends State { brightness: Brightness.dark, ), ), - routerConfig: _router.config(deepLinkBuilder: handleDeepLink), + routerConfig: _router.config(), ); } } @@ -393,8 +392,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 +465,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,16 +503,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( @@ -716,370 +701,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 // @@ -2217,7 +1838,19 @@ class TicketsTabScreen extends StatelessWidget { selectedId: selectedId, onSelect: onSelect, newLabel: 'New ticket', - onNew: () => context.router.push(const NewTicketRoute()), + onNew: () async { + final id = await showAdaptiveModal( + context: context, + config: const ModalConfig(showDragHandle: true), + 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) => @@ -2309,8 +1942,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: const ModalConfig(showDragHandle: true), + builder: (context, mode) => SizedBox( + width: mode == ModalLayoutMode.dialog ? 420 : double.infinity, + child: TicketOptionsScreen(ticketId: widget.ticketId), + ), ), ), if (widget.mode == DetailLayoutMode.sideBySide) @@ -2447,7 +2085,6 @@ class WorkPrefsTabScreen extends StatelessWidget { // ── Work modals ── -@RoutePage() class NewTicketScreen extends StatefulWidget { const NewTicketScreen({super.key}); @@ -2477,9 +2114,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 +2157,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; 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..e947676 100644 --- a/example/test/journeys/journeys_test.dart +++ b/example/test/journeys/journeys_test.dart @@ -192,5 +192,38 @@ 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-'), + ); + }); }); } diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 4ddb483..a242718 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -24,6 +24,9 @@ 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/compact_config.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/adaptive_split.dart'; // Core — shared configuration + vocabulary diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart new file mode 100644 index 0000000..3039bd6 --- /dev/null +++ b/lib/src/core/modal/adaptive_modal.dart @@ -0,0 +1,206 @@ +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/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; + bool _swapScheduled = 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, + }) { + final route = _buildRoute(mode, animate: animate); + _active = route; + _activeMode = mode; + 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) { + _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; + _push(target, animate: false, removeFirst: active); + }); + } + + Route _buildRoute(ModalLayoutMode mode, {required bool animate}) { + // Swap pushes skip the entrance animation (the modal is already + // visually present); exits keep Material's timing either way. + 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, + useSafeArea: config.useSafeArea, + settings: settings, + animationStyle: style, + ); + case ModalLayoutMode.sheet: + return ModalBottomSheetRoute( + builder: (context) => _ModalScope(session: this, mode: mode), + capturedThemes: themes, + isScrollControlled: config.isScrollControlled, + modalBarrierColor: config.barrierColor, + isDismissible: config.barrierDismissible, + enableDrag: config.enableDrag, + showDragHandle: config.showDragHandle, + settings: settings, + sheetAnimationStyle: style, + ); + } + } +} + +/// 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(); + } + final content = KeyedSubtree( + key: session._contentKey, + child: session.builder(context, mode), + ); + return mode == ModalLayoutMode.dialog ? Dialog(child: content) : 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..47c979e --- /dev/null +++ b/lib/src/core/modal/modal_config.dart @@ -0,0 +1,54 @@ +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. These fields only forward to the +/// matching parameters on those routes. +/// +/// ```dart +/// showAdaptiveModal( +/// context: context, +/// config: const ModalConfig(showDragHandle: true), +/// builder: (context, mode) => MyModalContent(), +/// ) +/// ``` +class ModalConfig { + /// Creates a modal configuration. + const ModalConfig({ + this.barrierDismissible = true, + this.barrierColor, + this.useSafeArea = true, + this.isScrollControlled = true, + this.enableDrag = true, + this.showDragHandle, + }); + + /// 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; +} 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/pubspec.yaml b/pubspec.yaml index 8db35d1..75a92b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,8 +2,8 @@ name: adaptive_layouts version: 0.0.0 description: >- Adaptive layout widgets that morph between phone and desktop forms — - list-detail, split panes, slide-over details, draggable anchored - dividers, and pane state that survives window resizes. + 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/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); + }); + }); +} From 5ff4960ca593408742fda3791b670229826064fd Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 18:38:36 +0530 Subject: [PATCH 03/41] =?UTF-8?q?feat:=20container=20transform=20for=20the?= =?UTF-8?q?=20modal=20=E2=80=94=20the=20flight=20carries=20the=20live=20co?= =?UTF-8?q?ntent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- CHANGELOG.pre.md | 2 +- README.md | 4 +- docs/ARCHITECTURE.md | 17 +- docs/CAPABILITY_ROADMAP.md | 2 +- docs/UPDATING.md | 17 ++ lib/src/core/modal/adaptive_modal.dart | 110 +++++++++- lib/src/core/modal/modal_config.dart | 19 +- lib/src/core/modal/modal_morph.dart | 287 +++++++++++++++++++++++++ test/core/modal/modal_morph_test.dart | 146 +++++++++++++ 10 files changed, 595 insertions(+), 11 deletions(-) create mode 100644 lib/src/core/modal/modal_morph.dart create mode 100644 test/core/modal/modal_morph_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e34440a..fe977e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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 live-swaps between the two real routes on resize, preserving content state and the awaited result. +- **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. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/CHANGELOG.pre.md b/CHANGELOG.pre.md index 688e12a..effe231 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -66,7 +66,7 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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. - **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 live-swaps between the two real routes on resize, preserving content state and the awaited result. +- **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. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/README.md b/README.md index 1d3da69..b609d9b 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Wide: side by side with the same draggable divider (primary at the start or end ### A modal that swaps between dialog and bottom sheet -`showAdaptiveModal` presents a real Material dialog on wide windows and a real Material bottom sheet on narrow ones — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply. Resize across the breakpoint while it is open and the form swaps live, keeping the content widget's state — a half-typed form field survives. +`showAdaptiveModal` presents a real Material dialog on wide windows and a real Material bottom sheet on narrow ones — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply. 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, and a half-typed form field survives the trip. `ModalConfig(morph: false)` swaps instantly instead. ```dart final choice = await showAdaptiveModal( @@ -232,6 +232,8 @@ An `OverlayPortal` paints in the Overlay, outside its parent — so a parent tha The pane layouts own their navigation feel in exchange for the state guarantee. The modal gets both: each form is Flutter's own route (`DialogRoute`, `ModalBottomSheetRoute`), so theming, drag-to-dismiss 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 animation is Material's container-transform pattern with one upgrade Material itself doesn't have: `Hero` and `OpenContainer` both rebuild the content they animate, while this flight carries the *live element* — the surface lerps rect, shape, color, and elevation between the two forms with your widget's state intact inside it. The destination route lays out a same-size placeholder whose live rect steers the landing each frame, so keyboard insets and content reflow are tracked automatically. + --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7f1e36..de8df48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,8 @@ lib/ ├── modal/ │ ├── adaptive_modal.dart # showAdaptiveModal + route-swap session │ ├── modal_config.dart # forwards to the Material routes - │ └── modal_layout_mode.dart # dialog / sheet + │ ├── 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 @@ -93,7 +94,19 @@ 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` only forwards parameters to the two routes. +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, and +elevation from the outgoing form's geometry to the destination's. The +destination route lays out a same-size placeholder whose LIVE rect is the +flight's landing target, tracked every frame; the flight reports the +content's laid-out size back to the placeholder, so at landing all three +agree and the handoff is pixel-clean. 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` diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index f8e0b12..cb1db62 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -47,7 +47,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | barrier, safe area, scroll control, drag, drag handle | -| Animated cross-form morph | WONT_DO | A route swap is a cut by design; real route semantics were chosen over a custom animatable chrome | +| 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) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 12f582f..e2ddf37 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -85,6 +85,23 @@ crosses the breakpoint. The swap machinery in 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. + ## §4 — Adding a component (divider / empty state) 1. Create the file under `src/components/{dividers|empty_states}/`. diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 3039bd6..2b89e77 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -4,6 +4,7 @@ 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. @@ -97,6 +98,13 @@ class _ModalSession { ModalLayoutMode? _activeMode; 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; @@ -124,6 +132,9 @@ class _ModalSession { // 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); } }), @@ -147,10 +158,87 @@ class _ModalSession { // the frame the swap waited on. final target = _modeFor(MediaQuery.sizeOf(navigator.context).width); if (target == _activeMode) return; + if (config.morph) _beginOrRetargetFlight(target); _push(target, animate: false, removeFirst: active); }); } + /// 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. + /// Bails silently to the instant cut when geometry can't be measured. + void _beginOrRetargetFlight(ModalLayoutMode target) { + final overlay = navigator.overlay; + if (overlay == null) return; + final theme = Theme.of(navigator.context); + final end = ModalFormVisuals.of(theme, target); + + final flight = _flight; + if (flight != null) { + flight.retarget(end: end, mode: target); + return; + } + + final outgoingMode = _activeMode; + final box = _contentKey.currentContext?.findRenderObject(); + final overlayBox = overlay.context.findRenderObject(); + if (outgoingMode == null) return; + if (box is! RenderBox || overlayBox is! RenderBox) return; + if (!box.attached || !box.hasSize) return; + final startRect = + box.localToGlobal(Offset.zero, ancestor: overlayBox) & box.size; + + _flight = ModalMorphFlight( + overlay: overlay, + startRect: startRect, + start: ModalFormVisuals.of(theme, outgoingMode), + end: end, + duration: config.morphDuration, + curve: config.morphCurve, + targetMode: target, + // 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(); + }); + }, + ); + _morphing.value = true; + _flight!.insert(); + } + + /// 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. + void _endFlight() { + final flight = _flight; + if (flight == null) return; + _morphing.value = false; + _flight = null; + flight.dispose(); + } + Route _buildRoute(ModalLayoutMode mode, {required bool animate}) { // Swap pushes skip the entrance animation (the modal is already // visually present); exits keep Material's timing either way. @@ -197,9 +285,25 @@ class _ModalScope extends StatelessWidget { if (session._modeFor(MediaQuery.sizeOf(context).width) != mode) { session._requestSwap(); } - final content = KeyedSubtree( - key: session._contentKey, - child: session.builder(context, mode), + // 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. + final content = ValueListenableBuilder( + valueListenable: session._morphing, + builder: (context, morphing, _) { + final flight = session._flight; + if (morphing && flight != null) { + return ValueListenableBuilder( + valueListenable: flight.contentSize, + builder: (context, size, _) => + SizedBox.fromSize(key: flight.placeholderKey, size: size), + ); + } + return KeyedSubtree( + key: session._contentKey, + child: session.builder(context, mode), + ); + }, ); return mode == ModalLayoutMode.dialog ? Dialog(child: content) : content; } diff --git a/lib/src/core/modal/modal_config.dart b/lib/src/core/modal/modal_config.dart index 47c979e..f797853 100644 --- a/lib/src/core/modal/modal_config.dart +++ b/lib/src/core/modal/modal_config.dart @@ -4,8 +4,9 @@ import 'package:flutter/widgets.dart'; /// /// Deliberately thin: the modal is presented by Flutter's own `DialogRoute` /// and `ModalBottomSheetRoute`, so chrome, theming, drag physics, and -/// accessibility all come from Material. These fields only forward to the -/// matching parameters on those routes. +/// 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( @@ -23,6 +24,9 @@ class ModalConfig { this.isScrollControlled = true, this.enableDrag = true, this.showDragHandle, + this.morph = true, + this.morphDuration = const Duration(milliseconds: 350), + this.morphCurve = Curves.easeInOutCubicEmphasized, }); /// Whether tapping the barrier dismisses the modal. Both forms. @@ -51,4 +55,15 @@ class ModalConfig { /// Forwards to `ModalBottomSheetRoute.showDragHandle`; /// `null` defers to `BottomSheetThemeData.showDragHandle`. final bool? showDragHandle; + + /// 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_morph.dart b/lib/src/core/modal/modal_morph.dart new file mode 100644 index 0000000..74c3400 --- /dev/null +++ b/lib/src/core/modal/modal_morph.dart @@ -0,0 +1,287 @@ +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, + }); + + /// 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) { + switch (mode) { + case ModalLayoutMode.dialog: + return ModalFormVisuals( + shape: + theme.dialogTheme.shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + color: + theme.dialogTheme.backgroundColor ?? + theme.colorScheme.surfaceContainerHigh, + elevation: theme.dialogTheme.elevation ?? 6, + ); + case ModalLayoutMode.sheet: + return ModalFormVisuals( + shape: + theme.bottomSheetTheme.shape ?? + const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + color: + theme.bottomSheetTheme.modalBackgroundColor ?? + theme.bottomSheetTheme.backgroundColor ?? + theme.colorScheme.surfaceContainerLow, + elevation: + theme.bottomSheetTheme.modalElevation ?? + theme.bottomSheetTheme.elevation ?? + 1, + ); + } + } + + /// Surface shape (all-corner radius for dialogs, top radius for sheets). + final ShapeBorder shape; + + /// Surface color. + final Color color; + + /// Surface elevation. + final double elevation; + + /// 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, + ); + } +} + +/// 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 feedback loop that makes the landing seamless: the flight lays the +/// content out at the lerped width and reports its size to [contentSize]; +/// the destination's placeholder adopts that size; the placeholder's +/// global rect is the flight target. At `t = 1` all three agree by +/// construction. +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, + }) : _startRect = startRect, + _start = start, + _end = end, + 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; + + /// 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; + + late final AnimationController _controller; + Rect _startRect; + ModalFormVisuals _start; + ModalFormVisuals _end; + 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, + }) { + _startRect = currentRect(); + _start = ModalFormVisuals.lerp(_start, _end, _t); + _end = end; + targetMode = mode; + _lastTargetRect = null; + 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 visuals = ModalFormVisuals.lerp(_start, _end, _t); + return Stack( + children: [ + Positioned.fromRect( + rect: rect, + child: IgnorePointer( + child: Material( + clipBehavior: Clip.antiAlias, + shape: visuals.shape, + color: visuals.color, + elevation: visuals.elevation, + child: OverflowBox( + alignment: Alignment.topCenter, + minWidth: 0, + maxWidth: rect.width, + minHeight: 0, + maxHeight: maxHeight, + child: _MeasureSize( + onSize: (size) { + if (contentSize.value != size) contentSize.value = size; + }, + child: contentBuilder(context), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// 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). +class _MeasureSize extends SingleChildRenderObjectWidget { + const _MeasureSize({required this.onSize, super.child}); + + final ValueChanged onSize; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderMeasureSize(onSize); + + @override + void updateRenderObject( + BuildContext context, + _RenderMeasureSize renderObject, + ) { + renderObject.onSize = onSize; + } +} + +class _RenderMeasureSize extends RenderProxyBox { + _RenderMeasureSize(this.onSize); + + ValueChanged onSize; + Size? _lastReported; + + @override + void performLayout() { + super.performLayout(); + if (size == _lastReported) return; + _lastReported = size; + final reported = size; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (attached) onSize(reported); + }); + } +} diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart new file mode 100644 index 0000000..4c89adc --- /dev/null +++ b/test/core/modal/modal_morph_test.dart @@ -0,0 +1,146 @@ +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('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)); + }); +} From 11256a7df4f99cd57059260496d66bdedbd6c5e2 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 19:02:07 +0530 Subject: [PATCH 04/41] fix: land the morph on true final geometry and clip both resting forms --- docs/ARCHITECTURE.md | 9 ++++-- docs/UPDATING.md | 9 ++++++ lib/src/core/modal/adaptive_modal.dart | 24 +++++++++++--- lib/src/core/modal/modal_morph.dart | 29 +++++++++++++---- test/core/modal/modal_morph_test.dart | 43 ++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de8df48..c4f25bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,9 +101,12 @@ the pattern): during the swap the live content mounts in a flight — an overlay entry above the routes — whose surface lerps rect, shape, color, and elevation from the outgoing form's geometry to the destination's. The destination route lays out a same-size placeholder whose LIVE rect is the -flight's landing target, tracked every frame; the flight reports the -content's laid-out size back to the placeholder, so at landing all three -agree and the handoff is pixel-clean. Dismissal mid-flight lands the content +flight's landing target, tracked every frame. The sizing handshake sends +one quantity each way: the placeholder reports the width its slot OFFERS, +the flight lays the content out at that width and reports the resulting +size back — so the destination reaches its true final geometry within a few +frames 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. diff --git a/docs/UPDATING.md b/docs/UPDATING.md index e2ddf37..404c278 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -101,6 +101,15 @@ With the container transform (`ModalConfig.morph`, default on), three more: 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. **The width handshake flows one quantity each way.** The placeholder + reports the width its slot OFFERS (`reportDestinationMaxWidth`); the + flight lays the content out at that width and reports the SIZE back. + Laying the content out at the lerped container width instead is + circular, and the circle's fixed point is wrong — the sheet wraps to + the outgoing dialog's width, then snaps wide after landing. 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. ## §4 — Adding a component (divider / empty state) diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 2b89e77..93490b9 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -264,6 +264,7 @@ class _ModalSession { isDismissible: config.barrierDismissible, enableDrag: config.enableDrag, showDragHandle: config.showDragHandle, + clipBehavior: Clip.antiAlias, settings: settings, sheetAnimationStyle: style, ); @@ -293,10 +294,18 @@ class _ModalScope extends StatelessWidget { builder: (context, morphing, _) { final flight = session._flight; if (morphing && flight != null) { - return ValueListenableBuilder( - valueListenable: flight.contentSize, - builder: (context, size, _) => - SizedBox.fromSize(key: flight.placeholderKey, size: size), + // The LayoutBuilder reports the width this slot OFFERS, so the + // flight can lay the content out at its true final width — the + // placeholder then mirrors the resulting size back. + return LayoutBuilder( + builder: (context, constraints) { + flight.reportDestinationMaxWidth(constraints.maxWidth); + return ValueListenableBuilder( + valueListenable: flight.contentSize, + builder: (context, size, _) => + SizedBox.fromSize(key: flight.placeholderKey, size: size), + ); + }, ); } return KeyedSubtree( @@ -305,6 +314,11 @@ class _ModalScope extends StatelessWidget { ); }, ); - return mode == ModalLayoutMode.dialog ? Dialog(child: content) : 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. + return mode == ModalLayoutMode.dialog + ? Dialog(clipBehavior: Clip.antiAlias, child: content) + : content; } } diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index 74c3400..f926bbe 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -83,11 +83,13 @@ class ModalFormVisuals { /// motion, and content reflow all steer the landing. On arrival the /// content reparents into the route and the flight disposes. /// -/// The feedback loop that makes the landing seamless: the flight lays the -/// content out at the lerped width and reports its size to [contentSize]; -/// the destination's placeholder adopts that size; the placeholder's -/// global rect is the flight target. At `t = 1` all three agree by -/// construction. +/// The sizing handshake that makes the landing seamless — one quantity +/// flows each way, so there is no feedback loop: the destination reports +/// the width it OFFERS (the placeholder's incoming constraints, via +/// [reportDestinationMaxWidth]); the flight lays the content out at that +/// width from the first frame and reports the resulting size to +/// [contentSize]; the placeholder adopts that size, and its global rect — +/// the true final geometry — is the flight target throughout. class ModalMorphFlight { /// Starts a flight. The controller runs immediately. ModalMorphFlight({ @@ -137,6 +139,20 @@ class ModalMorphFlight { /// behind layout. The destination placeholder mirrors it. final ValueNotifier contentSize; + /// The width the destination offers its content slot, reported from the + /// placeholder's incoming constraints. The flight lays the content out + /// at this width so the reported [contentSize] is the true final size — + /// laying it out at the lerped container width instead is circular, and + /// the circle has a wrong fixed point (a sheet that wraps to the + /// dialog's width, then snaps wide after landing). + double? _destinationMaxWidth; + + /// Called from the placeholder's `LayoutBuilder` during layout — plain + /// field write, read on the controller's next tick. + void reportDestinationMaxWidth(double width) { + if (width.isFinite && width > 0) _destinationMaxWidth = width; + } + late final AnimationController _controller; Rect _startRect; ModalFormVisuals _start; @@ -171,6 +187,7 @@ class ModalMorphFlight { _end = end; targetMode = mode; _lastTargetRect = null; + _destinationMaxWidth = null; unawaited(_controller.forward(from: 0)); _entry?.markNeedsBuild(); } @@ -217,7 +234,7 @@ class ModalMorphFlight { child: OverflowBox( alignment: Alignment.topCenter, minWidth: 0, - maxWidth: rect.width, + maxWidth: _destinationMaxWidth ?? rect.width, minHeight: 0, maxHeight: maxHeight, child: _MeasureSize( diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index 4c89adc..c6f218a 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -121,6 +121,49 @@ void main() { 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('flight surface morphs geometry between the forms', ( tester, ) async { From d9bfa466c6bf5e4662f014a965ce0e3bcf6f9e34 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 19:38:30 +0530 Subject: [PATCH 05/41] =?UTF-8?q?feat:=20the=20flight=20becomes=20the=20de?= =?UTF-8?q?stination=20=E2=80=94=20ghost=20routes=20until=20landing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 24 +++-- docs/UPDATING.md | 16 +++ lib/src/core/modal/adaptive_modal.dart | 136 +++++++++++++++++++------ lib/src/core/modal/modal_morph.dart | 94 ++++++++++++++--- test/core/modal/modal_morph_test.dart | 70 +++++++++++++ 5 files changed, 286 insertions(+), 54 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4f25bd..9f4a436 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,15 +98,21 @@ 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, and -elevation from the outgoing form's geometry to the destination's. The -destination route lays out a same-size placeholder whose LIVE rect is the -flight's landing target, tracked every frame. The sizing handshake sends -one quantity each way: the placeholder reports the width its slot OFFERS, -the flight lays the content out at that width and reports the resulting -size back — so the destination reaches its true final geometry within a few -frames 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 +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 +sizing handshake sends one quantity each way: the placeholder reports the +width its slot OFFERS, the flight lays the content out at that width and +reports the resulting size back — 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. diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 404c278..86f14ca 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -110,6 +110,22 @@ With the container transform (`ModalConfig.morph`, default on), three more: 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 spacer and the flight's + surface inset both use the SAME constant `BottomSheet` uses for its + content padding, so the metric cannot drift; 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. ## §4 — Adding a component (divider / empty state) diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 93490b9..3caafa7 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -96,6 +96,10 @@ class _ModalSession { 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. @@ -122,10 +126,12 @@ class _ModalSession { ModalLayoutMode mode, { required bool animate, Route? removeFirst, + bool ghost = false, }) { - final route = _buildRoute(mode, animate: animate); + 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) @@ -158,35 +164,53 @@ class _ModalSession { // the frame the swap waited on. final target = _modeFor(MediaQuery.sizeOf(navigator.context).width); if (target == _activeMode) return; - if (config.morph) _beginOrRetargetFlight(target); - _push(target, animate: false, removeFirst: active); + 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. - /// Bails silently to the instant cut when geometry can't be measured. - void _beginOrRetargetFlight(ModalLayoutMode target) { + /// Returns whether a flight is airborne; false bails to the instant cut. + bool _beginOrRetargetFlight(ModalLayoutMode target) { final overlay = navigator.overlay; - if (overlay == null) return; + if (overlay == null) return false; final theme = Theme.of(navigator.context); final end = ModalFormVisuals.of(theme, target); final flight = _flight; if (flight != null) { - flight.retarget(end: end, mode: target); - return; + 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; - if (box is! RenderBox || overlayBox is! RenderBox) return; - if (!box.attached || !box.hasSize) return; + 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; @@ -198,6 +222,12 @@ class _ModalSession { 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. @@ -220,26 +250,38 @@ class _ModalSession { onCompleted: () { final flight = _flight; WidgetsBinding.instance.addPostFrameCallback((_) { - if (identical(flight, _flight)) _endFlight(); + 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. - void _endFlight() { + /// 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}) { + Route _buildRoute( + ModalLayoutMode mode, { + required bool animate, + bool ghost = false, + }) { // Swap pushes skip the entrance animation (the modal is already // visually present); exits keep Material's timing either way. final style = animate ? null : AnimationStyle(duration: Duration.zero); @@ -256,14 +298,21 @@ class _ModalSession { animationStyle: style, ); 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, - enableDrag: config.enableDrag, - showDragHandle: config.showDragHandle, + enableDrag: ghost ? false : config.enableDrag, + showDragHandle: ghost ? false : config.showDragHandle, + backgroundColor: ghost ? Colors.transparent : null, + elevation: ghost ? 0 : null, clipBehavior: Clip.antiAlias, settings: settings, sheetAnimationStyle: style, @@ -289,36 +338,59 @@ class _ModalScope extends StatelessWidget { // 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. - final content = ValueListenableBuilder( + return ValueListenableBuilder( valueListenable: session._morphing, builder: (context, morphing, _) { final flight = session._flight; - if (morphing && flight != null) { + final ghosting = morphing && flight != null; + final Widget content; + if (ghosting) { // The LayoutBuilder reports the width this slot OFFERS, so the // flight can lay the content out at its true final width — the - // placeholder then mirrors the resulting size back. - return LayoutBuilder( + // placeholder then mirrors the resulting size back. 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)); + content = LayoutBuilder( builder: (context, constraints) { flight.reportDestinationMaxWidth(constraints.maxWidth); - return ValueListenableBuilder( + final placeholder = ValueListenableBuilder( valueListenable: flight.contentSize, builder: (context, size, _) => SizedBox.fromSize(key: flight.placeholderKey, size: size), ); + if (inset == 0) return placeholder; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: inset), + placeholder, + ], + ); }, ); + } else { + content = KeyedSubtree( + key: session._contentKey, + child: session.builder(context, mode), + ); } - return 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. + return ghosting + ? Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + shadowColor: Colors.transparent, + clipBehavior: Clip.antiAlias, + child: content, + ) + : Dialog(clipBehavior: Clip.antiAlias, child: 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. - return mode == ModalLayoutMode.dialog - ? Dialog(clipBehavior: Clip.antiAlias, child: content) - : content; } } diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index f926bbe..3a107e3 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -102,9 +102,15 @@ class ModalMorphFlight { 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( @@ -129,6 +135,13 @@ class ModalMorphFlight { /// 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( @@ -157,6 +170,13 @@ class ModalMorphFlight { 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; @@ -181,9 +201,12 @@ class ModalMorphFlight { 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; @@ -220,29 +243,74 @@ class ModalMorphFlight { animation: _controller, builder: (context, _) { final rect = currentRect(); - final visuals = ModalFormVisuals.lerp(_start, _end, _t); + 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: rect, + rect: surfaceRect, child: IgnorePointer( child: Material( clipBehavior: Clip.antiAlias, shape: visuals.shape, color: visuals.color, elevation: visuals.elevation, - child: OverflowBox( + child: Stack( alignment: Alignment.topCenter, - minWidth: 0, - maxWidth: _destinationMaxWidth ?? rect.width, - minHeight: 0, - maxHeight: maxHeight, - child: _MeasureSize( - onSize: (size) { - if (contentSize.value != size) contentSize.value = size; - }, - child: contentBuilder(context), - ), + children: [ + Padding( + padding: EdgeInsets.only(top: inset), + child: OverflowBox( + alignment: Alignment.topCenter, + minWidth: 0, + maxWidth: _destinationMaxWidth ?? rect.width, + minHeight: 0, + maxHeight: maxHeight, + child: _MeasureSize( + 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, + ), + ), + ), + ), + ), + ), + ], ), ), ), diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index c6f218a..3c4337a 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -164,6 +164,76 @@ void main() { 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('flight surface morphs geometry between the forms', ( tester, ) async { From 04ac579391ee994bb1c2e145ef5dc34da4dabc7e Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 19:44:49 +0530 Subject: [PATCH 06/41] fix: content reflows with the morphing container instead of being cropped --- docs/ARCHITECTURE.md | 9 ++--- docs/UPDATING.md | 24 ++++++++----- lib/src/core/modal/adaptive_modal.dart | 47 ++++++++++++++------------ lib/src/core/modal/modal_morph.dart | 32 +++++------------- test/core/modal/modal_morph_test.dart | 19 +++++++++++ 5 files changed, 73 insertions(+), 58 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f4a436..c6de56a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,10 +108,11 @@ 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 -sizing handshake sends one quantity each way: the placeholder reports the -width its slot OFFERS, the flight lays the content out at that width and -reports the resulting size back — so the target is the true final geometry -and the handoff is pixel-clean. Both resting forms clip (`Clip.antiAlias`) +content is laid out at the container's lerped width so it reflows with the +morph; the placeholder's width follows each form's convention (slot-decided +for full-bleed sheets, content-decided for dialogs) and only the height +flows from the flight's 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)` diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 86f14ca..75439c4 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -101,15 +101,21 @@ With the container transform (`ModalConfig.morph`, default on), three more: 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. **The width handshake flows one quantity each way.** The placeholder - reports the width its slot OFFERS (`reportDestinationMaxWidth`); the - flight lays the content out at that width and reports the SIZE back. - Laying the content out at the lerped container width instead is - circular, and the circle's fixed point is wrong — the sheet wraps to - the outgoing dialog's width, then snaps wide after landing. 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. +7. **Content lays at the container's lerped width; placeholder width is + per-form convention.** The content reflows WITH the morph — laying it + at the destination width makes the narrower container visibly crop it + mid-flight. The width circularity that reflow would otherwise cause + (placeholder width from content width from container width) is broken + by convention instead of measurement: the ghost sheet's placeholder is + `width: double.infinity` (sheets are full-bleed; the slot decides), + the dialog's placeholder takes the content's measured width (dialogs + wrap their content). Only the HEIGHT flows from the flight's + measurement. Content that defies its form's convention (fixed-width + sheet content, full-bleed dialog content) lands with a width settle — + accepted trade. 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 diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 3caafa7..7fbfd79 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -345,30 +345,33 @@ class _ModalScope extends StatelessWidget { final ghosting = morphing && flight != null; final Widget content; if (ghosting) { - // The LayoutBuilder reports the width this slot OFFERS, so the - // flight can lay the content out at its true final width — the - // placeholder then mirrors the resulting size back. The spacer - // stands in for the drag-handle band the ghost route omits, so - // the placeholder sits exactly where the content will land. + // 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)); - content = LayoutBuilder( - builder: (context, constraints) { - flight.reportDestinationMaxWidth(constraints.maxWidth); - final placeholder = ValueListenableBuilder( - valueListenable: flight.contentSize, - builder: (context, size, _) => - SizedBox.fromSize(key: flight.placeholderKey, size: size), - ); - if (inset == 0) return placeholder; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: inset), - placeholder, - ], - ); - }, + final placeholder = ValueListenableBuilder( + valueListenable: flight.contentSize, + builder: (context, size, _) => mode == ModalLayoutMode.sheet + ? SizedBox( + key: flight.placeholderKey, + width: double.infinity, + height: size.height, + ) + : SizedBox.fromSize(key: flight.placeholderKey, size: size), ); + content = inset == 0 + ? placeholder + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: inset), + placeholder, + ], + ); } else { content = KeyedSubtree( key: session._contentKey, diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index 3a107e3..9212ac0 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -83,13 +83,14 @@ class ModalFormVisuals { /// motion, and content reflow all steer the landing. On arrival the /// content reparents into the route and the flight disposes. /// -/// The sizing handshake that makes the landing seamless — one quantity -/// flows each way, so there is no feedback loop: the destination reports -/// the width it OFFERS (the placeholder's incoming constraints, via -/// [reportDestinationMaxWidth]); the flight lays the content out at that -/// width from the first frame and reports the resulting size to -/// [contentSize]; the placeholder adopts that size, and its global rect — -/// the true final geometry — is the flight target throughout. +/// 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({ @@ -152,20 +153,6 @@ class ModalMorphFlight { /// behind layout. The destination placeholder mirrors it. final ValueNotifier contentSize; - /// The width the destination offers its content slot, reported from the - /// placeholder's incoming constraints. The flight lays the content out - /// at this width so the reported [contentSize] is the true final size — - /// laying it out at the lerped container width instead is circular, and - /// the circle has a wrong fixed point (a sheet that wraps to the - /// dialog's width, then snaps wide after landing). - double? _destinationMaxWidth; - - /// Called from the placeholder's `LayoutBuilder` during layout — plain - /// field write, read on the controller's next tick. - void reportDestinationMaxWidth(double width) { - if (width.isFinite && width > 0) _destinationMaxWidth = width; - } - late final AnimationController _controller; Rect _startRect; ModalFormVisuals _start; @@ -210,7 +197,6 @@ class ModalMorphFlight { _end = end; targetMode = mode; _lastTargetRect = null; - _destinationMaxWidth = null; unawaited(_controller.forward(from: 0)); _entry?.markNeedsBuild(); } @@ -278,7 +264,7 @@ class ModalMorphFlight { child: OverflowBox( alignment: Alignment.topCenter, minWidth: 0, - maxWidth: _destinationMaxWidth ?? rect.width, + maxWidth: rect.width, minHeight: 0, maxHeight: maxHeight, child: _MeasureSize( diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index 3c4337a..a222b2e 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -234,6 +234,25 @@ void main() { 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('flight surface morphs geometry between the forms', ( tester, ) async { From 6aebdda61cdd6330be33b25f559d834081ef0087 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 20:11:43 +0530 Subject: [PATCH 07/41] =?UTF-8?q?fix:=20flight=20paints=20in=20lockstep=20?= =?UTF-8?q?with=20its=20lerp=20=E2=80=94=20no=20Material=20self-tween,=20n?= =?UTF-8?q?o=20phantom=20shadow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/UPDATING.md | 10 ++++++++++ lib/src/core/modal/adaptive_modal.dart | 12 +++++++++++- lib/src/core/modal/modal_morph.dart | 15 +++++++++++++++ test/core/modal/diag/pkg_landed.png | Bin 0 -> 7934 bytes test/core/modal/diag/pkg_mid.png | Bin 0 -> 8275 bytes test/core/modal/diag/pkg_t1.png | Bin 0 -> 7934 bytes test/core/modal/modal_morph_test.dart | 22 ++++++++++++++++++++++ 7 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/core/modal/diag/pkg_landed.png create mode 100644 test/core/modal/diag/pkg_mid.png create mode 100644 test/core/modal/diag/pkg_t1.png diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 75439c4..23ace81 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -132,6 +132,16 @@ With the container transform (`ModalConfig.morph`, default on), three more: `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 — Adding a component (divider / empty state) diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 7fbfd79..941c658 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -384,15 +384,25 @@ class _ModalScope extends StatelessWidget { // 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(clipBehavior: Clip.antiAlias, child: content); + : Dialog( + key: const ValueKey('real'), + clipBehavior: Clip.antiAlias, + child: content, + ); }, ); } diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index 9212ac0..fc5e40c 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -15,6 +15,7 @@ class ModalFormVisuals { required this.shape, required this.color, required this.elevation, + required this.shadowColor, }); /// Resolves the visuals for [mode] from [theme], mirroring the Material @@ -30,6 +31,7 @@ class ModalFormVisuals { theme.dialogTheme.backgroundColor ?? theme.colorScheme.surfaceContainerHigh, elevation: theme.dialogTheme.elevation ?? 6, + shadowColor: theme.dialogTheme.shadowColor ?? Colors.transparent, ); case ModalLayoutMode.sheet: return ModalFormVisuals( @@ -46,6 +48,7 @@ class ModalFormVisuals { theme.bottomSheetTheme.modalElevation ?? theme.bottomSheetTheme.elevation ?? 1, + shadowColor: theme.bottomSheetTheme.shadowColor ?? Colors.transparent, ); } } @@ -59,6 +62,11 @@ class ModalFormVisuals { /// 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, @@ -69,6 +77,7 @@ class 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, ); } } @@ -252,10 +261,16 @@ class ModalMorphFlight { 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: [ diff --git a/test/core/modal/diag/pkg_landed.png b/test/core/modal/diag/pkg_landed.png new file mode 100644 index 0000000000000000000000000000000000000000..4d465367402216f27b37afb54b51eede32921bf2 GIT binary patch literal 7934 zcmeHLeK?ct`@g*uJs#?LsGdmqKGl<+9@Ha|_Yz9sDWbe=i%{O?Wf^8hZ%SUil2RB+ zsburgEU_U;<(-&Wo41N#8;vnDzSqq6kKcd4|9;2uJM1`iZ1;Vg_j#Sy=RB{^=e)05 z|2Wtx|D^sC1VPHjj~zV?L2{$utEIRW+$s6(m>Kwy4?l9;SrL3<6ffKa*D~R!ZI3{N z)~##^+V<@D(ZkM>*&JrTzjH31Z=4#L_&%}g0(#vk)ziOk(3LqFoMN=|hTZ-Kclos> zZ=d&LNp1l?ee73>5wHG+ZD?At>$;(zvs2jAtPn)5Bhx3LJ{*Llrm%GkPdd7ZU|&FHaJ=$eL}#)cP429e4_ZZy&55Xb#_R1E zgIB)}fD_G5uMFK6Y?KWB#;7R1+|19dX*0@=*twE@q?t+Q{hGN3GjB!(X%l5&jY1Ot zQi6#TFg{2m(R}J%mn#9r#Oxy$JO9~mYqRI)uE??HgogE|A;(r`n7vW>bZjYB#3_$` zBYHKTqFH?X#647wu#dLwN6vCepteE93MPY0-lTgjtT#o9;A9frX1;tlrtgu>^4jA3 z9_;C~#mAJ9E?PK7S=^N-q9j3VJ^!JnxH#UYNo7p2B0(cr$D1Q~cXH{eq{id0z51O2 zsTM4C)5WEB-7*k zD#z$)tYGr((jbQ0ZP?@$u{m38DfKm=m0aPb+Lkg+efgQPc8UoB zgP@mj>C_10PV~ZbA!o$w$A`oO_2fq1%e9i7TSDHgo;*0?3KyeivHYx=t5{CgY=3{u zw5T7cJI3W=ng#}nz8sd|XSJ`-KbbeK&sHeoP(=?7;ZffRrG>e{8?8@Gqb}=gkkop} zO~Jy+`FJWC)XpCgx*FJ*X9KpZWLBX```OL112R5H{a6lOpKJ;mmQ%PULR7%9-SuUhEdFRwJK1LK7P%&sq#h}H( z7+n1glY{pu>*K7P_9PD7D6@q^CUxOB;U`y*u#eZf{Vxkgdyk>%n7As-i@qYboW8ZAO!Np zwOgmS=$esTel?>nZ`)owwC}`KxvR;!;t6V%xu~)&hB%=EHJ{9ryN2Os;bxOadTF4mnnMZ%GHE1c`RH%Ur?EJK=;+KfZr=%ZCT0s8G782}^U1 zi=*2=v1DqghkTW_+RJ>J=4&Q}deu(ZatU2Wy%!4fViuKn9uP8`{)?EfTCldxn0Om1;tVnyOYY-HR6%S zhG*>P?s1Rd*lJjm@Sy<__6Btc4nmpR6|b^kNY8vBtv~#ldi(Jmk9R1UFFZ=ECQWEU z3wJCP6epi+Scq>7_7URC&=B7-=mKq^FnD2F1FSaAQbC)U;p%bOuDsi*aze9qp*zsP zf%+q{$YBpgp*9AfWmgf*+zV$0lbi4|&EBBVK1=3i5OpP^BYU{euIT<;>e3K_`{_6U zGcz;$8D5+p-q%kNnWSb=)*z^13YvL4L7!OQCtTMAcN3_@T`I&!f3g;f#jzIh+P9SU zSspi!V>q z1(M-35P#h+;_I)|zU8L`KgUrHJWTeQ8SBO;%lso7ukz0oC&xqIrppYWiM9uR*`YRc z4e0w0aQ}uKgJBzg*3&cNi4PLWOqDmbOz@;G{>XUj{g?^X(VN>*+QDt2!Kr1EX_74! zMoVt%Ew(6qVnO>=xCfOkGN^}=CnBo8 z-G^zxt0;LB>sFlDln=^;T2_B|?jrq^S1*~?;17)q5Mc8KFR!<}Taf1Ww@U5jor<5A z?S`5^03-ftD+ao+)h2YcFaJM!BWXY1?N0(5SU;>-cGNUprTsj`jN&Arjava5El1X& z;#9`R#)9urWDtK4&OkYUGmQytO^%30OQqqJnq#ycU zr|7`f!?qHyAA^>n8D^4yNC`h+MFb>L^X!3v!4sA*>#VOPSMjK#kATUW4S^$hC#T{< zKuRSRs_E%0mWq`1wzK&4+wX&ba9r^e#X<)oR~xevP>m!@lKd;|1vM;XJv!Ux#V9p$aOM8bLRKp3ck& zNEWf;-NAb$8n6qvEyIc`rb;{Ju@VJ1c+jN=DG~$?OD>3D1-H!SOUHxZy)`9g0Qp-t z{(KTU?M-9Cz=aS9_uXeOA{ie&#>N0WnjHOZyHp4xiq>1j{R)u1BQX_^4Ai!_O0h7~n(AS>Ap^e&Ixad4c7 zS0f-1zler0*mrBSt^q|Y6Lz#bHbHYEC*R(O&EE~)p>sdM6B))&C$dlxm?!%KoAi=qTe3f^7jd*&_ zrzyeFWaiaIRn=jjQF6`pR#zKSmIf3w48KgXv$j52yWyqe1i~j3LiqB;^#Qu3{APU@ znvH+Aq*1CLs6`C>Syb@|ZLwVa{=J}4(gy+;-kgh@w7Rbgr%p5+(d^Bwi?4hCy(Klc zdNDmWq;V@kv#j Dz05Rj literal 0 HcmV?d00001 diff --git a/test/core/modal/diag/pkg_mid.png b/test/core/modal/diag/pkg_mid.png new file mode 100644 index 0000000000000000000000000000000000000000..0a54e3e9b1bcba0ac7c5ea01ca149dfdc388a5df GIT binary patch literal 8275 zcmeHMi&v7_7XQ>ro3h6>AEoA0Gg&!mjgFQtyi@orE%ip%2U^yonc@Q_5k+V2=%l7S zG^Y|ZQ>hF|6VVi3P1?&B2ILETq<{oyAcQE~59zLT|Af2Nm9<#FxA!?`pWiSpZm{1 zO|JkNrL1N-=*d^?Kp&XNB_#21yyek6k#mUPov0t!DJOTLH$byAj79oVs@& z1G`-19~3Q$XZzfJZEJ7O?Xg7#I^vGss`nhRt-V=}E4bSASETKwbH~qxdv%<#@BnoG zsMGn>S=V029)-*%wX8XtF_xMpFYdeei!yBDli~~eoyfWGNS{9;-~bs0TK4USmYEs7 zny9Fhi&6gmqFP06(PonmlqY1Vf*DA_o@ZRkU0>)mZ3@i?mD68v3A9CQ+Iahh5KBwE z9jsa}Z~9`r-A|mM>qFGF z4xnJFtJV!ID%elECwN{x)wwzKVdCJ6%`hJU_YOHGY+OcJSZwE5?R3f51OV-g@%1-c zwz2Ug*fej|7)Bf0`IepYQtSn|Z{PkX?R6#ISCMK>n^BL_!;yZ8+%XiY7_E?59%UJ4 zi`iyrjV0T-nx^0m;g$62cq__OUhl+Tvg`LfSgbMU`=X(XYy*Au zI({4wDJe23sZB@^!ZxMSpFDW(i%#tP*Q|AD@^r8R`M(vdhEHYHUN~_!beF_ zQk1Y+*z}gAki2|xO{^uC!uB@Q2OOYXx-hQ~J1(%;r$({rQ#0{LzPzw==XQ0hV_-86QX!gU9#)7 zc`N9-KB72K&B~Ar z1O^igI5h+kP8$@X;BT3R@Q58fHtPLnKCL99q{|kkmp(21zM-#JlxbhTacWngSEb+3 z$VgD>=)fscKDl{=YT_MkedA$DE!NV^;)L5Gv!rDBV_sg>8zao#75w{8X6kVl(3CnN zS=u&*c@^QNhHS#bippC+S7POo;ey`Ys-wrcR+UWK}qrXP?y|rcp`W`;} zS4_uV)yWyx2i*$mmR+B^wgNdojIZ!uEl|Blk;J${>6H2y1KP<1@abd@so|f`@@5S# z8*8t#1LI1bCw5$k9+S?F8%%lU29jjxSw7d`G7prS?pOP~CG)-qL|WkA>%{NekiTef z*}JACKE9GjjGUdNCFHB40N6Bj2GMc)QNV$e6!fK#BDg~`BU86<56*MtfW)qGPubA$ z#v;z8>A{0Y4qxD&tv2^XtgR{%k79TFRIGT;s0v&SG0|!B6>oBM5FxQEH?;B3(Hm;0 z+D~z|n|xBzQ>D%+6P>?@5c9-n3j9CV|3nn0HL6c#Y(Wy^k{oWj5@|$r6ajf1Co9?r z)lmcW+imvN_uT#IY-7G@wwUh=34zQvv1bToMjvdS7(W4{6ljZh+wR&|=v`!mow?z@ zm@lavp+7k?Co(TNd@#E6U$5;+ft`+zwAjYiu zDk$hb{ENLkYDw~GNO-sLV&7JxK(JzJ?oH6tR#G!ci8TaZ5)!Y3FY8K`Ufz#3uF&Je>UbMi-$|mS5aF>MvbNT_A_FCL>GduAaVxam zbnU!M7wQWb-Cm0?4ivBPIxh^B2$%EmrNdu1;VjmiuM#88Oytoe`jqw50E@ymd}x09 zHnXMQlyO~_ZOhH<-a9tZL@45ZODmFXm{S5A{DHhgkB0h|{Z+yeFXefKsona#BL<8rHzwsi3 ze@Yt2@D@%NFUEn;hDG$vn!^^hQPyYA?2fao_r^}u-O0x)uks*r!_v02-q#5i4Jg_` z*}yxTBZ3|}s<;z{og3>WQYJ%Sy$8^nw|SNgK2m) z)V3;hNqyct-5p^5~$}cx14R*v% zO(0i~A00iY_@q?GE~tb#OwI7Wmkn+?J-WPr!a1en!9q2Ft~*D}`*1SSw1t%gT03`b zh7UJYV1r$VLM#>9qx30SKzg`n#vVuEB$UdkLsdF<89t9fFas~m;aI+|p@B+eDq)#q zAN)m+4hoC10lM4ZJO-UOWd(M&eyZaQ@;P!s33_$mwXJVLRw zHX)m-0kpqzNbGo&0GX4UIX&Q*9^A3Up>mK)^`k7HV&n_uDDe8|5F@8ZOIcLvq{GpI zJXbuj_lVpQH;6|iZBto3aEO=q%*TQKiHrny;&vBQsA*nwKqPAd`;K&D3T~)II0`Yo zbO89C{b&AaS@t{hwMxG}rkT8mzq7LRTHTFcF8_){-&QcKQ^o*kmbeF@cVY7vr=b99u zp|ri$F`85P;gC07+mT+C&l0-cm)ip-IX_Q2jiajM<$eeH0(o4xh11>` z@Uc4qW2<9CtU@6jBo6{wUgG>BaX-?kAA&N%X{AL$_u}~mJMFGo$tLrQ`wYR6^(0dA z&2?S&9UBi`&RgDRjEKYA4i68T`;^!YZASv>>kcJTKYB(-^L-rrc4*?LA4t_w*8yYj z5+0PNv0rbs23@T|S1%<08&|PPDucm*iNP*tS0>0BZe{)lD9vbRPc6V!r#r3Snkzm4 zjZQ+}Tn9vK85;{q$zcL}JV7PLB!TK)se7(Fj}KCSKFA7}S&BOINHF^6KPP4QH0dC{ zrmvPRUGZKCd}B!3l=~1j%}+?!wzv zQeVydFz~kbW~zCWIva(sNgdT#NE@FZ1`pqUCt(Ng|`z zO&i#E2TeJxe2|Y752&)q7ES?GYqRsyg?TfG!VD?|fUxt_`jRhX^CTDXtE1H*dxN>>OOdufNnYSF`1`@6 zW&lDrCdmleIre$-wm_A8VlfRZjg|7>l%i$G59+k{Xh9;`*?5-wlLZ=`lB7>r3#Qk> z!I5XY%u2=+4xhg;5A`}x=8yJuUVdLQ!o+K1Z+2-Pdl0D6Zy@1wDE{hSmkbiXVb~vwZAixYXrK|8FjJ9!IxuY;zrNrzTGk zuyZM>1*wp`GGP{j)rMC}A+fwM)DQB{UFGEVGhiK4pfANli<*hg&kfBO+C^Kc*3N2> z{eB{dl}gipdwNtv>VYCMYQO7`JZu3Yd`jkhZ}5P#Gb3`Bu3(u<`A^ z-bGruc|XuVX^HM;x6L(WF{O_7scd)U;)^0)4cA*sk7L^cHcVv``8!7>V6mAr{hm%A zQx!+PPV*I9sIa_cjl)5>4$^J#E~)Qwuq0nO_ktJmd~pQwWgvV6Eae7nmBrG$D&tcI z_I%ruyWQq4qD4@(wkUWhZ?G5r!q~8ZL^@#!*30vij%{beYtqj)O_O6R(3;=ZrAP&> zG~b#)W&a-5Kb5p*!RXUec@iQ#>o*;wQD#leOMliCIuV(K8=^IZb#(SNfpr;^PNxTo zt-A1~(ZP&U;&tg0hod{VR@9e=xLp3wvt&_~sj*!RtM-?kVO(|EqR^3E6EX-BTX~qP z8lK;#S2wOUi&pz*t8KE?HrZ;MY_(1H|JWw`?>|k_mDKwy4?l9;SrL3<6ffKa*D~R!ZI3{N z)~##^+V<@D(ZkM>*&JrTzjH31Z=4#L_&%}g0(#vk)ziOk(3LqFoMN=|hTZ-Kclos> zZ=d&LNp1l?ee73>5wHG+ZD?At>$;(zvs2jAtPn)5Bhx3LJ{*Llrm%GkPdd7ZU|&FHaJ=$eL}#)cP429e4_ZZy&55Xb#_R1E zgIB)}fD_G5uMFK6Y?KWB#;7R1+|19dX*0@=*twE@q?t+Q{hGN3GjB!(X%l5&jY1Ot zQi6#TFg{2m(R}J%mn#9r#Oxy$JO9~mYqRI)uE??HgogE|A;(r`n7vW>bZjYB#3_$` zBYHKTqFH?X#647wu#dLwN6vCepteE93MPY0-lTgjtT#o9;A9frX1;tlrtgu>^4jA3 z9_;C~#mAJ9E?PK7S=^N-q9j3VJ^!JnxH#UYNo7p2B0(cr$D1Q~cXH{eq{id0z51O2 zsTM4C)5WEB-7*k zD#z$)tYGr((jbQ0ZP?@$u{m38DfKm=m0aPb+Lkg+efgQPc8UoB zgP@mj>C_10PV~ZbA!o$w$A`oO_2fq1%e9i7TSDHgo;*0?3KyeivHYx=t5{CgY=3{u zw5T7cJI3W=ng#}nz8sd|XSJ`-KbbeK&sHeoP(=?7;ZffRrG>e{8?8@Gqb}=gkkop} zO~Jy+`FJWC)XpCgx*FJ*X9KpZWLBX```OL112R5H{a6lOpKJ;mmQ%PULR7%9-SuUhEdFRwJK1LK7P%&sq#h}H( z7+n1glY{pu>*K7P_9PD7D6@q^CUxOB;U`y*u#eZf{Vxkgdyk>%n7As-i@qYboW8ZAO!Np zwOgmS=$esTel?>nZ`)owwC}`KxvR;!;t6V%xu~)&hB%=EHJ{9ryN2Os;bxOadTF4mnnMZ%GHE1c`RH%Ur?EJK=;+KfZr=%ZCT0s8G782}^U1 zi=*2=v1DqghkTW_+RJ>J=4&Q}deu(ZatU2Wy%!4fViuKn9uP8`{)?EfTCldxn0Om1;tVnyOYY-HR6%S zhG*>P?s1Rd*lJjm@Sy<__6Btc4nmpR6|b^kNY8vBtv~#ldi(Jmk9R1UFFZ=ECQWEU z3wJCP6epi+Scq>7_7URC&=B7-=mKq^FnD2F1FSaAQbC)U;p%bOuDsi*aze9qp*zsP zf%+q{$YBpgp*9AfWmgf*+zV$0lbi4|&EBBVK1=3i5OpP^BYU{euIT<;>e3K_`{_6U zGcz;$8D5+p-q%kNnWSb=)*z^13YvL4L7!OQCtTMAcN3_@T`I&!f3g;f#jzIh+P9SU zSspi!V>q z1(M-35P#h+;_I)|zU8L`KgUrHJWTeQ8SBO;%lso7ukz0oC&xqIrppYWiM9uR*`YRc z4e0w0aQ}uKgJBzg*3&cNi4PLWOqDmbOz@;G{>XUj{g?^X(VN>*+QDt2!Kr1EX_74! zMoVt%Ew(6qVnO>=xCfOkGN^}=CnBo8 z-G^zxt0;LB>sFlDln=^;T2_B|?jrq^S1*~?;17)q5Mc8KFR!<}Taf1Ww@U5jor<5A z?S`5^03-ftD+ao+)h2YcFaJM!BWXY1?N0(5SU;>-cGNUprTsj`jN&Arjava5El1X& z;#9`R#)9urWDtK4&OkYUGmQytO^%30OQqqJnq#ycU zr|7`f!?qHyAA^>n8D^4yNC`h+MFb>L^X!3v!4sA*>#VOPSMjK#kATUW4S^$hC#T{< zKuRSRs_E%0mWq`1wzK&4+wX&ba9r^e#X<)oR~xevP>m!@lKd;|1vM;XJv!Ux#V9p$aOM8bLRKp3ck& zNEWf;-NAb$8n6qvEyIc`rb;{Ju@VJ1c+jN=DG~$?OD>3D1-H!SOUHxZy)`9g0Qp-t z{(KTU?M-9Cz=aS9_uXeOA{ie&#>N0WnjHOZyHp4xiq>1j{R)u1BQX_^4Ai!_O0h7~n(AS>Ap^e&Ixad4c7 zS0f-1zler0*mrBSt^q|Y6Lz#bHbHYEC*R(O&EE~)p>sdM6B))&C$dlxm?!%KoAi=qTe3f^7jd*&_ zrzyeFWaiaIRn=jjQF6`pR#zKSmIf3w48KgXv$j52yWyqe1i~j3LiqB;^#Qu3{APU@ znvH+Aq*1CLs6`C>Syb@|ZLwVa{=J}4(gy+;-kgh@w7Rbgr%p5+(d^Bwi?4hCy(Klc zdNDmWq;V@kv#j Dz05Rj literal 0 HcmV?d00001 diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index a222b2e..05a7ffb 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -253,6 +253,28 @@ void main() { expect(tester.getRect(find.byType(CounterPane)).width, 500); }); + 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 { From c2f99d9723c5ecc51c723da9f1035b22f856febf Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 20:11:57 +0530 Subject: [PATCH 08/41] chore: drop leftover diagnostic captures --- test/core/modal/diag/pkg_landed.png | Bin 7934 -> 0 bytes test/core/modal/diag/pkg_mid.png | Bin 8275 -> 0 bytes test/core/modal/diag/pkg_t1.png | Bin 7934 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test/core/modal/diag/pkg_landed.png delete mode 100644 test/core/modal/diag/pkg_mid.png delete mode 100644 test/core/modal/diag/pkg_t1.png diff --git a/test/core/modal/diag/pkg_landed.png b/test/core/modal/diag/pkg_landed.png deleted file mode 100644 index 4d465367402216f27b37afb54b51eede32921bf2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7934 zcmeHLeK?ct`@g*uJs#?LsGdmqKGl<+9@Ha|_Yz9sDWbe=i%{O?Wf^8hZ%SUil2RB+ zsburgEU_U;<(-&Wo41N#8;vnDzSqq6kKcd4|9;2uJM1`iZ1;Vg_j#Sy=RB{^=e)05 z|2Wtx|D^sC1VPHjj~zV?L2{$utEIRW+$s6(m>Kwy4?l9;SrL3<6ffKa*D~R!ZI3{N z)~##^+V<@D(ZkM>*&JrTzjH31Z=4#L_&%}g0(#vk)ziOk(3LqFoMN=|hTZ-Kclos> zZ=d&LNp1l?ee73>5wHG+ZD?At>$;(zvs2jAtPn)5Bhx3LJ{*Llrm%GkPdd7ZU|&FHaJ=$eL}#)cP429e4_ZZy&55Xb#_R1E zgIB)}fD_G5uMFK6Y?KWB#;7R1+|19dX*0@=*twE@q?t+Q{hGN3GjB!(X%l5&jY1Ot zQi6#TFg{2m(R}J%mn#9r#Oxy$JO9~mYqRI)uE??HgogE|A;(r`n7vW>bZjYB#3_$` zBYHKTqFH?X#647wu#dLwN6vCepteE93MPY0-lTgjtT#o9;A9frX1;tlrtgu>^4jA3 z9_;C~#mAJ9E?PK7S=^N-q9j3VJ^!JnxH#UYNo7p2B0(cr$D1Q~cXH{eq{id0z51O2 zsTM4C)5WEB-7*k zD#z$)tYGr((jbQ0ZP?@$u{m38DfKm=m0aPb+Lkg+efgQPc8UoB zgP@mj>C_10PV~ZbA!o$w$A`oO_2fq1%e9i7TSDHgo;*0?3KyeivHYx=t5{CgY=3{u zw5T7cJI3W=ng#}nz8sd|XSJ`-KbbeK&sHeoP(=?7;ZffRrG>e{8?8@Gqb}=gkkop} zO~Jy+`FJWC)XpCgx*FJ*X9KpZWLBX```OL112R5H{a6lOpKJ;mmQ%PULR7%9-SuUhEdFRwJK1LK7P%&sq#h}H( z7+n1glY{pu>*K7P_9PD7D6@q^CUxOB;U`y*u#eZf{Vxkgdyk>%n7As-i@qYboW8ZAO!Np zwOgmS=$esTel?>nZ`)owwC}`KxvR;!;t6V%xu~)&hB%=EHJ{9ryN2Os;bxOadTF4mnnMZ%GHE1c`RH%Ur?EJK=;+KfZr=%ZCT0s8G782}^U1 zi=*2=v1DqghkTW_+RJ>J=4&Q}deu(ZatU2Wy%!4fViuKn9uP8`{)?EfTCldxn0Om1;tVnyOYY-HR6%S zhG*>P?s1Rd*lJjm@Sy<__6Btc4nmpR6|b^kNY8vBtv~#ldi(Jmk9R1UFFZ=ECQWEU z3wJCP6epi+Scq>7_7URC&=B7-=mKq^FnD2F1FSaAQbC)U;p%bOuDsi*aze9qp*zsP zf%+q{$YBpgp*9AfWmgf*+zV$0lbi4|&EBBVK1=3i5OpP^BYU{euIT<;>e3K_`{_6U zGcz;$8D5+p-q%kNnWSb=)*z^13YvL4L7!OQCtTMAcN3_@T`I&!f3g;f#jzIh+P9SU zSspi!V>q z1(M-35P#h+;_I)|zU8L`KgUrHJWTeQ8SBO;%lso7ukz0oC&xqIrppYWiM9uR*`YRc z4e0w0aQ}uKgJBzg*3&cNi4PLWOqDmbOz@;G{>XUj{g?^X(VN>*+QDt2!Kr1EX_74! zMoVt%Ew(6qVnO>=xCfOkGN^}=CnBo8 z-G^zxt0;LB>sFlDln=^;T2_B|?jrq^S1*~?;17)q5Mc8KFR!<}Taf1Ww@U5jor<5A z?S`5^03-ftD+ao+)h2YcFaJM!BWXY1?N0(5SU;>-cGNUprTsj`jN&Arjava5El1X& z;#9`R#)9urWDtK4&OkYUGmQytO^%30OQqqJnq#ycU zr|7`f!?qHyAA^>n8D^4yNC`h+MFb>L^X!3v!4sA*>#VOPSMjK#kATUW4S^$hC#T{< zKuRSRs_E%0mWq`1wzK&4+wX&ba9r^e#X<)oR~xevP>m!@lKd;|1vM;XJv!Ux#V9p$aOM8bLRKp3ck& zNEWf;-NAb$8n6qvEyIc`rb;{Ju@VJ1c+jN=DG~$?OD>3D1-H!SOUHxZy)`9g0Qp-t z{(KTU?M-9Cz=aS9_uXeOA{ie&#>N0WnjHOZyHp4xiq>1j{R)u1BQX_^4Ai!_O0h7~n(AS>Ap^e&Ixad4c7 zS0f-1zler0*mrBSt^q|Y6Lz#bHbHYEC*R(O&EE~)p>sdM6B))&C$dlxm?!%KoAi=qTe3f^7jd*&_ zrzyeFWaiaIRn=jjQF6`pR#zKSmIf3w48KgXv$j52yWyqe1i~j3LiqB;^#Qu3{APU@ znvH+Aq*1CLs6`C>Syb@|ZLwVa{=J}4(gy+;-kgh@w7Rbgr%p5+(d^Bwi?4hCy(Klc zdNDmWq;V@kv#j Dz05Rj diff --git a/test/core/modal/diag/pkg_mid.png b/test/core/modal/diag/pkg_mid.png deleted file mode 100644 index 0a54e3e9b1bcba0ac7c5ea01ca149dfdc388a5df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8275 zcmeHMi&v7_7XQ>ro3h6>AEoA0Gg&!mjgFQtyi@orE%ip%2U^yonc@Q_5k+V2=%l7S zG^Y|ZQ>hF|6VVi3P1?&B2ILETq<{oyAcQE~59zLT|Af2Nm9<#FxA!?`pWiSpZm{1 zO|JkNrL1N-=*d^?Kp&XNB_#21yyek6k#mUPov0t!DJOTLH$byAj79oVs@& z1G`-19~3Q$XZzfJZEJ7O?Xg7#I^vGss`nhRt-V=}E4bSASETKwbH~qxdv%<#@BnoG zsMGn>S=V029)-*%wX8XtF_xMpFYdeei!yBDli~~eoyfWGNS{9;-~bs0TK4USmYEs7 zny9Fhi&6gmqFP06(PonmlqY1Vf*DA_o@ZRkU0>)mZ3@i?mD68v3A9CQ+Iahh5KBwE z9jsa}Z~9`r-A|mM>qFGF z4xnJFtJV!ID%elECwN{x)wwzKVdCJ6%`hJU_YOHGY+OcJSZwE5?R3f51OV-g@%1-c zwz2Ug*fej|7)Bf0`IepYQtSn|Z{PkX?R6#ISCMK>n^BL_!;yZ8+%XiY7_E?59%UJ4 zi`iyrjV0T-nx^0m;g$62cq__OUhl+Tvg`LfSgbMU`=X(XYy*Au zI({4wDJe23sZB@^!ZxMSpFDW(i%#tP*Q|AD@^r8R`M(vdhEHYHUN~_!beF_ zQk1Y+*z}gAki2|xO{^uC!uB@Q2OOYXx-hQ~J1(%;r$({rQ#0{LzPzw==XQ0hV_-86QX!gU9#)7 zc`N9-KB72K&B~Ar z1O^igI5h+kP8$@X;BT3R@Q58fHtPLnKCL99q{|kkmp(21zM-#JlxbhTacWngSEb+3 z$VgD>=)fscKDl{=YT_MkedA$DE!NV^;)L5Gv!rDBV_sg>8zao#75w{8X6kVl(3CnN zS=u&*c@^QNhHS#bippC+S7POo;ey`Ys-wrcR+UWK}qrXP?y|rcp`W`;} zS4_uV)yWyx2i*$mmR+B^wgNdojIZ!uEl|Blk;J${>6H2y1KP<1@abd@so|f`@@5S# z8*8t#1LI1bCw5$k9+S?F8%%lU29jjxSw7d`G7prS?pOP~CG)-qL|WkA>%{NekiTef z*}JACKE9GjjGUdNCFHB40N6Bj2GMc)QNV$e6!fK#BDg~`BU86<56*MtfW)qGPubA$ z#v;z8>A{0Y4qxD&tv2^XtgR{%k79TFRIGT;s0v&SG0|!B6>oBM5FxQEH?;B3(Hm;0 z+D~z|n|xBzQ>D%+6P>?@5c9-n3j9CV|3nn0HL6c#Y(Wy^k{oWj5@|$r6ajf1Co9?r z)lmcW+imvN_uT#IY-7G@wwUh=34zQvv1bToMjvdS7(W4{6ljZh+wR&|=v`!mow?z@ zm@lavp+7k?Co(TNd@#E6U$5;+ft`+zwAjYiu zDk$hb{ENLkYDw~GNO-sLV&7JxK(JzJ?oH6tR#G!ci8TaZ5)!Y3FY8K`Ufz#3uF&Je>UbMi-$|mS5aF>MvbNT_A_FCL>GduAaVxam zbnU!M7wQWb-Cm0?4ivBPIxh^B2$%EmrNdu1;VjmiuM#88Oytoe`jqw50E@ymd}x09 zHnXMQlyO~_ZOhH<-a9tZL@45ZODmFXm{S5A{DHhgkB0h|{Z+yeFXefKsona#BL<8rHzwsi3 ze@Yt2@D@%NFUEn;hDG$vn!^^hQPyYA?2fao_r^}u-O0x)uks*r!_v02-q#5i4Jg_` z*}yxTBZ3|}s<;z{og3>WQYJ%Sy$8^nw|SNgK2m) z)V3;hNqyct-5p^5~$}cx14R*v% zO(0i~A00iY_@q?GE~tb#OwI7Wmkn+?J-WPr!a1en!9q2Ft~*D}`*1SSw1t%gT03`b zh7UJYV1r$VLM#>9qx30SKzg`n#vVuEB$UdkLsdF<89t9fFas~m;aI+|p@B+eDq)#q zAN)m+4hoC10lM4ZJO-UOWd(M&eyZaQ@;P!s33_$mwXJVLRw zHX)m-0kpqzNbGo&0GX4UIX&Q*9^A3Up>mK)^`k7HV&n_uDDe8|5F@8ZOIcLvq{GpI zJXbuj_lVpQH;6|iZBto3aEO=q%*TQKiHrny;&vBQsA*nwKqPAd`;K&D3T~)II0`Yo zbO89C{b&AaS@t{hwMxG}rkT8mzq7LRTHTFcF8_){-&QcKQ^o*kmbeF@cVY7vr=b99u zp|ri$F`85P;gC07+mT+C&l0-cm)ip-IX_Q2jiajM<$eeH0(o4xh11>` z@Uc4qW2<9CtU@6jBo6{wUgG>BaX-?kAA&N%X{AL$_u}~mJMFGo$tLrQ`wYR6^(0dA z&2?S&9UBi`&RgDRjEKYA4i68T`;^!YZASv>>kcJTKYB(-^L-rrc4*?LA4t_w*8yYj z5+0PNv0rbs23@T|S1%<08&|PPDucm*iNP*tS0>0BZe{)lD9vbRPc6V!r#r3Snkzm4 zjZQ+}Tn9vK85;{q$zcL}JV7PLB!TK)se7(Fj}KCSKFA7}S&BOINHF^6KPP4QH0dC{ zrmvPRUGZKCd}B!3l=~1j%}+?!wzv zQeVydFz~kbW~zCWIva(sNgdT#NE@FZ1`pqUCt(Ng|`z zO&i#E2TeJxe2|Y752&)q7ES?GYqRsyg?TfG!VD?|fUxt_`jRhX^CTDXtE1H*dxN>>OOdufNnYSF`1`@6 zW&lDrCdmleIre$-wm_A8VlfRZjg|7>l%i$G59+k{Xh9;`*?5-wlLZ=`lB7>r3#Qk> z!I5XY%u2=+4xhg;5A`}x=8yJuUVdLQ!o+K1Z+2-Pdl0D6Zy@1wDE{hSmkbiXVb~vwZAixYXrK|8FjJ9!IxuY;zrNrzTGk zuyZM>1*wp`GGP{j)rMC}A+fwM)DQB{UFGEVGhiK4pfANli<*hg&kfBO+C^Kc*3N2> z{eB{dl}gipdwNtv>VYCMYQO7`JZu3Yd`jkhZ}5P#Gb3`Bu3(u<`A^ z-bGruc|XuVX^HM;x6L(WF{O_7scd)U;)^0)4cA*sk7L^cHcVv``8!7>V6mAr{hm%A zQx!+PPV*I9sIa_cjl)5>4$^J#E~)Qwuq0nO_ktJmd~pQwWgvV6Eae7nmBrG$D&tcI z_I%ruyWQq4qD4@(wkUWhZ?G5r!q~8ZL^@#!*30vij%{beYtqj)O_O6R(3;=ZrAP&> zG~b#)W&a-5Kb5p*!RXUec@iQ#>o*;wQD#leOMliCIuV(K8=^IZb#(SNfpr;^PNxTo zt-A1~(ZP&U;&tg0hod{VR@9e=xLp3wvt&_~sj*!RtM-?kVO(|EqR^3E6EX-BTX~qP z8lK;#S2wOUi&pz*t8KE?HrZ;MY_(1H|JWw`?>|k_mDKwy4?l9;SrL3<6ffKa*D~R!ZI3{N z)~##^+V<@D(ZkM>*&JrTzjH31Z=4#L_&%}g0(#vk)ziOk(3LqFoMN=|hTZ-Kclos> zZ=d&LNp1l?ee73>5wHG+ZD?At>$;(zvs2jAtPn)5Bhx3LJ{*Llrm%GkPdd7ZU|&FHaJ=$eL}#)cP429e4_ZZy&55Xb#_R1E zgIB)}fD_G5uMFK6Y?KWB#;7R1+|19dX*0@=*twE@q?t+Q{hGN3GjB!(X%l5&jY1Ot zQi6#TFg{2m(R}J%mn#9r#Oxy$JO9~mYqRI)uE??HgogE|A;(r`n7vW>bZjYB#3_$` zBYHKTqFH?X#647wu#dLwN6vCepteE93MPY0-lTgjtT#o9;A9frX1;tlrtgu>^4jA3 z9_;C~#mAJ9E?PK7S=^N-q9j3VJ^!JnxH#UYNo7p2B0(cr$D1Q~cXH{eq{id0z51O2 zsTM4C)5WEB-7*k zD#z$)tYGr((jbQ0ZP?@$u{m38DfKm=m0aPb+Lkg+efgQPc8UoB zgP@mj>C_10PV~ZbA!o$w$A`oO_2fq1%e9i7TSDHgo;*0?3KyeivHYx=t5{CgY=3{u zw5T7cJI3W=ng#}nz8sd|XSJ`-KbbeK&sHeoP(=?7;ZffRrG>e{8?8@Gqb}=gkkop} zO~Jy+`FJWC)XpCgx*FJ*X9KpZWLBX```OL112R5H{a6lOpKJ;mmQ%PULR7%9-SuUhEdFRwJK1LK7P%&sq#h}H( z7+n1glY{pu>*K7P_9PD7D6@q^CUxOB;U`y*u#eZf{Vxkgdyk>%n7As-i@qYboW8ZAO!Np zwOgmS=$esTel?>nZ`)owwC}`KxvR;!;t6V%xu~)&hB%=EHJ{9ryN2Os;bxOadTF4mnnMZ%GHE1c`RH%Ur?EJK=;+KfZr=%ZCT0s8G782}^U1 zi=*2=v1DqghkTW_+RJ>J=4&Q}deu(ZatU2Wy%!4fViuKn9uP8`{)?EfTCldxn0Om1;tVnyOYY-HR6%S zhG*>P?s1Rd*lJjm@Sy<__6Btc4nmpR6|b^kNY8vBtv~#ldi(Jmk9R1UFFZ=ECQWEU z3wJCP6epi+Scq>7_7URC&=B7-=mKq^FnD2F1FSaAQbC)U;p%bOuDsi*aze9qp*zsP zf%+q{$YBpgp*9AfWmgf*+zV$0lbi4|&EBBVK1=3i5OpP^BYU{euIT<;>e3K_`{_6U zGcz;$8D5+p-q%kNnWSb=)*z^13YvL4L7!OQCtTMAcN3_@T`I&!f3g;f#jzIh+P9SU zSspi!V>q z1(M-35P#h+;_I)|zU8L`KgUrHJWTeQ8SBO;%lso7ukz0oC&xqIrppYWiM9uR*`YRc z4e0w0aQ}uKgJBzg*3&cNi4PLWOqDmbOz@;G{>XUj{g?^X(VN>*+QDt2!Kr1EX_74! zMoVt%Ew(6qVnO>=xCfOkGN^}=CnBo8 z-G^zxt0;LB>sFlDln=^;T2_B|?jrq^S1*~?;17)q5Mc8KFR!<}Taf1Ww@U5jor<5A z?S`5^03-ftD+ao+)h2YcFaJM!BWXY1?N0(5SU;>-cGNUprTsj`jN&Arjava5El1X& z;#9`R#)9urWDtK4&OkYUGmQytO^%30OQqqJnq#ycU zr|7`f!?qHyAA^>n8D^4yNC`h+MFb>L^X!3v!4sA*>#VOPSMjK#kATUW4S^$hC#T{< zKuRSRs_E%0mWq`1wzK&4+wX&ba9r^e#X<)oR~xevP>m!@lKd;|1vM;XJv!Ux#V9p$aOM8bLRKp3ck& zNEWf;-NAb$8n6qvEyIc`rb;{Ju@VJ1c+jN=DG~$?OD>3D1-H!SOUHxZy)`9g0Qp-t z{(KTU?M-9Cz=aS9_uXeOA{ie&#>N0WnjHOZyHp4xiq>1j{R)u1BQX_^4Ai!_O0h7~n(AS>Ap^e&Ixad4c7 zS0f-1zler0*mrBSt^q|Y6Lz#bHbHYEC*R(O&EE~)p>sdM6B))&C$dlxm?!%KoAi=qTe3f^7jd*&_ zrzyeFWaiaIRn=jjQF6`pR#zKSmIf3w48KgXv$j52yWyqe1i~j3LiqB;^#Qu3{APU@ znvH+Aq*1CLs6`C>Syb@|ZLwVa{=J}4(gy+;-kgh@w7Rbgr%p5+(d^Bwi?4hCy(Klc zdNDmWq;V@kv#j Dz05Rj From 319f5a3312213fc3b53e885b57d1e2910495ad26 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 20:26:05 +0530 Subject: [PATCH 09/41] fix: content follows the container both directions; add ModalConfig.backgroundColor --- README.md | 2 +- docs/ARCHITECTURE.md | 10 +++--- docs/UPDATING.md | 28 ++++++++-------- lib/src/core/modal/adaptive_modal.dart | 44 ++++++++++++++++++------ lib/src/core/modal/modal_config.dart | 9 +++++ lib/src/core/modal/modal_morph.dart | 37 +++++++++++++++++++-- test/core/modal/modal_morph_test.dart | 46 ++++++++++++++++++++++++++ 7 files changed, 144 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b609d9b..9ea1b58 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ final choice = await showAdaptiveModal( ); ``` -The returned future completes with the pop result no matter how many form swaps happened while the modal was open. 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. +The returned future completes with the pop result no matter how many form swaps happened while the modal was open. Each form keeps its own Material surface tone (`surfaceContainerHigh` for dialogs, `surfaceContainerLow` for sheets, themable as usual); `ModalConfig(backgroundColor: ...)` pins one color across both forms when the crossfade is unwanted. 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. ### One breakpoint for the whole app diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6de56a..10a6ae1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,11 +108,11 @@ 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 at the container's lerped width so it reflows with the -morph; the placeholder's width follows each form's convention (slot-decided -for full-bleed sheets, content-decided for dialogs) and only the height -flows from the flight's measurement — so the target is the true final -geometry and the handoff is pixel-clean. Both resting forms clip (`Clip.antiAlias`) +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)` diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 23ace81..32e01a1 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -101,21 +101,21 @@ With the container transform (`ModalConfig.morph`, default on), three more: 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 at the container's lerped width; placeholder width is - per-form convention.** The content reflows WITH the morph — laying it - at the destination width makes the narrower container visibly crop it - mid-flight. The width circularity that reflow would otherwise cause +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 convention instead of measurement: the ghost sheet's placeholder is - `width: double.infinity` (sheets are full-bleed; the slot decides), - the dialog's placeholder takes the content's measured width (dialogs - wrap their content). Only the HEIGHT flows from the flight's - measurement. Content that defies its form's convention (fixed-width - sheet content, full-bleed dialog content) lands with a width settle — - accepted trade. 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. + by sampling: the FIRST flight layout is loose, recording the content's + natural width and whether it is full-bleed; every later layout is + tight, and the placeholder's width is the frozen sample (full-bleed → + the slot decides via `double.infinity`; self-sized → the sampled + width). 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 diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 941c658..692001d 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -193,7 +193,11 @@ class _ModalSession { final overlay = navigator.overlay; if (overlay == null) return false; final theme = Theme.of(navigator.context); - final end = ModalFormVisuals.of(theme, target); + final end = ModalFormVisuals.of( + theme, + target, + backgroundColor: config.backgroundColor, + ); final flight = _flight; if (flight != null) { @@ -217,7 +221,11 @@ class _ModalSession { _flight = ModalMorphFlight( overlay: overlay, startRect: startRect, - start: ModalFormVisuals.of(theme, outgoingMode), + start: ModalFormVisuals.of( + theme, + outgoingMode, + backgroundColor: config.backgroundColor, + ), end: end, duration: config.morphDuration, curve: config.morphCurve, @@ -311,7 +319,7 @@ class _ModalSession { isDismissible: config.barrierDismissible, enableDrag: ghost ? false : config.enableDrag, showDragHandle: ghost ? false : config.showDragHandle, - backgroundColor: ghost ? Colors.transparent : null, + backgroundColor: ghost ? Colors.transparent : config.backgroundColor, elevation: ghost ? 0 : null, clipBehavior: Clip.antiAlias, settings: settings, @@ -355,13 +363,28 @@ class _ModalScope extends StatelessWidget { final inset = session._contentInsetFor(mode, Theme.of(context)); final placeholder = ValueListenableBuilder( valueListenable: flight.contentSize, - builder: (context, size, _) => mode == ModalLayoutMode.sheet - ? SizedBox( - key: flight.placeholderKey, - width: double.infinity, - height: size.height, - ) - : SizedBox.fromSize(key: flight.placeholderKey, size: size), + builder: (context, size, _) { + // 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, + ); + }, ); content = inset == 0 ? placeholder @@ -400,6 +423,7 @@ class _ModalScope extends StatelessWidget { ) : 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 index f797853..5b2b496 100644 --- a/lib/src/core/modal/modal_config.dart +++ b/lib/src/core/modal/modal_config.dart @@ -18,6 +18,7 @@ import 'package:flutter/widgets.dart'; class ModalConfig { /// Creates a modal configuration. const ModalConfig({ + this.backgroundColor, this.barrierDismissible = true, this.barrierColor, this.useSafeArea = true, @@ -29,6 +30,14 @@ class ModalConfig { 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; diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index fc5e40c..54923ab 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -20,7 +20,11 @@ class ModalFormVisuals { /// 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) { + factory ModalFormVisuals.of( + ThemeData theme, + ModalLayoutMode mode, { + Color? backgroundColor, + }) { switch (mode) { case ModalLayoutMode.dialog: return ModalFormVisuals( @@ -28,6 +32,7 @@ class ModalFormVisuals { theme.dialogTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), color: + backgroundColor ?? theme.dialogTheme.backgroundColor ?? theme.colorScheme.surfaceContainerHigh, elevation: theme.dialogTheme.elevation ?? 6, @@ -41,6 +46,7 @@ class ModalFormVisuals { borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), color: + backgroundColor ?? theme.bottomSheetTheme.modalBackgroundColor ?? theme.bottomSheetTheme.backgroundColor ?? theme.colorScheme.surfaceContainerLow, @@ -162,6 +168,28 @@ class ModalMorphFlight { /// 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; + + void _recordSample(Size size, double containerWidth) { + if (_widthSampled) return; + _widthSampled = true; + _isFullBleed = size.width >= containerWidth - 0.5; + _naturalWidth = size.width; + } + late final AnimationController _controller; Rect _startRect; ModalFormVisuals _start; @@ -278,12 +306,17 @@ class ModalMorphFlight { padding: EdgeInsets.only(top: inset), child: OverflowBox( alignment: Alignment.topCenter, - minWidth: 0, + // First layout is loose to sample the content's + // natural width; then tight so the content + // shrinks/grows WITH the container instead of + // jumping to its destination width at takeoff. + minWidth: _widthSampled ? rect.width : 0, maxWidth: rect.width, minHeight: 0, maxHeight: maxHeight, child: _MeasureSize( onSize: (size) { + _recordSample(size, rect.width); if (contentSize.value != size) { contentSize.value = size; } diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index 05a7ffb..b9b6ad9 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -253,6 +253,52 @@ void main() { 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('the flight paints exactly like the chrome it becomes', ( tester, ) async { From 8fa37c5e0cd21830699e1420e76a2017797b866a Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 20:36:22 +0530 Subject: [PATCH 10/41] fix: sample the natural width invisibly and retake it on retarget --- docs/UPDATING.md | 17 +++++-- lib/src/core/modal/adaptive_modal.dart | 10 ++-- lib/src/core/modal/modal_morph.dart | 65 ++++++++++++++++++++------ test/core/modal/modal_morph_test.dart | 49 +++++++++++++++++++ 4 files changed, 118 insertions(+), 23 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 32e01a1..8498d45 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -108,11 +108,18 @@ With the container transform (`ModalConfig.morph`, default on), three more: 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 FIRST flight layout is loose, recording the content's - natural width and whether it is full-bleed; every later layout is - tight, and the placeholder's width is the frozen sample (full-bleed → - the slot decides via `double.infinity`; self-sized → the sampled - width). Only the HEIGHT flows from the live measurement. Related: both + 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. diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index 692001d..27288c7 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -361,9 +361,13 @@ class _ModalScope extends StatelessWidget { // the ghost route omits, so the placeholder sits exactly where // the content will land. final inset = session._contentInsetFor(mode, Theme.of(context)); - final placeholder = ValueListenableBuilder( - valueListenable: flight.contentSize, - builder: (context, size, _) { + 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 diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index 54923ab..7929549 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -183,11 +183,20 @@ class ModalMorphFlight { bool _isFullBleed = false; bool _widthSampled = false; - void _recordSample(Size size, double containerWidth) { - if (_widthSampled) return; + /// 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 = size.width >= containerWidth - 0.5; - _naturalWidth = size.width; + _isFullBleed = natural.width >= containerWidth - 0.5; + _naturalWidth = natural.width; + WidgetsBinding.instance.addPostFrameCallback((_) { + sampleRevision.value++; + }); } late final AnimationController _controller; @@ -234,6 +243,9 @@ class ModalMorphFlight { _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(); } @@ -306,17 +318,19 @@ class ModalMorphFlight { padding: EdgeInsets.only(top: inset), child: OverflowBox( alignment: Alignment.topCenter, - // First layout is loose to sample the content's - // natural width; then tight so the content - // shrinks/grows WITH the container instead of - // jumping to its destination width at takeoff. - minWidth: _widthSampled ? rect.width : 0, + // 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, maxHeight: maxHeight, child: _MeasureSize( + sampleNeeded: !_widthSampled, + onSample: _recordSample, onSize: (size) { - _recordSample(size, rect.width); if (contentSize.value != size) { contentSize.value = size; } @@ -368,33 +382,54 @@ class _FlightTickerProvider implements TickerProvider { } /// Reports the child's laid-out size, deferred to post-frame (notifying -/// listeners during layout is illegal). +/// 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.onSize, super.child}); + 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(onSize); + _RenderMeasureSize(sampleNeeded, onSample, onSize); @override void updateRenderObject( BuildContext context, _RenderMeasureSize renderObject, ) { - renderObject.onSize = onSize; + renderObject + ..sampleNeeded = sampleNeeded + ..onSample = onSample + ..onSize = onSize; } } class _RenderMeasureSize extends RenderProxyBox { - _RenderMeasureSize(this.onSize); + _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; diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index b9b6ad9..0cc0659 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -299,6 +299,55 @@ void main() { ); }); + 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('the flight paints exactly like the chrome it becomes', ( tester, ) async { From 7e950db996235c8ef6c4ea58788449e0eb8cb741 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 21:11:46 +0530 Subject: [PATCH 11/41] =?UTF-8?q?feat:=20CompactDetailMode.route=20?= =?UTF-8?q?=E2=80=94=20the=20compact=20detail=20as=20a=20real=20page=20rou?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + CHANGELOG.pre.md | 1 + README.md | 11 + docs/ARCHITECTURE.md | 15 + docs/CAPABILITY_ROADMAP.md | 2 +- docs/UPDATING.md | 43 ++- example/lib/main.dart | 346 +++++++++++++++--- example/test/journeys/journeys_test.dart | 34 ++ lib/src/core/list_detail/compact_config.dart | 7 + .../core/list_detail/detail_layout_mode.dart | 12 + .../core/list_detail/detail_page_route.dart | 43 +++ .../core/list_detail/list_detail_layout.dart | 279 +++++++++++++- .../list_detail_layout_builders.dart | 53 ++- .../paint_visibility_detector.dart | 7 + lib/src/core/modal/adaptive_modal.dart | 13 +- lib/src/core/modal/modal_morph.dart | 7 +- .../list_detail/list_detail_route_test.dart | 238 ++++++++++++ test/core/modal/modal_morph_test.dart | 44 +++ 18 files changed, 1071 insertions(+), 85 deletions(-) create mode 100644 lib/src/core/list_detail/detail_page_route.dart create mode 100644 test/core/list_detail/list_detail_route_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fe977e6..34a6b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. +- **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. - **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 effe231..03f312e 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -68,6 +68,7 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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. +- **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. - **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 9ea1b58..2237d7b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,17 @@ The detail now renders in the Navigator's overlay, sliding over everything the N 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. +And when the compact detail should be a real page — real platform transitions, **predictive back**, Cupertino edge swipes, all from your app's `PageTransitionsTheme`: + +```dart +ListDetailLayout( + compactDetailMode: CompactDetailMode.route, + ... +) +``` + +Selecting pushes a genuine page route holding the detail; back is the route's own (no interception). Resize across the breakpoint and the same detail element reparents between the route and the side-by-side pane — the state guarantee holds through real navigation. Hidden kept-alive tabs remove their route (keeping the selection) and restore it instantly when shown again. + ### Sizing the panes `PaneConfig` is pure data; the divider visual is a builder you pick or write: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10a6ae1..d962092 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -147,6 +147,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 diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index cb1db62..7742eec 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -23,7 +23,7 @@ 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 (real push/pop, preserved instance) | PLANNED | Reuses the modal's route-swap primitive; restores predictive back + platform transitions; the hard part is the pop-direction handoff (predictive-back cancel) | +| 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 | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 8498d45..1048c01 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -132,9 +132,13 @@ With the container transform (`ModalConfig.morph`, default on), three more: 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 spacer and the flight's + 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; the handle's look + 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 @@ -150,7 +154,34 @@ With the container transform (`ModalConfig.morph`, default on), three more: flips a `ValueKey` so its Material mounts fresh instead of implicitly tweening elevation after landing. -## §4 — Adding a component (divider / empty state) +## §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.** `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. + +## §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` @@ -163,7 +194,7 @@ With the container transform (`ModalConfig.morph`, default on), three more: (idle / dragging / settling for dividers). 6. Row in `CAPABILITY_ROADMAP.md`. -## §5 — 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. @@ -177,7 +208,7 @@ With the container transform (`ModalConfig.morph`, default on), three more: lie — this package once shipped `anchors` / `resizeMode` / `isSettling` unimplemented; they're real now. Don't regress the standard. -## §6 — 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`, @@ -188,7 +219,7 @@ With the container transform (`ModalConfig.morph`, default on), three more: 4. Barrel export, mirrored test folder, roadmap rows, architecture tree update. -## §7 — Changing the API surface +## §8 — Changing the API surface 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:` diff --git a/example/lib/main.dart b/example/lib/main.dart index 0003acd..a4a56fe 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -19,7 +19,8 @@ // └── 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. @@ -53,19 +54,25 @@ 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(), ), ), - routerConfig: _router.config(), ); } } @@ -75,16 +82,260 @@ 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; + 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, + anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], + ); + + 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), + ), + _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 // @@ -773,7 +1024,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...). @@ -783,10 +1034,6 @@ 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; /// When provided, auto-clears selection if the entity no longer exists. final bool Function(String id)? selectedIdExists; @@ -798,10 +1045,6 @@ 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.selectedIdExists, }); @@ -926,16 +1169,18 @@ class _ListDetailRouterState extends State { @override Widget build(BuildContext context) { + // Layout configuration comes from the live package settings (⚙ in the + // status strip) so every mode and knob is testable at runtime. + final settings = PackageSettings.instance; 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, + dividerBuilder: settings.dividerBuilder, + paneConfig: settings.paneConfig, + compactConfig: settings.compactConfig, + compactDetailMode: settings.compactDetailMode, ); } } @@ -986,20 +1231,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 @@ -1176,6 +1413,7 @@ class _MultiTypeListDetailRouterState extends State { ? widget.types[_selection.activeType] : null; + final settings = PackageSettings.instance; return ListDetailLayout( controller: _controller, listBuilder: (context, selectedId, onSelect) { @@ -1188,11 +1426,10 @@ class _MultiTypeListDetailRouterState extends State { return const SizedBox.shrink(); }, emptyStateBuilder: widget.emptyStateBuilder, - dividerBuilder: widget.dividerBuilder, - expandedBreakpoint: widget.expandedBreakpoint, - paneConfig: widget.paneConfig, - compactConfig: widget.compactConfig, - compactDetailMode: CompactDetailMode.overlay, + dividerBuilder: settings.dividerBuilder, + paneConfig: settings.paneConfig, + compactConfig: settings.compactConfig, + compactDetailMode: settings.compactDetailMode, ); } } @@ -1269,6 +1506,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(context), + ), const SizedBox(width: 4), ], ), @@ -1826,12 +2069,6 @@ 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), listBuilder: (context, selectedId, onSelect) => DemoListPane( collection: store.tickets, @@ -1841,7 +2078,7 @@ class TicketsTabScreen extends StatelessWidget { onNew: () async { final id = await showAdaptiveModal( context: context, - config: const ModalConfig(showDragHandle: true), + config: PackageSettings.instance.modalConfig, builder: (context, mode) => SizedBox( width: mode == ModalLayoutMode.dialog ? 420 : double.infinity, child: const NewTicketScreen(), @@ -1944,7 +2181,7 @@ class _TicketPaneState extends State { tooltip: 'Ticket options (modal)', onPressed: () => showAdaptiveModal( context: context, - config: const ModalConfig(showDragHandle: true), + config: PackageSettings.instance.modalConfig, builder: (context, mode) => SizedBox( width: mode == ModalLayoutMode.dialog ? 420 : double.infinity, child: TicketOptionsScreen(ticketId: widget.ticketId), @@ -2283,7 +2520,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, @@ -2542,7 +2778,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, @@ -2617,7 +2852,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', @@ -2759,7 +2993,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', @@ -2835,7 +3068,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', @@ -2926,7 +3158,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', @@ -2965,7 +3196,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/test/journeys/journeys_test.dart b/example/test/journeys/journeys_test.dart index e947676..0b7b6d0 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; @@ -226,4 +227,37 @@ void main() { ); }); }); + + group('package settings', () { + 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(); + + await tester.tap(find.text('Webhook drops events')); + await tester.pumpAndSettle(); + expect(find.byType(TicketPane), findsOneWidget); + expect(rootRouter(tester).currentPath, '/work/tickets/ticket-hooks'); + + // 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/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..dd6f776 100644 --- a/lib/src/core/list_detail/detail_layout_mode.dart +++ b/lib/src/core/list_detail/detail_layout_mode.dart @@ -23,4 +23,16 @@ enum CompactDetailMode { /// detail in the widget tree (state preserved via GlobalKey). /// Which overlay is controlled by `CompactConfig.useRootOverlay`. overlay, + + /// Detail is pushed as a REAL page route on the Navigator. + /// + /// Covers everything below the route (bottom nav, tab bars) and inherits + /// the app's `PageTransitionsTheme` — platform transitions, predictive + /// back, Cupertino edge swipes. Back is handled by the route itself + /// (`CompactConfig.handleBackGesture` and the slide/swipe machinery are + /// not used in this mode). Which navigator receives the route is + /// controlled by `CompactConfig.useRootNavigator`. State is preserved + /// across compact ↔ expanded resizes by reparenting the detail element + /// between the route and the expanded pane. + 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/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 8c165f9..ee9805b 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -5,6 +5,7 @@ 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/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'; @@ -125,6 +126,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,7 +140,8 @@ 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; @override @@ -242,6 +251,45 @@ 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 _wasExpandedForBridge = false; + bool _routePaintCheckArmed = false; + // =========================================================================== // LIFECYCLE // =========================================================================== @@ -282,6 +330,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 @@ -321,6 +377,11 @@ class _ListDetailLayoutState extends State> referenceWidth: _referenceWidth, ); } + + if (widget.compactDetailMode != oldWidget.compactDetailMode && + oldWidget.compactDetailMode == CompactDetailMode.route) { + _teardownDetailRoute(); + } } /// Reference width for ratio conversion — the expanded breakpoint, since @@ -331,6 +392,8 @@ class _ListDetailLayoutState extends State> @override void dispose() { + if (_useRoute) _paintVisibility.notifier.removeListener(_scheduleRouteSync); + _teardownDetailRoute(); _controller.removeListener(_onControllerChanged); _ownedController?.dispose(); _slideController.dispose(); @@ -339,6 +402,23 @@ class _ListDetailLayoutState extends State> 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,6 +449,18 @@ 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; @@ -397,15 +489,168 @@ 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 || _detailRoute == null) return; + if (!_paintVisibility.paintedThisFrame && + _paintVisibility.notifier.value) { + _paintVisibility.notifier.value = false; + _scheduleRouteSync(); + } + }); + } + + void _scheduleRouteSync() { + if (!_useRoute || _routeSyncScheduled) return; + _routeSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _routeSyncScheduled = false; + if (mounted) _syncDetailRoute(); + }); + } + + /// Reconciles the pushed route with the desired state — the ONLY place + /// route-mode navigation happens, always post-frame, never during build. + void _syncDetailRoute() { + final painted = _paintVisibility.notifier.value; + 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. + _instantRoutePush = true; + _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; + _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 // =========================================================================== @@ -560,6 +805,32 @@ class _ListDetailLayoutState extends State> ); } + if (_useRoute) { + _paintVisibility.evaluate(); + // One-shot: after THIS frame's paint, verify the layout actually + // painted. A build pass that ends unpainted means the tab was + // hidden under the route (URL navigation) — suppress it. Armed + // only by build passes, so clean idle frames never false-trigger. + if (_detailRoute != null) _armRoutePaintCheck(); + if (isExpanded) { + _bridgeDetail = false; + } else if (_wasExpandedForBridge && _controller.hasSelection) { + // Resize into compact with an open detail: bridge the frame + // until the instant push lands, so the list never flashes. + _bridgeDetail = true; + _instantRoutePush = true; + } + _wasExpandedForBridge = isExpanded; + _scheduleRouteSync(); + final innerLayout = isExpanded + ? buildExpandedLayout(constraints) + : buildCompactRouteList(); + return PaintVisibilityObserver( + detector: _paintVisibility, + child: innerLayout, + ); + } + return isExpanded ? 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..54d8aaa 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -30,15 +30,24 @@ extension _LayoutBuilders on _ListDetailLayoutState { child: widget.listBuilder(context, selectedId, _handleSelect), ), Expanded( + // 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. child: selectedId != null - ? KeyedSubtree( - key: _detailKey, - child: widget.detailBuilder( - context, - selectedId, - DetailLayoutMode.sideBySide, - _handleDismiss, - ), + ? ValueListenableBuilder( + valueListenable: _detailRouted, + builder: (context, routed, _) => routed + ? const SizedBox.shrink() + : KeyedSubtree( + key: _detailKey, + child: widget.detailBuilder( + context, + selectedId, + DetailLayoutMode.sideBySide, + _handleDismiss, + ), + ), ) : widget.emptyStateBuilder?.call(context) ?? const SizedBox.shrink(), @@ -166,7 +175,33 @@ 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 instant push: render the detail inline under its key so the + // list never flashes. The push claims the key post-frame. + return KeyedSubtree( + key: _detailKey, + child: widget.detailBuilder( + context, + selectedId, + DetailLayoutMode.stacked, + _handleDismiss, + ), + ); + } + return widget.listBuilder(context, selectedId, _handleSelect); + } + + // =========================================================================== + // SHARED SLIDING DETAIL (used by inline and overlay compact modes) // =========================================================================== /// The sliding detail pane with swipe-to-dismiss gesture. 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 index 27288c7..be5d230 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -390,14 +390,15 @@ class _ModalScope extends StatelessWidget { ); }, ); + // 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 - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: inset), - placeholder, - ], + : Padding( + padding: EdgeInsets.only(top: inset), + child: placeholder, ); } else { content = KeyedSubtree( diff --git a/lib/src/core/modal/modal_morph.dart b/lib/src/core/modal/modal_morph.dart index 7929549..baafac3 100644 --- a/lib/src/core/modal/modal_morph.dart +++ b/lib/src/core/modal/modal_morph.dart @@ -326,7 +326,12 @@ class ModalMorphFlight { minWidth: rect.width, maxWidth: rect.width, minHeight: 0, - maxHeight: maxHeight, + // 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, 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..5e3ea65 --- /dev/null +++ b/test/core/list_detail/list_detail_route_test.dart @@ -0,0 +1,238 @@ +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('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); + + 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); + }); +} diff --git a/test/core/modal/modal_morph_test.dart b/test/core/modal/modal_morph_test.dart index 0cc0659..e4d577a 100644 --- a/test/core/modal/modal_morph_test.dart +++ b/test/core/modal/modal_morph_test.dart @@ -348,6 +348,50 @@ void main() { 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 { From ffb6cf4ecc63f9c53018fdd633034700fc5d0ea2 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 21:18:45 +0530 Subject: [PATCH 12/41] =?UTF-8?q?fix:=20settings=20changes=20reach=20route?= =?UTF-8?q?d=20subtrees=20=E2=80=94=20each=20layout=20listens=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/main.dart | 66 ++++++++++++++---------- example/test/journeys/journeys_test.dart | 9 ++++ 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index a4a56fe..f426f61 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1170,17 +1170,24 @@ class _ListDetailRouterState extends State { @override Widget build(BuildContext context) { // Layout configuration comes from the live package settings (⚙ in the - // status strip) so every mode and knob is testable at runtime. - final settings = PackageSettings.instance; - return ListDetailLayout( - controller: _controller, - listBuilder: widget.listBuilder, - detailBuilder: widget.detailBuilder, - emptyStateBuilder: widget.emptyStateBuilder, - dividerBuilder: settings.dividerBuilder, - paneConfig: settings.paneConfig, - compactConfig: settings.compactConfig, - compactDetailMode: settings.compactDetailMode, + // 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; + return ListDetailLayout( + controller: _controller, + listBuilder: widget.listBuilder, + detailBuilder: widget.detailBuilder, + emptyStateBuilder: widget.emptyStateBuilder, + dividerBuilder: settings.dividerBuilder, + paneConfig: settings.paneConfig, + compactConfig: settings.compactConfig, + compactDetailMode: settings.compactDetailMode, + ); + }, ); } } @@ -1413,23 +1420,28 @@ class _MultiTypeListDetailRouterState extends State { ? widget.types[_selection.activeType] : null; - 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(); + 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: settings.dividerBuilder, - paneConfig: settings.paneConfig, - compactConfig: settings.compactConfig, - compactDetailMode: settings.compactDetailMode, ); } } diff --git a/example/test/journeys/journeys_test.dart b/example/test/journeys/journeys_test.dart index 0b7b6d0..bf5118e 100644 --- a/example/test/journeys/journeys_test.dart +++ b/example/test/journeys/journeys_test.dart @@ -247,10 +247,19 @@ void main() { 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); // REAL system back pops the real route; the URL syncs through the // controller exactly as in the other modes. From 99c6eda70a713acb1f372acdaa5f8110b9f11480 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 21:36:15 +0530 Subject: [PATCH 13/41] =?UTF-8?q?fix:=20route=20re-show=20after=20hidden?= =?UTF-8?q?=20resize=20=E2=80=94=20schedule=20the=20frame,=20trust=20same-?= =?UTF-8?q?frame=20paint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/UPDATING.md | 9 ++++ .../core/list_detail/list_detail_layout.dart | 10 +++- .../list_detail/list_detail_route_test.dart | 48 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 1048c01..f4189f6 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -180,6 +180,15 @@ The machinery in `list_detail_layout.dart` holds: 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. +7. **The re-show chain schedules its own frame and trusts same-frame + paint.** `_scheduleRouteSync` calls `ensureVisualUpdate()` — the + paint-probe listener fires inside another frame's post-frame flush, + where a queued callback waits for a frame nothing else schedules; an + idle device stalls forever showing the inline bridge. And the sync + reads `notifier.value || paintedThisFrame`: the notifier flips true + one deferred callback late, so the flag is what lets the route push + in the very flush where paint resumed (repro: resize to compact while + the tab is hidden, then return to the tab). ## §5 — Adding a component (divider / empty state) diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index ee9805b..6a2cda3 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -519,12 +519,20 @@ class _ListDetailLayoutState extends State> _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() { - final painted = _paintVisibility.notifier.value; + // paintedThisFrame covers the frame where paint just resumed: the + // notifier only flips true a deferred callback later, and waiting for + // it costs extra frames of the inline bridge on screen. + final painted = + _paintVisibility.notifier.value || _paintVisibility.paintedThisFrame; final wantRoute = !_isExpanded && _controller.hasSelection && painted; final route = _detailRoute; diff --git a/test/core/list_detail/list_detail_route_test.dart b/test/core/list_detail/list_detail_route_test.dart index 5e3ea65..77620ac 100644 --- a/test/core/list_detail/list_detail_route_test.dart +++ b/test/core/list_detail/list_detail_route_test.dart @@ -198,6 +198,54 @@ void main() { expect(_navigator(tester).canPop(), isFalse); // it was a single route }); + 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 { From 1789a5269e744a8039e0c7f186e17c161ba8cee1 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 22:20:43 +0530 Subject: [PATCH 14/41] fix: route mode survives hidden-tab breakpoint crossings and live mode flips --- README.md | 4 +- docs/UPDATING.md | 55 +++++++++--- example/lib/main.dart | 3 + example/test/journeys/journeys_test.dart | 86 +++++++++++++++++++ .../core/list_detail/list_detail_layout.dart | 85 ++++++++++++++---- .../list_detail/list_detail_route_test.dart | 82 ++++++++++++++++++ 6 files changed, 285 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2237d7b..979540a 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,9 @@ ListDetailLayout( ) ``` -Selecting pushes a genuine page route holding the detail; back is the route's own (no interception). Resize across the breakpoint and the same detail element reparents between the route and the side-by-side pane — the state guarantee holds through real navigation. Hidden kept-alive tabs remove their route (keeping the selection) and restore it instantly when shown again. +Selecting pushes a genuine page route holding the detail; back is the route's own (no interception). Resize across the breakpoint and the same detail element reparents between the route and the side-by-side pane — the state guarantee holds through real navigation. Hidden kept-alive tabs remove their route (keeping the selection and the detail's state) and restore it instantly when shown again — including when the breakpoint crossing itself happens while the tab is hidden. + +One app-side note: every route push makes Flutter scan the shell for `Hero` tags, kept-alive tabs included. If several `FloatingActionButton`s coexist under one page (one per tab), give them explicit `heroTag`s — the shared default tag asserts on the first push. ### Sizing the panes diff --git a/docs/UPDATING.md b/docs/UPDATING.md index f4189f6..00a2459 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -175,20 +175,47 @@ The machinery in `list_detail_layout.dart` holds: 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.** `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. -7. **The re-show chain schedules its own frame and trusts same-frame - paint.** `_scheduleRouteSync` calls `ensureVisualUpdate()` — the - paint-probe listener fires inside another frame's post-frame flush, - where a queued callback waits for a frame nothing else schedules; an - idle device stalls forever showing the inline bridge. And the sync - reads `notifier.value || paintedThisFrame`: the notifier flips true - one deferred callback late, so the flag is what lets the route push - in the very flush where paint resumed (repro: resize to compact while - the tab is hidden, then return to the tab). +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. **`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. ## §5 — Adding a component (divider / empty state) diff --git a/example/lib/main.dart b/example/lib/main.dart index f426f61..92d0039 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1711,6 +1711,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), ) diff --git a/example/test/journeys/journeys_test.dart b/example/test/journeys/journeys_test.dart index bf5118e..fd3dd6b 100644 --- a/example/test/journeys/journeys_test.dart +++ b/example/test/journeys/journeys_test.dart @@ -229,6 +229,92 @@ void main() { }); 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 { diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 6a2cda3..0ab631a 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -290,6 +290,13 @@ class _ListDetailLayoutState extends State> bool _wasExpandedForBridge = false; bool _routePaintCheckArmed = false; + /// 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 // =========================================================================== @@ -378,9 +385,39 @@ class _ListDetailLayoutState extends State> ); } - if (widget.compactDetailMode != oldWidget.compactDetailMode && - oldWidget.compactDetailMode == CompactDetailMode.route) { - _teardownDetailRoute(); + 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; + } } } @@ -392,7 +429,9 @@ class _ListDetailLayoutState extends State> @override void dispose() { - if (_useRoute) _paintVisibility.notifier.removeListener(_scheduleRouteSync); + // 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(); @@ -503,9 +542,14 @@ class _ListDetailLayoutState extends State> _routePaintCheckArmed = true; WidgetsBinding.instance.addPostFrameCallback((_) { _routePaintCheckArmed = false; - if (!mounted || _detailRoute == null) return; + 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(); } @@ -528,11 +572,17 @@ class _ListDetailLayoutState extends State> /// Reconciles the pushed route with the desired state — the ONLY place /// route-mode navigation happens, always post-frame, never during build. void _syncDetailRoute() { - // paintedThisFrame covers the frame where paint just resumed: the - // notifier only flips true a deferred callback later, and waiting for - // it costs extra frames of the inline bridge on screen. - final painted = - _paintVisibility.notifier.value || _paintVisibility.paintedThisFrame; + // 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; @@ -547,8 +597,11 @@ class _ListDetailLayoutState extends State> } 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. + // 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; _removeDetailRoute(route); } else if (_isExpanded) { // Resize into expanded: the pane claims the key in the same @@ -815,11 +868,13 @@ class _ListDetailLayoutState extends State> 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 tab was - // hidden under the route (URL navigation) — suppress it. Armed - // only by build passes, so clean idle frames never false-trigger. - if (_detailRoute != null) _armRoutePaintCheck(); + // 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; } else if (_wasExpandedForBridge && _controller.hasSelection) { diff --git a/test/core/list_detail/list_detail_route_test.dart b/test/core/list_detail/list_detail_route_test.dart index 77620ac..fdbcf7d 100644 --- a/test/core/list_detail/list_detail_route_test.dart +++ b/test/core/list_detail/list_detail_route_test.dart @@ -198,6 +198,82 @@ void main() { 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 { @@ -269,6 +345,9 @@ void main() { 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(); @@ -282,5 +361,8 @@ void main() { 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); }); } From 55927d507f8ac648aeb18335d298c6b6cc4dc5f7 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 22:31:40 +0530 Subject: [PATCH 15/41] docs: per-mode contracts on CompactDetailMode + README mode-picker table --- README.md | 15 ++++ .../core/list_detail/detail_layout_mode.dart | 69 +++++++++++++++---- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 979540a..5f1daee 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,21 @@ Selecting pushes a genuine page route holding the detail; back is the route's ow One app-side note: every route push makes Flutter scan the shell for `Hero` tags, kept-alive tabs included. If several `FloatingActionButton`s coexist under one page (one per tab), give them explicit `heroTag`s — the shared default tag asserts on the first push. +### Picking a compact detail mode + +All three modes keep the state guarantee across resizes. They differ in what the open detail covers and who owns the back gesture: + +| | `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 | +| Hero discipline (`heroTag`s) | not needed | not needed | needed | + +Rule of thumb: `inline` when the surrounding chrome should stay present, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page and inherit every platform back-gesture convention as it evolves. The per-value doc comments on `CompactDetailMode` carry the full contracts. + ### Sizing the panes `PaneConfig` is pure data; the divider visual is a builder you pick or write: diff --git a/lib/src/core/list_detail/detail_layout_mode.dart b/lib/src/core/list_detail/detail_layout_mode.dart index dd6f776..7df08c2 100644 --- a/lib/src/core/list_detail/detail_layout_mode.dart +++ b/lib/src/core/list_detail/detail_layout_mode.dart @@ -11,28 +11,67 @@ 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. + /// + /// 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. + /// + /// 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 below the route (bottom nav, tab bars) and inherits - /// the app's `PageTransitionsTheme` — platform transitions, predictive - /// back, Cupertino edge swipes. Back is handled by the route itself - /// (`CompactConfig.handleBackGesture` and the slide/swipe machinery are - /// not used in this mode). Which navigator receives the route is - /// controlled by `CompactConfig.useRootNavigator`. State is preserved - /// across compact ↔ expanded resizes by reparenting the detail element - /// between the route and the expanded pane. + /// - 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. + /// + /// Pick when the detail should feel like native navigation and evolve + /// with the platform's back-gesture conventions. route, } From 5e3d7d239fd0acde37243c73a6eb580deaca2a55 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 22:36:57 +0530 Subject: [PATCH 16/41] =?UTF-8?q?feat:=20a11y=20route=20parity=20for=20inl?= =?UTF-8?q?ine/overlay=20details=20=E2=80=94=20semantics=20scoping,=20bloc?= =?UTF-8?q?ked=20covered=20content,=20Escape=20dismissal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + README.md | 2 + docs/CAPABILITY_ROADMAP.md | 1 + .../core/list_detail/detail_layout_mode.dart | 9 + .../list_detail_layout_builders.dart | 62 +++++-- .../list_detail_semantics_test.dart | 161 ++++++++++++++++++ 6 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 test/core/list_detail/list_detail_semantics_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a6b96..25ee8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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. - **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. - **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 5f1daee..bc2bfa9 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ All three modes keep the state guarantee across resizes. They differ in what the | 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` | | Hero discipline (`heroTag`s) | not needed | not needed | needed | Rule of thumb: `inline` when the surrounding chrome should stay present, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page and inherit every platform back-gesture convention as it evolves. The per-value doc comments on `CompactDetailMode` carry the full contracts. diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 7742eec..7d19ffb 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -26,6 +26,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | ## AdaptiveSplit diff --git a/lib/src/core/list_detail/detail_layout_mode.dart b/lib/src/core/list_detail/detail_layout_mode.dart index 7df08c2..82419ac 100644 --- a/lib/src/core/list_detail/detail_layout_mode.dart +++ b/lib/src/core/list_detail/detail_layout_mode.dart @@ -28,6 +28,10 @@ enum CompactDetailMode { /// 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. /// /// Pick when the detail is part of the screen and the surrounding /// chrome should stay present. @@ -46,6 +50,11 @@ enum CompactDetailMode { /// 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. /// /// Pick for full-screen details with instant resize morphs, when the /// page's own snackbars/FABs are not needed while a detail is open. 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 54d8aaa..af95768 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -98,9 +98,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: widget.listBuilder(context, selectedId, _handleSelect), + ), ), // Detail — slides over list, stays during dismiss animation if (showDetail) Positioned.fill(child: buildSlidingDetail()), @@ -141,8 +147,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()); }, ); }, @@ -217,17 +229,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/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..fc34d0a --- /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.hasFlag(SemanticsFlag.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(); + }); +} From 07588f9162cd97f0edbe6085df11d6a0a8bbcd2b Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 22:39:00 +0530 Subject: [PATCH 17/41] test: use flagsCollection over deprecated hasFlag --- test/core/list_detail/list_detail_semantics_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/list_detail/list_detail_semantics_test.dart b/test/core/list_detail/list_detail_semantics_test.dart index fc34d0a..d74085d 100644 --- a/test/core/list_detail/list_detail_semantics_test.dart +++ b/test/core/list_detail/list_detail_semantics_test.dart @@ -91,7 +91,7 @@ void main() { bool scoped = false; void walk(SemanticsNode node) { - if (node.hasFlag(SemanticsFlag.scopesRoute)) scoped = true; + if (node.flagsCollection.scopesRoute) scoped = true; node.visitChildren((child) { walk(child); return true; From 14b4c917f6922f5a56af314396989bb8392c21ed Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sat, 18 Jul 2026 23:20:04 +0530 Subject: [PATCH 18/41] =?UTF-8?q?feat:=20crossing=20motion=20on=20discrete?= =?UTF-8?q?=20breakpoint=20jumps=20=E2=80=94=20pane=20growth=20or=20real?= =?UTF-8?q?=20route=20entrance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + README.md | 3 + docs/CAPABILITY_ROADMAP.md | 1 + docs/UPDATING.md | 15 +- .../core/list_detail/detail_layout_mode.dart | 10 ++ .../core/list_detail/list_detail_layout.dart | 74 ++++++++-- .../list_detail_layout_builders.dart | 19 ++- .../list_detail/breakpoint_motion_test.dart | 135 ++++++++++++++++++ 8 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 test/core/list_detail/breakpoint_motion_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ee8c0..7afb42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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:** discrete breakpoint jumps (fold, rotation, split-screen snap) animate — the detail grows out of its pane (inline/overlay) or plays the route's real entrance (route mode); continuous window drags track the hand with a clean cut. - **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. - **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 bc2bfa9..c1a69d7 100644 --- a/README.md +++ b/README.md @@ -149,10 +149,13 @@ All three modes keep the state guarantee across resizes. They differ in what the | 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` | +| Discrete breakpoint jump (fold, rotation) | detail grows out of its pane | detail grows out of its pane | the route's real entrance plays | | Hero discipline (`heroTag`s) | not needed | not needed | needed | Rule of thumb: `inline` when the surrounding chrome should stay present, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page and inherit every platform back-gesture convention as it evolves. The per-value doc comments on `CompactDetailMode` carry the full contracts. +Breakpoint crossings distinguish HOW the window changed: a continuous drag tracks your hand with a clean cut (animating against an active resize fights the user), while a discrete jump — fold/unfold, rotation, split-screen snap — gets crossing motion in every mode. + ### Sizing the panes `PaneConfig` is pure data; the divider visual is a builder you pick or write: diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 7d19ffb..ac5204d 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -27,6 +27,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 (discrete jumps) | DONE | Fold/rotation/split-snap: inline/overlay grow the detail out of its pane; route plays its real entrance. Continuous drags cut (track the hand) | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | ## AdaptiveSplit diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 00a2459..d7ffd30 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -207,7 +207,20 @@ The machinery in `list_detail_layout.dart` holds: 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. **`didUpdateWidget` mirrors initState's per-mode wiring.** Mode flips +9. **Crossing motion is discrete-only, and the offstage bridge is the + route's handoff.** A breakpoint crossing animates only when its + one-frame width delta reaches `_discreteResizeDelta` (fold / + rotation / split-snap); continuous drags cut — animating against an + active resize fights the hand. 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. + Hidden layouts (paint-probe false) always take the instant path — an + entrance nobody watches is wasted, and the re-show contract expects + instant. +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 diff --git a/lib/src/core/list_detail/detail_layout_mode.dart b/lib/src/core/list_detail/detail_layout_mode.dart index 82419ac..899440e 100644 --- a/lib/src/core/list_detail/detail_layout_mode.dart +++ b/lib/src/core/list_detail/detail_layout_mode.dart @@ -32,6 +32,9 @@ enum CompactDetailMode { /// 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: a discrete jump (fold, + /// rotation, split-screen snap) grows the detail out of its pane to + /// full width; a continuous window drag tracks the hand — no motion. /// /// Pick when the detail is part of the screen and the surrounding /// chrome should stay present. @@ -55,6 +58,9 @@ enum CompactDetailMode { /// (`BlockSemantics` — the Drawer/ModalBarrier primitive), and /// `DismissIntent` (Escape on desktop) dismisses when the focus is /// inside the detail. + /// - Breakpoint crossings with an open detail: a discrete jump (fold, + /// rotation, split-screen snap) grows the detail out of its pane to + /// full width; a continuous window drag tracks the hand — no motion. /// /// Pick for full-screen details with instant resize morphs, when the /// page's own snackbars/FABs are not needed while a detail is open. @@ -79,6 +85,10 @@ enum CompactDetailMode { /// 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: a discrete jump (fold, + /// rotation, split-screen snap) plays the route's REAL entrance over + /// the list; a continuous window drag pushes with zero entrance — + /// tracking the hand, not animating against it. /// /// Pick when the detail should feel like native navigation and evolve /// with the platform's back-gesture conventions. diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 0ab631a..26c0e12 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -46,6 +46,12 @@ typedef DetailPaneBuilder = // DividerBuilder is in shared/typedefs.dart — re-exported from barrel. +/// A breakpoint crossing whose one-frame width delta reaches this many +/// logical pixels is a discrete jump (fold/unfold, rotation, split-screen +/// snap) and gets crossing motion. Continuous window drags deliver far +/// smaller per-frame deltas — those must track the hand, never animate. +const double _discreteResizeDelta = 64.0; + // ============================================================================= // WIDGET // ============================================================================= @@ -287,9 +293,22 @@ class _ListDetailLayoutState extends State> bool _instantRoutePush = false; bool _routeSyncScheduled = false; - bool _wasExpandedForBridge = 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). + bool _wasExpandedLastBuild = false; + + /// Width of the previous build. A crossing whose one-frame delta + /// reaches [_discreteResizeDelta] is a discrete jump (fold, rotation, + /// split-screen snap) and gets crossing motion; smaller deltas are a + /// continuous drag tracking the user's hand — those cut. + double? _lastBuildWidth; + /// 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 @@ -602,6 +621,7 @@ class _ListDetailLayoutState extends State> // 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 @@ -627,6 +647,7 @@ class _ListDetailLayoutState extends State> _routeNavigator = navigator; _detailRouted.value = true; _bridgeDetail = false; + _bridgeOffstage = false; _instantRoutePush = false; unawaited( route.popped.then((_) { @@ -838,13 +859,36 @@ class _ListDetailLayoutState extends State> final isExpanded = constraints.maxWidth >= breakpoint; _isExpanded = isExpanded; + final crossedIntoCompact = _wasExpandedLastBuild && !isExpanded; + final lastWidth = _lastBuildWidth; + final discreteCrossing = + crossedIntoCompact && + lastWidth != null && + (constraints.maxWidth - lastWidth).abs() >= _discreteResizeDelta; + _wasExpandedLastBuild = isExpanded; + _lastBuildWidth = constraints.maxWidth; + // 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. + // Crossing into compact with an open detail (inline/overlay). + // Discrete jump: the detail GROWS out of its pane — the slide + // starts with its leading edge at the old divider position and + // settles to full width. Continuous drag: snap fully open; the + // detail was already visible and motion would fight the hand. + if (!_useRoute && crossedIntoCompact && _controller.hasSelection) { + if (discreteCrossing) { + final listWidth = _paneWidth.width(_lastExpandedWidth); + _slideController.value = (1 - listWidth / constraints.maxWidth) + .clamp(0.0, 1.0); + unawaited(_slideController.forward()); + } else { + _slideController.value = 1.0; + } + } + + // 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; @@ -877,13 +921,23 @@ class _ListDetailLayoutState extends State> _armRoutePaintCheck(); if (isExpanded) { _bridgeDetail = false; - } else if (_wasExpandedForBridge && _controller.hasSelection) { - // Resize into compact with an open detail: bridge the frame - // until the instant push lands, so the list never flashes. + _bridgeOffstage = false; + } else if (crossedIntoCompact && _controller.hasSelection) { + // Resize into compact with an open detail: the bridge holds + // the detail key until the push claims it. Discrete jump 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. Anything + // else: visible bridge + instant push, so nothing flashes. _bridgeDetail = true; - _instantRoutePush = true; + if (discreteCrossing && _paintVisibility.notifier.value) { + _bridgeOffstage = true; + _instantRoutePush = false; + } else { + _bridgeOffstage = false; + _instantRoutePush = true; + } } - _wasExpandedForBridge = isExpanded; _scheduleRouteSync(); final innerLayout = isExpanded ? buildExpandedLayout(constraints) 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 af95768..7072626 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -197,9 +197,9 @@ extension _LayoutBuilders on _ListDetailLayoutState { final selectedId = _controller.selectedId; if (_bridgeDetail && !_detailRouted.value && selectedId != null) { // The frame between a resize into compact (or a deep-link mount) and - // the instant push: render the detail inline under its key so the - // list never flashes. The push claims the key post-frame. - return KeyedSubtree( + // 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, @@ -208,6 +208,19 @@ extension _LayoutBuilders on _ListDetailLayoutState { _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: widget.listBuilder(context, selectedId, _handleSelect), + ), + Offstage(child: keyed), + ], + ); + } + return keyed; } return widget.listBuilder(context, selectedId, _handleSelect); } 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..82fb7b4 --- /dev/null +++ b/test/core/list_detail/breakpoint_motion_test.dart @@ -0,0 +1,135 @@ +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 _detail = find.text('detail-a (stacked)'); + +/// A crossing delivered as many small per-frame deltas — a window drag. +Future _continuousResize( + WidgetTester tester, + double from, + double to, +) async { + var width = from; + while (width > to) { + width = (width - 30).clamp(to, from); + tester.view.physicalSize = Size(width, 800); + await tester.pump(); + } +} + +void main() { + for (final mode in [CompactDetailMode.inline, CompactDetailMode.overlay]) { + testWidgets( + '${mode.name}: discrete jump 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(); + + // One-frame jump across the breakpoint (fold / rotation shape). + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final midMorph = tester.getTopLeft(_detail).dx; + await tester.pumpAndSettle(); + final settled = tester.getTopLeft(_detail).dx; + + expect(midMorph, greaterThan(settled)); // it slid in from the pane edge + expect(find.text('count: 2'), findsOneWidget); // state rode the morph + }, + ); + + testWidgets('${mode.name}: continuous drag across the breakpoint cuts', ( + tester, + ) async { + await pumpApp(tester, _layout(mode), size: const Size(1000, 800)); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + await _continuousResize(tester, 1000, 700); + + // No entrance animation is running — the detail is simply there. + expect(tester.hasRunningAnimations, isFalse); + final immediate = tester.getTopLeft(_detail).dx; + await tester.pumpAndSettle(); + expect(tester.getTopLeft(_detail).dx, immediate); + }); + } + + testWidgets('route: discrete jump 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(); + + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); // crossing frame: list + offstage bridge, push queued + 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(_detail))!; + expect(route.animation!.status, AnimationStatus.forward); + + await tester.pumpAndSettle(); + expect(route.animation!.status, AnimationStatus.completed); + expect(find.text('count: 2'), findsOneWidget); + }); + + testWidgets('route: continuous drag across the breakpoint pushes instantly', ( + tester, + ) async { + await pumpApp( + tester, + _layout(CompactDetailMode.route), + size: const Size(1000, 800), + ); + await tester.tap(find.text('item-a')); + await tester.pumpAndSettle(); + + await _continuousResize(tester, 1000, 700); + await tester.pump(); + + final route = ModalRoute.of(tester.element(_detail))!; + expect(route.animation!.status, AnimationStatus.completed); + }); +} From 1b77b1b1ffee6fd83ad2bc874d40c104827e6b31 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 00:40:05 +0530 Subject: [PATCH 19/41] feat: crossing motion on every breakpoint crossing, both directions --- CHANGELOG.md | 2 +- README.md | 5 +- docs/CAPABILITY_ROADMAP.md | 2 +- docs/UPDATING.md | 30 ++-- .../core/list_detail/detail_layout_mode.dart | 20 +-- .../core/list_detail/list_detail_layout.dart | 88 +++++----- .../list_detail_layout_builders.dart | 18 ++- .../list_detail/breakpoint_motion_test.dart | 151 +++++++++++------- 8 files changed, 195 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7afb42d..852e316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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:** discrete breakpoint jumps (fold, rotation, split-screen snap) animate — the detail grows out of its pane (inline/overlay) or plays the route's real entrance (route mode); continuous window drags track the hand with a clean cut. +- **Crossing motion:** breakpoint crossings with an open detail animate in both directions — 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. Pane geometry keeps tracking window drags without motion. - **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. - **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 c1a69d7..8282ba5 100644 --- a/README.md +++ b/README.md @@ -149,12 +149,13 @@ All three modes keep the state guarantee across resizes. They differ in what the | 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` | -| Discrete breakpoint jump (fold, rotation) | detail grows out of its pane | detail grows out of its pane | the route's real entrance plays | +| 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 | Rule of thumb: `inline` when the surrounding chrome should stay present, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page and inherit every platform back-gesture convention as it evolves. The per-value doc comments on `CompactDetailMode` carry the full contracts. -Breakpoint crossings distinguish HOW the window changed: a continuous drag tracks your hand with a clean cut (animating against an active resize fights the user), while a discrete jump — fold/unfold, rotation, split-screen snap — gets crossing motion in every mode. +Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfold, rotation, split-screen snap, or dragging the window edge across the threshold. Only the pane geometry itself tracks the drag without motion (a lagging pane would fight your hand); the arrangement flip is always animated, the way Compose's canonical scaffolds and desktop sidebars behave. ### Sizing the panes diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index ac5204d..7b6ef75 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -27,7 +27,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 (discrete jumps) | DONE | Fold/rotation/split-snap: inline/overlay grow the detail out of its pane; route plays its real entrance. Continuous drags cut (track the hand) | +| 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 | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | ## AdaptiveSplit diff --git a/docs/UPDATING.md b/docs/UPDATING.md index d7ffd30..a4b6f1c 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -207,19 +207,27 @@ The machinery in `list_detail_layout.dart` holds: 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. **Crossing motion is discrete-only, and the offstage bridge is the - route's handoff.** A breakpoint crossing animates only when its - one-frame width delta reaches `_discreteResizeDelta` (fold / - rotation / split-snap); continuous drags cut — animating against an - active resize fights the hand. 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. +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. + instant. A first build is never a crossing (`_hasBuiltOnce`) — deep + links render settled. 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 diff --git a/lib/src/core/list_detail/detail_layout_mode.dart b/lib/src/core/list_detail/detail_layout_mode.dart index 899440e..b5508cb 100644 --- a/lib/src/core/list_detail/detail_layout_mode.dart +++ b/lib/src/core/list_detail/detail_layout_mode.dart @@ -32,9 +32,9 @@ enum CompactDetailMode { /// 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: a discrete jump (fold, - /// rotation, split-screen snap) grows the detail out of its pane to - /// full width; a continuous window drag tracks the hand — no motion. + /// - 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. @@ -58,9 +58,9 @@ enum CompactDetailMode { /// (`BlockSemantics` — the Drawer/ModalBarrier primitive), and /// `DismissIntent` (Escape on desktop) dismisses when the focus is /// inside the detail. - /// - Breakpoint crossings with an open detail: a discrete jump (fold, - /// rotation, split-screen snap) grows the detail out of its pane to - /// full width; a continuous window drag tracks the hand — no motion. + /// - 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. @@ -85,10 +85,10 @@ enum CompactDetailMode { /// 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: a discrete jump (fold, - /// rotation, split-screen snap) plays the route's REAL entrance over - /// the list; a continuous window drag pushes with zero entrance — - /// tracking the hand, not animating against it. + /// - 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. diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 26c0e12..7b018a0 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -46,12 +46,6 @@ typedef DetailPaneBuilder = // DividerBuilder is in shared/typedefs.dart — re-exported from barrel. -/// A breakpoint crossing whose one-frame width delta reaches this many -/// logical pixels is a discrete jump (fold/unfold, rotation, split-screen -/// snap) and gets crossing motion. Continuous window drags deliver far -/// smaller per-frame deltas — those must track the hand, never animate. -const double _discreteResizeDelta = 64.0; - // ============================================================================= // WIDGET // ============================================================================= @@ -300,14 +294,16 @@ class _ListDetailLayoutState extends State> bool _bridgeOffstage = false; /// Expanded/compact state of the previous build — a flip marks the - /// breakpoint-crossing frame (all modes). + /// 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; - /// Width of the previous build. A crossing whose one-frame delta - /// reaches [_discreteResizeDelta] is a discrete jump (fold, rotation, - /// split-screen snap) and gets crossing motion; smaller deltas are a - /// continuous drag tracking the user's hand — those cut. - double? _lastBuildWidth; + /// 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; /// True between a route-mode build (which ran `evaluate()`) and the /// sync that consumes it. In such a frame `paintedThisFrame` is the @@ -329,6 +325,14 @@ class _ListDetailLayoutState extends State> duration: widget.compactConfig.duration, ); _slideAnimation = _buildSlideAnimation(); + // No listener: buildExpandedLayout wraps itself in an AnimatedBuilder + // on this controller — a setState listener would fire during build + // when a crossing frame seeds the value. + _expandEntryController = AnimationController( + vsync: this, + duration: widget.compactConfig.duration, + value: 1.0, + ); _paneWidth = PaneWidthModel( widget.paneConfig, @@ -392,6 +396,7 @@ class _ListDetailLayoutState extends State> if (widget.compactConfig.duration != oldWidget.compactConfig.duration) { _slideController.duration = widget.compactConfig.duration; + _expandEntryController.duration = widget.compactConfig.duration; } if (widget.compactConfig.curve != oldWidget.compactConfig.curve) { _slideAnimation = _buildSlideAnimation(); @@ -455,6 +460,7 @@ class _ListDetailLayoutState extends State> _controller.removeListener(_onControllerChanged); _ownedController?.dispose(); _slideController.dispose(); + _expandEntryController.dispose(); _settleController.dispose(); _paintVisibility.dispose(); super.dispose(); @@ -859,32 +865,37 @@ class _ListDetailLayoutState extends State> final isExpanded = constraints.maxWidth >= breakpoint; _isExpanded = isExpanded; - final crossedIntoCompact = _wasExpandedLastBuild && !isExpanded; - final lastWidth = _lastBuildWidth; - final discreteCrossing = - crossedIntoCompact && - lastWidth != null && - (constraints.maxWidth - lastWidth).abs() >= _discreteResizeDelta; + final crossedIntoCompact = + _hasBuiltOnce && _wasExpandedLastBuild && !isExpanded; + final crossedIntoExpanded = + _hasBuiltOnce && !_wasExpandedLastBuild && isExpanded; _wasExpandedLastBuild = isExpanded; - _lastBuildWidth = constraints.maxWidth; + _hasBuiltOnce = true; // Evaluate overlay visibility (immediate hide for inactive tabs). if (_useOverlay) _paintVisibility.evaluate(); - // Crossing into compact with an open detail (inline/overlay). - // Discrete jump: the detail GROWS out of its pane — the slide - // starts with its leading edge at the old divider position and - // settles to full width. Continuous drag: snap fully open; the - // detail was already visible and motion would fight the hand. + // Crossing into compact with an open detail (inline/overlay): the + // detail GROWS out of its pane — the slide starts with its leading + // edge at the old divider position and settles to full width. The + // window itself keeps tracking the hand; only the pane + // re-arrangement animates (the Compose-canonical pane motion). if (!_useRoute && crossedIntoCompact && _controller.hasSelection) { - if (discreteCrossing) { - final listWidth = _paneWidth.width(_lastExpandedWidth); - _slideController.value = (1 - listWidth / constraints.maxWidth) - .clamp(0.0, 1.0); - unawaited(_slideController.forward()); - } else { - _slideController.value = 1.0; - } + final listWidth = _paneWidth.width(_lastExpandedWidth); + _slideController.value = (1 - listWidth / constraints.maxWidth).clamp( + 0.0, + 1.0, + ); + unawaited(_slideController.forward()); + } + + // Crossing into expanded with an open detail (every mode): 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. + if (crossedIntoExpanded && _controller.hasSelection) { + _expandEntryController.value = 0.0; + unawaited(_expandEntryController.forward()); } // Overlay entering compact outside a crossing frame (deep link, @@ -924,13 +935,14 @@ class _ListDetailLayoutState extends State> _bridgeOffstage = false; } else if (crossedIntoCompact && _controller.hasSelection) { // Resize into compact with an open detail: the bridge holds - // the detail key until the push claims it. Discrete jump 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. Anything - // else: visible bridge + instant push, so nothing flashes. + // 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 (discreteCrossing && _paintVisibility.notifier.value) { + if (_paintVisibility.notifier.value) { _bridgeOffstage = true; _instantRoutePush = false; } else { 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 7072626..25b8d47 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -12,11 +12,25 @@ extension _LayoutBuilders on _ListDetailLayoutState { // EXPANDED LAYOUT // =========================================================================== - /// Expanded: side-by-side panes with draggable divider. + /// Expanded: side-by-side panes with draggable divider, rebuilt per + /// expand-entry tick without a setState listener. Widget buildExpandedLayout(BoxConstraints constraints) { + return AnimatedBuilder( + animation: _expandEntryController, + builder: (context, _) => _buildExpandedLayoutInner(constraints), + ); + } + + Widget _buildExpandedLayoutInner(BoxConstraints constraints) { final availableWidth = constraints.maxWidth; _lastExpandedWidth = availableWidth; - final listWidth = _paneWidth.width(availableWidth); + // The expand-entry animation scales the RENDERED list width only — + // the width model itself is untouched, so dividers and anchors keep + // their real geometry the moment the entry settles. + final entry = _expandEntryController.isAnimating + ? widget.compactConfig.curve.transform(_expandEntryController.value) + : 1.0; + final listWidth = _paneWidth.width(availableWidth) * entry; final selectedId = _controller.selectedId; final dividerBuilder = widget.dividerBuilder; diff --git a/test/core/list_detail/breakpoint_motion_test.dart b/test/core/list_detail/breakpoint_motion_test.dart index 82fb7b4..016d50b 100644 --- a/test/core/list_detail/breakpoint_motion_test.dart +++ b/test/core/list_detail/breakpoint_motion_test.dart @@ -27,17 +27,19 @@ Widget _layout(CompactDetailMode mode) { ); } -final _detail = find.text('detail-a (stacked)'); +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. -Future _continuousResize( - WidgetTester tester, - double from, - double to, -) async { +/// 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 = (width - 30).clamp(to, 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(); } @@ -45,9 +47,22 @@ Future _continuousResize( void main() { for (final mode in [CompactDetailMode.inline, CompactDetailMode.overlay]) { - testWidgets( - '${mode.name}: discrete jump grows the detail out of its pane', - (tester) async { + 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(); @@ -55,81 +70,105 @@ void main() { await tester.tap(find.byType(CounterPane)); await tester.pump(); - // One-frame jump across the breakpoint (fold / rotation shape). - tester.view.physicalSize = const Size(500, 800); - await tester.pump(); + await cross(tester); await tester.pump(const Duration(milliseconds: 40)); - final midMorph = tester.getTopLeft(_detail).dx; + final midMorph = tester.getTopLeft(_stacked).dx; await tester.pumpAndSettle(); - final settled = tester.getTopLeft(_detail).dx; + final settled = tester.getTopLeft(_stacked).dx; - expect(midMorph, greaterThan(settled)); // it slid in from the pane edge + expect(midMorph, greaterThan(settled)); // slid in from the pane edge expect(find.text('count: 2'), findsOneWidget); // state rode the morph - }, - ); + }); + } - testWidgets('${mode.name}: continuous drag across the breakpoint cuts', ( + testWidgets('${mode.name}: crossing into expanded slides the list in', ( tester, ) async { - await pumpApp(tester, _layout(mode), size: const Size(1000, 800)); + 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(); - await _continuousResize(tester, 1000, 700); + tester.view.physicalSize = const Size(1000, 800); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); - // No entrance animation is running — the detail is simply there. - expect(tester.hasRunningAnimations, isFalse); - final immediate = tester.getTopLeft(_detail).dx; + // 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(); - expect(tester.getTopLeft(_detail).dx, immediate); + final settled = tester.getTopLeft(_sideBySide).dx; + + expect(midEntry, lessThan(settled)); + expect(find.text('count: 2'), findsOneWidget); }); } - testWidgets('route: discrete jump 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(); + 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(); - tester.view.physicalSize = const Size(500, 800); - await tester.pump(); // crossing frame: list + offstage bridge, push queued - await tester.pump(const Duration(milliseconds: 16)); // overlay inserts - await tester.pump(const Duration(milliseconds: 16)); // route content live + 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(_detail))!; - expect(route.animation!.status, AnimationStatus.forward); + // 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); - }); + await tester.pumpAndSettle(); + expect(route.animation!.status, AnimationStatus.completed); + expect(find.text('count: 2'), findsOneWidget); + }); + } - testWidgets('route: continuous drag across the breakpoint pushes instantly', ( + testWidgets('route: crossing into expanded slides the list in seamlessly', ( tester, ) async { await pumpApp( tester, _layout(CompactDetailMode.route), - size: const Size(1000, 800), + 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 _continuousResize(tester, 1000, 700); + 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; - final route = ModalRoute.of(tester.element(_detail))!; - expect(route.animation!.status, AnimationStatus.completed); + 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); }); } From e2a7269726575fda09c75b168547878cbec12777 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 00:58:49 +0530 Subject: [PATCH 20/41] feat: reveal-style expand entry (with resize opt-in) + divider position memory --- CHANGELOG.md | 3 +- README.md | 4 ++ docs/CAPABILITY_ROADMAP.md | 2 + docs/UPDATING.md | 15 +++++-- example/lib/main.dart | 10 +++++ lib/adaptive_layouts.dart | 1 + .../core/list_detail/list_detail_layout.dart | 6 ++- .../list_detail_layout_builders.dart | 34 ++++++++++---- lib/src/core/shared/expanded_entry_style.dart | 16 +++++++ lib/src/core/shared/pane_anchor.dart | 8 +++- lib/src/core/shared/pane_config.dart | 34 ++++++++++++++ lib/src/core/split/adaptive_split.dart | 5 ++- .../list_detail/breakpoint_motion_test.dart | 45 +++++++++++++++++++ .../list_detail/list_detail_layout_test.dart | 36 +++++++++++++++ test/core/shared/pane_anchor_test.dart | 14 ++++++ 15 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 lib/src/core/shared/expanded_entry_style.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 852e316..27b5f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,8 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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 with an open detail animate in both directions — 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. Pane geometry keeps tracking window drags without motion. +- **Crossing motion:** breakpoint crossings with an open detail animate in both directions — 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. +- **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. - **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. - **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 8282ba5..e891b09 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,10 @@ Rule of thumb: `inline` when the surrounding chrome should stay present, `overla Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfold, rotation, split-screen snap, or dragging the window edge across the threshold. Only the pane geometry itself tracks the drag without motion (a lagging pane would fight your hand); the arrangement flip is always animated, the way Compose's canonical scaffolds and desktop sidebars behave. +Entering expanded, the arriving list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer the list to lay out live and grow into its pane instead? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. + +The divider remembers. A dragged divider position survives compact spells, window resizes, and rebuilds — `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. + ### Sizing the panes `PaneConfig` is pure data; the divider visual is a builder you pick or write: diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 7b6ef75..59777d2 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -28,6 +28,8 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | +| 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 | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | ## AdaptiveSplit diff --git a/docs/UPDATING.md b/docs/UPDATING.md index a4b6f1c..3e6117b 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -255,13 +255,20 @@ The machinery in `list_detail_layout.dart` holds: 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. diff --git a/example/lib/main.dart b/example/lib/main.dart index 92d0039..e390a59 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -113,6 +113,7 @@ class PackageSettings extends ChangeNotifier { DividerStyle divider = DividerStyle.handle; PaneResizeMode resizeMode = PaneResizeMode.ratio; bool anchorsEnabled = false; + ExpandedEntryStyle entryStyle = ExpandedEntryStyle.reveal; double expandedBreakpoint = 720; bool handleBackGesture = true; int slideDurationMs = 300; @@ -132,6 +133,7 @@ class PackageSettings extends ChangeNotifier { PaneConfig get paneConfig => PaneConfig( resizeMode: resizeMode, + entryStyle: entryStyle, anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], ); @@ -299,6 +301,14 @@ class PackageSettingsPanel extends StatelessWidget { 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), + ), _section(context, 'Adaptive modal'), _toggle( label: 'Container-transform morph', diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index a242718..705dc19 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -32,6 +32,7 @@ export 'src/core/split/adaptive_split.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_config.dart'; export 'src/core/shared/pane_resize_mode.dart'; diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 7b018a0..fe98200 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -10,6 +10,7 @@ import 'package:adaptive_layouts/src/core/list_detail/list_detail_controller.dar 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_config.dart'; import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; @@ -401,7 +402,10 @@ class _ListDetailLayoutState extends State> 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(); _paneWidth = PaneWidthModel( widget.paneConfig, 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 25b8d47..6fc7fbf 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -24,25 +24,43 @@ extension _LayoutBuilders on _ListDetailLayoutState { Widget _buildExpandedLayoutInner(BoxConstraints constraints) { final availableWidth = constraints.maxWidth; _lastExpandedWidth = availableWidth; - // The expand-entry animation scales the RENDERED list width only — - // the width model itself is untouched, so dividers and anchors keep - // their real geometry the moment the entry settles. + // The expand-entry animation scales the list's SLOT only — the width + // model is untouched, so dividers and anchors keep their real + // geometry the moment the entry settles. final entry = _expandEntryController.isAnimating ? widget.compactConfig.curve.transform(_expandEntryController.value) : 1.0; - final listWidth = _paneWidth.width(availableWidth) * entry; + final finalListWidth = _paneWidth.width(availableWidth); + final listWidth = finalListWidth * entry; final selectedId = _controller.selectedId; final dividerBuilder = widget.dividerBuilder; + Widget list = widget.listBuilder(context, selectedId, _handleSelect); + 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, + ), + ); + } + return Stack( children: [ Row( children: [ - SizedBox( - width: listWidth, - child: widget.listBuilder(context, selectedId, _handleSelect), - ), + SizedBox(width: listWidth, child: list), Expanded( // While a route (active or exiting) holds the detail key, the // pane leaves its slot empty — exactly one key holder per 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_config.dart b/lib/src/core/shared/pane_config.dart index 75a0690..1e56e70 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -1,3 +1,6 @@ +import 'package:flutter/foundation.dart'; + +import 'package:adaptive_layouts/src/core/shared/expanded_entry_style.dart'; import 'package:adaptive_layouts/src/core/shared/pane_anchor.dart'; import 'package:adaptive_layouts/src/core/shared/pane_resize_mode.dart'; @@ -23,6 +26,7 @@ class PaneConfig { this.anchors = const [], this.initialAnchorIndex = 1, this.resizeMode = PaneResizeMode.ratio, + this.entryStyle = ExpandedEntryStyle.reveal, }); /// Default width of the list pane in logical pixels. @@ -48,9 +52,39 @@ 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; + /// 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; + + @override + int get hashCode => Object.hash( + defaultListWidth, + minListWidth, + maxListRatio, + Object.hashAll(anchors), + initialAnchorIndex, + resizeMode, + entryStyle, + ); } diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index c68062a..cba7322 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -165,7 +165,10 @@ class _AdaptiveSplitState extends State @override void didUpdateWidget(AdaptiveSplit oldWidget) { super.didUpdateWidget(oldWidget); - 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(); _paneWidth = PaneWidthModel( widget.paneConfig, diff --git a/test/core/list_detail/breakpoint_motion_test.dart b/test/core/list_detail/breakpoint_motion_test.dart index 016d50b..dbcbc65 100644 --- a/test/core/list_detail/breakpoint_motion_test.dart +++ b/test/core/list_detail/breakpoint_motion_test.dart @@ -144,6 +144,51 @@ void main() { }); } + 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 { diff --git a/test/core/list_detail/list_detail_layout_test.dart b/test/core/list_detail/list_detail_layout_test.dart index 70f87cf..367e4fc 100644 --- a/test/core/list_detail/list_detail_layout_test.dart +++ b/test/core/list_detail/list_detail_layout_test.dart @@ -116,6 +116,42 @@ 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('divider settles to the nearest anchor after a drag', ( tester, ) async { 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); From cafb835a6620d36e8d5e7287aab026530af01311 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 01:13:42 +0530 Subject: [PATCH 21/41] feat: PaneWidthMemory knob (persist / resetOnReentry) + crossing-logic extraction --- CHANGELOG.md | 2 +- README.md | 2 +- docs/CAPABILITY_ROADMAP.md | 2 +- example/lib/main.dart | 10 +++ lib/adaptive_layouts.dart | 1 + .../core/list_detail/list_detail_layout.dart | 86 ++++++++++++------- lib/src/core/shared/pane_config.dart | 9 +- lib/src/core/shared/pane_width_memory.dart | 12 +++ lib/src/core/split/adaptive_split.dart | 20 +++++ .../list_detail/list_detail_layout_test.dart | 27 ++++++ test/core/split/adaptive_split_test.dart | 26 ++++++ 11 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 lib/src/core/shared/pane_width_memory.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b5f5f..9c2183b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 with an open detail animate in both directions — 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. -- **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. +- **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. - **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. - **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 e891b09..8851373 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfo Entering expanded, the arriving list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer the list to lay out live and grow into its pane instead? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. -The divider remembers. A dragged divider position survives compact spells, window resizes, and rebuilds — `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. +The divider remembers. A dragged divider position survives compact spells, window resizes, and rebuilds — `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. Prefer a fresh divider on every return to the wide layout instead? `PaneConfig(widthMemory: PaneWidthMemory.resetOnReentry)`. ### Sizing the panes diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 59777d2..2820e5a 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -29,7 +29,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | | 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 | +| 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 + AdaptiveSplit) | | Deep-link-friendly controller semantics | DONE | Initial selection renders without animation; example ships URL sync | ## AdaptiveSplit diff --git a/example/lib/main.dart b/example/lib/main.dart index e390a59..8ff627b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -114,6 +114,7 @@ class PackageSettings extends ChangeNotifier { PaneResizeMode resizeMode = PaneResizeMode.ratio; bool anchorsEnabled = false; ExpandedEntryStyle entryStyle = ExpandedEntryStyle.reveal; + PaneWidthMemory widthMemory = PaneWidthMemory.persist; double expandedBreakpoint = 720; bool handleBackGesture = true; int slideDurationMs = 300; @@ -134,6 +135,7 @@ class PackageSettings extends ChangeNotifier { PaneConfig get paneConfig => PaneConfig( resizeMode: resizeMode, entryStyle: entryStyle, + widthMemory: widthMemory, anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], ); @@ -309,6 +311,14 @@ class PackageSettingsPanel extends StatelessWidget { 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), + ), _section(context, 'Adaptive modal'), _toggle( label: 'Container-transform morph', diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 705dc19..9ef08a6 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -36,6 +36,7 @@ export 'src/core/shared/expanded_entry_style.dart'; export 'src/core/shared/pane_anchor.dart'; export 'src/core/shared/pane_config.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/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index fe98200..f405e8c 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -12,6 +12,7 @@ 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_config.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'; @@ -854,6 +855,59 @@ class _ListDetailLayoutState extends State> }); } + // =========================================================================== + // BREAKPOINT CROSSINGS + // =========================================================================== + + /// 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, + double width, + ) { + if (crossing.intoCompact && !_useRoute && _controller.hasSelection) { + // The detail GROWS out of its pane: the slide starts with its + // leading edge at the old divider position and settles to full + // width. (Route mode's equivalent is the real route entrance — + // see the route branch in build.) + final listWidth = _paneWidth.width(_lastExpandedWidth); + _slideController.value = (1 - listWidth / width).clamp(0.0, 1.0); + unawaited(_slideController.forward()); + } + + 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) { + // 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. + _expandEntryController.value = 0.0; + unawaited(_expandEntryController.forward()); + } + } + } + // =========================================================================== // BUILD // =========================================================================== @@ -869,38 +923,12 @@ class _ListDetailLayoutState extends State> final isExpanded = constraints.maxWidth >= breakpoint; _isExpanded = isExpanded; - final crossedIntoCompact = - _hasBuiltOnce && _wasExpandedLastBuild && !isExpanded; - final crossedIntoExpanded = - _hasBuiltOnce && !_wasExpandedLastBuild && isExpanded; - _wasExpandedLastBuild = isExpanded; - _hasBuiltOnce = true; + final crossing = _detectCrossing(isExpanded); // Evaluate overlay visibility (immediate hide for inactive tabs). if (_useOverlay) _paintVisibility.evaluate(); - // Crossing into compact with an open detail (inline/overlay): the - // detail GROWS out of its pane — the slide starts with its leading - // edge at the old divider position and settles to full width. The - // window itself keeps tracking the hand; only the pane - // re-arrangement animates (the Compose-canonical pane motion). - if (!_useRoute && crossedIntoCompact && _controller.hasSelection) { - final listWidth = _paneWidth.width(_lastExpandedWidth); - _slideController.value = (1 - listWidth / constraints.maxWidth).clamp( - 0.0, - 1.0, - ); - unawaited(_slideController.forward()); - } - - // Crossing into expanded with an open detail (every mode): 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. - if (crossedIntoExpanded && _controller.hasSelection) { - _expandEntryController.value = 0.0; - unawaited(_expandEntryController.forward()); - } + _handleCrossing(crossing, constraints.maxWidth); // Overlay entering compact outside a crossing frame (deep link, // mode flip): the detail is a settled fact — show it fully open. @@ -937,7 +965,7 @@ class _ListDetailLayoutState extends State> if (isExpanded) { _bridgeDetail = false; _bridgeOffstage = false; - } else if (crossedIntoCompact && _controller.hasSelection) { + } 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 diff --git a/lib/src/core/shared/pane_config.dart b/lib/src/core/shared/pane_config.dart index 1e56e70..0346b70 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:adaptive_layouts/src/core/shared/expanded_entry_style.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`. /// @@ -27,6 +28,7 @@ class PaneConfig { this.initialAnchorIndex = 1, this.resizeMode = PaneResizeMode.ratio, this.entryStyle = ExpandedEntryStyle.reveal, + this.widthMemory = PaneWidthMemory.persist, }); /// Default width of the list pane in logical pixels. @@ -56,6 +58,9 @@ class PaneConfig { /// expanded layout with an open detail. final ExpandedEntryStyle entryStyle; + /// Whether a dragged divider position survives compact spells. + final PaneWidthMemory widthMemory; + /// Sensible defaults for a standard list-detail layout. static const standard = PaneConfig(); @@ -75,7 +80,8 @@ class PaneConfig { listEquals(other.anchors, anchors) && other.initialAnchorIndex == initialAnchorIndex && other.resizeMode == resizeMode && - other.entryStyle == entryStyle; + other.entryStyle == entryStyle && + other.widthMemory == widthMemory; @override int get hashCode => Object.hash( @@ -86,5 +92,6 @@ class PaneConfig { initialAnchorIndex, resizeMode, entryStyle, + widthMemory, ); } 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/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index cba7322..f906d30 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -3,6 +3,7 @@ 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_memory.dart'; import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; // ============================================================================= @@ -162,6 +163,11 @@ class _AdaptiveSplitState extends State ); } + /// Crossing bookkeeping for [PaneWidthMemory.resetOnReentry]. A first + /// build is never a crossing. + bool _wasExpandedLastBuild = false; + bool _hasBuiltOnce = false; + @override void didUpdateWidget(AdaptiveSplit oldWidget) { super.didUpdateWidget(oldWidget); @@ -255,6 +261,20 @@ class _AdaptiveSplitState extends State widget.expandedBreakpoint, ); final isExpanded = constraints.maxWidth >= breakpoint; + 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, + ); + } + _wasExpandedLastBuild = isExpanded; + _hasBuiltOnce = true; return isExpanded ? _buildExpandedLayout(constraints) diff --git a/test/core/list_detail/list_detail_layout_test.dart b/test/core/list_detail/list_detail_layout_test.dart index 367e4fc..5d15a24 100644 --- a/test/core/list_detail/list_detail_layout_test.dart +++ b/test/core/list_detail/list_detail_layout_test.dart @@ -152,6 +152,33 @@ void main() { }, ); + 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 { diff --git a/test/core/split/adaptive_split_test.dart b/test/core/split/adaptive_split_test.dart index 36f8ccd..5f9cb7b 100644 --- a/test/core/split/adaptive_split_test.dart +++ b/test/core/split/adaptive_split_test.dart @@ -47,6 +47,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; From 1a4e88639ded6d5cd4e0b2541613a13906f6323e Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 01:22:23 +0530 Subject: [PATCH 22/41] fix: crossing morph seeds from the divider's fraction through the curve inverse --- .../core/list_detail/list_detail_layout.dart | 48 +++++++++++++++---- .../list_detail/breakpoint_motion_test.dart | 11 +++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index f405e8c..1e1830d 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -859,6 +859,23 @@ class _ListDetailLayoutState extends State> // 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; + } + /// 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) { @@ -873,17 +890,28 @@ class _ListDetailLayoutState extends State> /// 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, - double width, - ) { + 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 old divider position and settles to full - // width. (Route mode's equivalent is the real route entrance — - // see the route branch in build.) - final listWidth = _paneWidth.width(_lastExpandedWidth); - _slideController.value = (1 - listWidth / width).clamp(0.0, 1.0); + // 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()); } @@ -928,7 +956,7 @@ class _ListDetailLayoutState extends State> // Evaluate overlay visibility (immediate hide for inactive tabs). if (_useOverlay) _paintVisibility.evaluate(); - _handleCrossing(crossing, constraints.maxWidth); + _handleCrossing(crossing); // Overlay entering compact outside a crossing frame (deep link, // mode flip): the detail is a settled fact — show it fully open. diff --git a/test/core/list_detail/breakpoint_motion_test.dart b/test/core/list_detail/breakpoint_motion_test.dart index dbcbc65..60ff328 100644 --- a/test/core/list_detail/breakpoint_motion_test.dart +++ b/test/core/list_detail/breakpoint_motion_test.dart @@ -69,8 +69,19 @@ void main() { 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; From 47e8d3e896efd3c7435a0611a646a54265ad389e Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 01:40:11 +0530 Subject: [PATCH 23/41] =?UTF-8?q?feat:=20ExpandedEmptyBehavior.listOnly=20?= =?UTF-8?q?=E2=80=94=20on-demand=20detail=20pane,=20plus=20the=20three-sch?= =?UTF-8?q?ools=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + README.md | 17 +++ docs/CAPABILITY_ROADMAP.md | 1 + example/lib/main.dart | 42 +++++ lib/adaptive_layouts.dart | 1 + .../list_detail/expanded_empty_behavior.dart | 19 +++ .../core/list_detail/list_detail_layout.dart | 39 ++++- .../list_detail_layout_builders.dart | 135 +++++++++++------ .../list_detail_empty_behavior_test.dart | 143 ++++++++++++++++++ 9 files changed, 344 insertions(+), 54 deletions(-) create mode 100644 lib/src/core/list_detail/expanded_empty_behavior.dart create mode 100644 test/core/list_detail/list_detail_empty_behavior_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2183b..94a1d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 with an open detail animate in both directions — 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. - **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. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/README.md b/README.md index 8851373..ec07d5f 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,23 @@ Selecting pushes a genuine page route holding the detail; back is the route's ow One app-side note: every route push makes Flutter scan the shell for `Hero` tags, kept-alive tabs included. If several `FloatingActionButton`s coexist under one page (one per tab), give them explicit `heroTag`s — the shared default tag asserts on the first push. +### The empty detail pane — three schools + +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` (`IconMessageEmpty` ships as a convenience). The Apple Mail / Outlook reading-pane shape. +2. **Auto-select** — never show emptiness: when the list loads and nothing is selected, select the first (or last-used) item from your controller: `controller.select(items.first.id)`. The Notes / Slack shape. 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 app ships the recipe behind a ⚙ toggle. +3. **On-demand pane** — the list owns the full width until a selection summons the pane, which reveals from the end edge; dismissing hands the width back. Material's "supporting pane" shape (Gmail without a reading pane): + +```dart +ListDetailLayout( + expandedEmptyBehavior: ExpandedEmptyBehavior.listOnly, + ... +) +``` + +A 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 entirely. + ### Picking a compact detail mode All three modes keep the state guarantee across resizes. They differ in what the open detail covers and who owns the back gesture: diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 2820e5a..993f1cd 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -31,6 +31,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 + AdaptiveSplit) | | 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 diff --git a/example/lib/main.dart b/example/lib/main.dart index 8ff627b..5031d58 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -115,6 +115,8 @@ class PackageSettings extends ChangeNotifier { bool anchorsEnabled = false; ExpandedEntryStyle entryStyle = ExpandedEntryStyle.reveal; PaneWidthMemory widthMemory = PaneWidthMemory.persist; + ExpandedEmptyBehavior emptyBehavior = ExpandedEmptyBehavior.placeholder; + bool autoSelectFirst = false; double expandedBreakpoint = 720; bool handleBackGesture = true; int slideDurationMs = 300; @@ -319,6 +321,19 @@ class PackageSettingsPanel extends StatelessWidget { 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), + ), + _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', @@ -1058,6 +1073,12 @@ class ListDetailRouter extends StatefulWidget { /// 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', @@ -1066,6 +1087,7 @@ class ListDetailRouter extends StatefulWidget { required this.detailBuilder, this.emptyStateBuilder, this.selectedIdExists, + this.firstItemId, }); @override @@ -1197,6 +1219,7 @@ class _ListDetailRouterState extends State { listenable: PackageSettings.instance, builder: (context, _) { final settings = PackageSettings.instance; + _maybeAutoSelectFirst(context, settings); return ListDetailLayout( controller: _controller, listBuilder: widget.listBuilder, @@ -1206,10 +1229,28 @@ class _ListDetailRouterState extends State { 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 @@ -2105,6 +2146,7 @@ class TicketsTabScreen extends StatelessWidget { return ListDetailRouter( idParamName: Params.ticketId, 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, diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 9ef08a6..17d7101 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -23,6 +23,7 @@ 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/modal/adaptive_modal.dart'; export 'src/core/modal/modal_config.dart'; 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 1e1830d..92eecaf 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -6,6 +6,7 @@ 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'; @@ -89,6 +90,7 @@ class ListDetailLayout extends StatefulWidget { 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. @@ -146,6 +148,11 @@ class ListDetailLayout extends StatefulWidget { /// 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(); } @@ -307,6 +314,12 @@ class _ListDetailLayoutState extends State> /// 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 @@ -327,14 +340,19 @@ class _ListDetailLayoutState extends State> duration: widget.compactConfig.duration, ); _slideAnimation = _buildSlideAnimation(); - // No listener: buildExpandedLayout wraps itself in an AnimatedBuilder - // on this controller — a setState listener would fire during build - // when a crossing frame seeds the value. + // 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, @@ -399,6 +417,11 @@ 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(); @@ -466,6 +489,7 @@ class _ListDetailLayoutState extends State> _ownedController?.dispose(); _slideController.dispose(); _expandEntryController.dispose(); + _detailPaneController.dispose(); _settleController.dispose(); _paintVisibility.dispose(); super.dispose(); @@ -538,6 +562,7 @@ class _ListDetailLayoutState extends State> // === ENTERING: detail was closed, now something is selected === _outgoingDetailId = null; unawaited(_slideController.forward()); + unawaited(_detailPaneController.forward()); } else if (!shouldBeOpen && isCurrentlyOpen) { // === EXITING: detail was open, selection cleared === // @@ -547,8 +572,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(), + _detailPaneController.reverse(), + ]).then((_) { if (mounted) { setState(() => _outgoingDetailId = null); _controller.setAnimatingOut(false); 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 6fc7fbf..9b491ad 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -13,10 +13,13 @@ extension _LayoutBuilders on _ListDetailLayoutState { // =========================================================================== /// Expanded: side-by-side panes with draggable divider, rebuilt per - /// expand-entry tick without a setState listener. + /// animation tick without setState listeners. Widget buildExpandedLayout(BoxConstraints constraints) { return AnimatedBuilder( - animation: _expandEntryController, + animation: Listenable.merge([ + _expandEntryController, + _detailPaneController, + ]), builder: (context, _) => _buildExpandedLayoutInner(constraints), ); } @@ -24,16 +27,26 @@ extension _LayoutBuilders on _ListDetailLayoutState { Widget _buildExpandedLayoutInner(BoxConstraints constraints) { final availableWidth = constraints.maxWidth; _lastExpandedWidth = availableWidth; - // The expand-entry animation scales the list's SLOT only — the width - // model is untouched, so dividers and anchors keep their real - // geometry the moment the entry settles. + // Both animations scale SLOTS only — the width model is untouched, + // so dividers and anchors keep their real geometry when they settle. final entry = _expandEntryController.isAnimating ? widget.compactConfig.curve.transform(_expandEntryController.value) : 1.0; + // Detail-pane presence (listOnly): 0 = list owns the full width. + // Placeholder behavior pins it at 1 — the pane slot always exists. + final listOnly = + widget.expandedEmptyBehavior == ExpandedEmptyBehavior.listOnly; + final pane = listOnly + ? widget.compactConfig.curve.transform(_detailPaneController.value) + : 1.0; final finalListWidth = _paneWidth.width(availableWidth); - final listWidth = finalListWidth * entry; + // 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 = widget.listBuilder(context, selectedId, _handleSelect); @@ -56,59 +69,81 @@ extension _LayoutBuilders on _ListDetailLayoutState { ); } + 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 (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(); + } + return Stack( children: [ Row( children: [ SizedBox(width: listWidth, child: list), - Expanded( - // 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. - child: selectedId != null - ? ValueListenableBuilder( - valueListenable: _detailRouted, - builder: (context, routed, _) => routed - ? const SizedBox.shrink() - : KeyedSubtree( - key: _detailKey, - child: widget.detailBuilder( - context, - selectedId, - DetailLayoutMode.sideBySide, - _handleDismiss, - ), - ), - ) - : widget.emptyStateBuilder?.call(context) ?? - const SizedBox.shrink(), - ), + Expanded(child: detailSlot), ], ), - // 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, + // Divider — visual (if builder provided) or invisible drag zone. + // In listOnly the seam only exists once the pane has settled. + if (!listOnly || (pane == 1.0 && detailId != null)) + 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, + ), ), ), - ), ], ); } 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..7afdfc3 --- /dev/null +++ b/test/core/list_detail/list_detail_empty_behavior_test.dart @@ -0,0 +1,143 @@ +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); + }); + + 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); + }); +} From 4b23020869f8617da4ee12436a5d60cfccdfc14a Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 01:44:18 +0530 Subject: [PATCH 24/41] fix: listOnly divider rides the revealing seam instead of popping in at settle --- lib/src/core/list_detail/list_detail_layout_builders.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 9b491ad..593db05 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -120,8 +120,9 @@ extension _LayoutBuilders on _ListDetailLayoutState { ], ), // Divider — visual (if builder provided) or invisible drag zone. - // In listOnly the seam only exists once the pane has settled. - if (!listOnly || (pane == 1.0 && detailId != null)) + // In listOnly it exists whenever the pane does, riding the + // animated seam — appearing only after settle reads as a pop-in. + if (!listOnly || detailId != null) PositionedDirectional( start: listWidth - 12, // 24px hit area centered on the border top: 0, From 33ce31d264deb262a2ec1394b725e2093563e4e6 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 01:51:37 +0530 Subject: [PATCH 25/41] feat: empty placeholder pane animates its breakpoint crossings both ways --- CHANGELOG.md | 2 +- README.md | 2 +- docs/UPDATING.md | 8 ++- .../core/list_detail/list_detail_layout.dart | 53 +++++++++++++++++-- .../list_detail_layout_builders.dart | 25 +++++++-- .../list_detail_empty_behavior_test.dart | 47 ++++++++++++++++ 6 files changed, 125 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a1d6e..1fc856c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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 with an open detail animate in both directions — 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. +- **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. - **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. diff --git a/README.md b/README.md index ec07d5f..ca2db5b 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ All three modes keep the state guarantee across resizes. They differ in what the Rule of thumb: `inline` when the surrounding chrome should stay present, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page and inherit every platform back-gesture convention as it evolves. The per-value doc comments on `CompactDetailMode` carry the full contracts. -Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfold, rotation, split-screen snap, or dragging the window edge across the threshold. Only the pane geometry itself tracks the drag without motion (a lagging pane would fight your hand); the arrangement flip is always animated, the way Compose's canonical scaffolds and desktop sidebars behave. +Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfold, rotation, split-screen snap, or dragging the window edge across the threshold. Only the pane geometry itself tracks the drag without motion (a lagging pane would fight your hand); the arrangement flip is always animated, the way Compose's canonical scaffolds and desktop sidebars behave. That includes the EMPTY placeholder pane: with nothing selected it reveals from the end edge on expand and retreats into it on shrink. Entering expanded, the arriving list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer the list to lay out live and grow into its pane instead? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 3e6117b..476a9fe 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -227,7 +227,13 @@ The machinery in `list_detail_layout.dart` holds: 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. + 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 diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 92eecaf..0a1412e 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -558,11 +558,17 @@ class _ListDetailLayoutState extends State> 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()); - unawaited(_detailPaneController.forward()); + if (selectionDrivesPane) unawaited(_detailPaneController.forward()); } else if (!shouldBeOpen && isCurrentlyOpen) { // === EXITING: detail was open, selection cleared === // @@ -578,7 +584,7 @@ class _ListDetailLayoutState extends State> unawaited( Future.wait([ _slideController.reverse(), - _detailPaneController.reverse(), + if (selectionDrivesPane) _detailPaneController.reverse(), ]).then((_) { if (mounted) { setState(() => _outgoingDetailId = null); @@ -907,6 +913,16 @@ class _ListDetailLayoutState extends State> 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) { @@ -946,6 +962,33 @@ class _ListDetailLayoutState extends State> 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. + final placeholderEmpty = + 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 @@ -1001,7 +1044,7 @@ class _ListDetailLayoutState extends State> // 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 + final innerLayout = isExpanded || _emptyPaneRetreating ? buildExpandedLayout(constraints) : buildCompactOverlayList(); return buildOverlayPortalWrapper( @@ -1042,7 +1085,7 @@ class _ListDetailLayoutState extends State> } } _scheduleRouteSync(); - final innerLayout = isExpanded + final innerLayout = isExpanded || _emptyPaneRetreating ? buildExpandedLayout(constraints) : buildCompactRouteList(); return PaintVisibilityObserver( @@ -1051,7 +1094,7 @@ class _ListDetailLayoutState extends State> ); } - return isExpanded + 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 593db05..5edeb64 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -26,17 +26,21 @@ extension _LayoutBuilders on _ListDetailLayoutState { Widget _buildExpandedLayoutInner(BoxConstraints constraints) { final availableWidth = constraints.maxWidth; - _lastExpandedWidth = 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. final entry = _expandEntryController.isAnimating ? widget.compactConfig.curve.transform(_expandEntryController.value) : 1.0; - // Detail-pane presence (listOnly): 0 = list owns the full width. - // Placeholder behavior pins it at 1 — the pane slot always exists. final listOnly = widget.expandedEmptyBehavior == ExpandedEmptyBehavior.listOnly; - final pane = 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 = listOnly || _detailPaneController.isAnimating ? widget.compactConfig.curve.transform(_detailPaneController.value) : 1.0; final finalListWidth = _paneWidth.width(availableWidth); @@ -109,6 +113,19 @@ extension _LayoutBuilders on _ListDetailLayoutState { } else { detailSlot = widget.emptyStateBuilder?.call(context) ?? const SizedBox.shrink(); + 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( diff --git a/test/core/list_detail/list_detail_empty_behavior_test.dart b/test/core/list_detail/list_detail_empty_behavior_test.dart index 7afdfc3..b3e63af 100644 --- a/test/core/list_detail/list_detail_empty_behavior_test.dart +++ b/test/core/list_detail/list_detail_empty_behavior_test.dart @@ -114,6 +114,53 @@ void main() { 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 { From 5a45d45f53474ca4bb00d9d38eacbb1deb36a9ab Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 06:11:12 +0530 Subject: [PATCH 26/41] feat: native passthroughs on ModalConfig + PaneConfig settle/hit-zone knobs --- CHANGELOG.md | 1 + README.md | 2 + docs/CAPABILITY_ROADMAP.md | 2 +- .../core/list_detail/list_detail_layout.dart | 5 +- .../list_detail_layout_builders.dart | 5 +- lib/src/core/modal/adaptive_modal.dart | 17 ++- lib/src/core/modal/modal_config.dart | 54 ++++++++ lib/src/core/shared/pane_config.dart | 21 ++- lib/src/core/split/adaptive_split.dart | 15 ++- .../list_detail/list_detail_layout_test.dart | 46 +++++++ .../modal/modal_config_forwarding_test.dart | 127 ++++++++++++++++++ 11 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 test/core/modal/modal_config_forwarding_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc856c..7093d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. - **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 ca2db5b..2c91ca1 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ final choice = await showAdaptiveModal( ); ``` +`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. 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). + The returned future completes with the pop result no matter how many form swaps happened while the modal was open. Each form keeps its own Material surface tone (`surfaceContainerHigh` for dialogs, `surfaceContainerLow` for sheets, themable as usual); `ModalConfig(backgroundColor: ...)` pins one color across both forms when the crossfade is unwanted. 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. ### One breakpoint for the whole app diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 993f1cd..695bcf1 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -51,7 +51,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | barrier, safe area, scroll control, drag, drag handle | +| 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) diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 0a1412e..70419cb 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -360,7 +360,7 @@ class _ListDetailLayoutState extends State> ); _settleController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 220), + duration: widget.paneConfig.settleDuration, ); _controller.addListener(_onControllerChanged); @@ -431,6 +431,7 @@ class _ListDetailLayoutState extends State> // user's dragged divider position. if (widget.paneConfig != oldWidget.paneConfig) { _settleController.stop(); + _settleController.duration = widget.paneConfig.settleDuration; _paneWidth = PaneWidthModel( widget.paneConfig, referenceWidth: _referenceWidth, @@ -873,7 +874,7 @@ class _ListDetailLayoutState extends State> final begin = _paneWidth.width(availableWidth); final curve = CurvedAnimation( parent: _settleController, - curve: Curves.easeOutCubic, + curve: widget.paneConfig.settleCurve, ); void tick() { _paneWidth.setWidth( 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 5edeb64..d2150dc 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -141,7 +141,8 @@ extension _LayoutBuilders on _ListDetailLayoutState { // animated seam — appearing only after settle reads as a pop-in. if (!listOnly || detailId != null) PositionedDirectional( - start: listWidth - 12, // 24px hit area centered on the border + // Hit area centered on the pane border. + start: listWidth - widget.paneConfig.dividerHitWidth / 2, top: 0, bottom: 0, child: GestureDetector( @@ -151,7 +152,7 @@ extension _LayoutBuilders on _ListDetailLayoutState { _handleDividerDragUpdate(d.primaryDelta ?? 0), onHorizontalDragEnd: (_) => _handleDividerDragEnd(), child: SizedBox( - width: 24, + width: widget.paneConfig.dividerHitWidth, child: dividerBuilder != null ? dividerBuilder( context, diff --git a/lib/src/core/modal/adaptive_modal.dart b/lib/src/core/modal/adaptive_modal.dart index be5d230..3a49f7f 100644 --- a/lib/src/core/modal/adaptive_modal.dart +++ b/lib/src/core/modal/adaptive_modal.dart @@ -291,7 +291,8 @@ class _ModalSession { bool ghost = false, }) { // Swap pushes skip the entrance animation (the modal is already - // visually present); exits keep Material's timing either way. + // 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: @@ -301,9 +302,13 @@ class _ModalSession { themes: themes, barrierColor: config.barrierColor ?? Colors.black54, barrierDismissible: config.barrierDismissible, + barrierLabel: config.barrierLabel, useSafeArea: config.useSafeArea, settings: settings, - animationStyle: style, + 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 @@ -317,13 +322,19 @@ class _ModalSession { 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, - sheetAnimationStyle: style, + anchorPoint: config.anchorPoint, + requestFocus: config.requestFocus, + sheetAnimationStyle: style ?? config.sheetAnimationStyle, ); } } diff --git a/lib/src/core/modal/modal_config.dart b/lib/src/core/modal/modal_config.dart index 5b2b496..e660b48 100644 --- a/lib/src/core/modal/modal_config.dart +++ b/lib/src/core/modal/modal_config.dart @@ -21,10 +21,18 @@ class 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, @@ -65,6 +73,52 @@ class ModalConfig { /// `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. diff --git a/lib/src/core/shared/pane_config.dart b/lib/src/core/shared/pane_config.dart index 0346b70..6f06a4c 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -1,4 +1,5 @@ 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_anchor.dart'; @@ -29,6 +30,9 @@ class PaneConfig { 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, }); /// Default width of the list pane in logical pixels. @@ -61,6 +65,15 @@ class PaneConfig { /// 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; + /// Sensible defaults for a standard list-detail layout. static const standard = PaneConfig(); @@ -81,7 +94,10 @@ class PaneConfig { other.initialAnchorIndex == initialAnchorIndex && other.resizeMode == resizeMode && other.entryStyle == entryStyle && - other.widthMemory == widthMemory; + other.widthMemory == widthMemory && + other.settleDuration == settleDuration && + other.settleCurve == settleCurve && + other.dividerHitWidth == dividerHitWidth; @override int get hashCode => Object.hash( @@ -93,5 +109,8 @@ class PaneConfig { resizeMode, entryStyle, widthMemory, + settleDuration, + settleCurve, + dividerHitWidth, ); } diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index f906d30..8084bb3 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -159,7 +159,7 @@ class _AdaptiveSplitState extends State ); _settleController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 220), + duration: widget.paneConfig.settleDuration, ); } @@ -176,6 +176,7 @@ class _AdaptiveSplitState extends State // user's dragged divider position. if (widget.paneConfig != oldWidget.paneConfig) { _settleController.stop(); + _settleController.duration = widget.paneConfig.settleDuration; _paneWidth = PaneWidthModel( widget.paneConfig, referenceWidth: _referenceWidth, @@ -229,7 +230,7 @@ class _AdaptiveSplitState extends State final begin = _paneWidth.width(availableWidth); final curve = CurvedAnimation( parent: _settleController, - curve: Curves.easeOutCubic, + curve: widget.paneConfig.settleCurve, ); void tick() { _paneWidth.setWidth( @@ -314,10 +315,12 @@ class _AdaptiveSplitState extends State Row(children: [first, second]), // Divider — visual (if builder provided) or invisible drag zone. PositionedDirectional( + // Hit area centered on the pane border. start: isStart - ? primaryWidth - - 12 // 24px hit area centered on the border - : availableWidth - primaryWidth - 12, + ? primaryWidth - widget.paneConfig.dividerHitWidth / 2 + : availableWidth - + primaryWidth - + widget.paneConfig.dividerHitWidth / 2, top: 0, bottom: 0, child: GestureDetector( @@ -327,7 +330,7 @@ class _AdaptiveSplitState extends State _handleDividerDragUpdate(d.primaryDelta ?? 0), onHorizontalDragEnd: (_) => _handleDividerDragEnd(), child: SizedBox( - width: 24, + width: widget.paneConfig.dividerHitWidth, child: dividerBuilder != null ? dividerBuilder( context, diff --git a/test/core/list_detail/list_detail_layout_test.dart b/test/core/list_detail/list_detail_layout_test.dart index 5d15a24..57f6df9 100644 --- a/test/core/list_detail/list_detail_layout_test.dart +++ b/test/core/list_detail/list_detail_layout_test.dart @@ -152,6 +152,52 @@ void main() { }, ); + 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('widthMemory.resetOnReentry forgets the drag on re-entry', ( tester, ) async { 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(); + }); +} From bfaacdee612949b26900bde067174054f7be6cd8 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 06:20:20 +0530 Subject: [PATCH 27/41] =?UTF-8?q?docs(example):=20status=20strip=20moves?= =?UTF-8?q?=20to=20MaterialApp.builder=20=E2=80=94=20chrome=20above=20ever?= =?UTF-8?q?y=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/main.dart | 57 ++++++++++++++++-------- example/test/journeys/journeys_test.dart | 10 +++++ 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5031d58..61e3423 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -71,6 +71,32 @@ class _ExampleAppState extends State { ), ), 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!), + ], + ), + ), + ), + ], + ), ), ), ); @@ -1511,34 +1537,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) { @@ -1583,7 +1604,7 @@ class StatusStrip extends StatelessWidget { icon: const Icon(Icons.tune, size: 16), visualDensity: VisualDensity.compact, tooltip: 'Package settings', - onPressed: () => showPackageSettings(context), + onPressed: () => showPackageSettings(navigatorContext()), ), const SizedBox(width: 4), ], diff --git a/example/test/journeys/journeys_test.dart b/example/test/journeys/journeys_test.dart index fd3dd6b..ef4e8de 100644 --- a/example/test/journeys/journeys_test.dart +++ b/example/test/journeys/journeys_test.dart @@ -347,6 +347,16 @@ void main() { // 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(); From 14c8aa02e568b17a9491af46b0501d453703b2e1 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 06:34:28 +0530 Subject: [PATCH 28/41] =?UTF-8?q?feat:=20pane=20collapsing=20=E2=80=94=20e?= =?UTF-8?q?dge=20anchors=20to=20zero=20+=20double-tap=20divider=20gesture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + README.md | 2 + docs/CAPABILITY_ROADMAP.md | 1 + example/lib/main.dart | 11 +++ .../core/list_detail/list_detail_layout.dart | 33 ++++++- .../list_detail_layout_builders.dart | 5 + lib/src/core/shared/pane_config.dart | 14 ++- lib/src/core/split/adaptive_split.dart | 39 +++++++- test/core/list_detail/pane_collapse_test.dart | 92 +++++++++++++++++++ test/core/split/adaptive_split_test.dart | 29 ++++++ 10 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 test/core/list_detail/pane_collapse_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7093d32..7131796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. +- **Pane collapsing:** edge anchors with open clamps settle a pane to zero width (Material's pane-expansion edges, macOS's split-view collapse); `PaneConfig(collapseOnDoubleTap: true)` adds double-tap-to-collapse with proportional restore. - **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. - **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 2c91ca1..cb0b8d6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfo Entering expanded, the arriving list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer the list to lay out live and grow into its pane instead? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. +Panes can collapse — Material's pane-expansion anchors at the edges, macOS's split-view collapse. Open the clamps (`minListWidth: 0`, `maxListRatio: 1.0`) with edge anchors and a hard drag settles a pane to nothing; `PaneConfig(collapseOnDoubleTap: true)` adds the macOS gesture — double-tap the divider to collapse, double-tap again to restore the previous position. + The divider remembers. A dragged divider position survives compact spells, window resizes, and rebuilds — `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. Prefer a fresh divider on every return to the wide layout instead? `PaneConfig(widthMemory: PaneWidthMemory.resetOnReentry)`. ### Sizing the panes diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 695bcf1..8902e88 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -62,6 +62,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | +| Pane collapsing | DONE | Edge anchors + open clamps (`minListWidth: 0`, `maxListRatio: 1.0`) settle a pane to zero; `collapseOnDoubleTap` adds the macOS divider gesture with proportional restore. Both pane widgets | | Initial width from anchor index | DONE | `PaneConfig.initialAnchorIndex`, anchors non-empty | ## Components diff --git a/example/lib/main.dart b/example/lib/main.dart index 61e3423..f535446 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -143,6 +143,7 @@ class PackageSettings extends ChangeNotifier { PaneWidthMemory widthMemory = PaneWidthMemory.persist; ExpandedEmptyBehavior emptyBehavior = ExpandedEmptyBehavior.placeholder; bool autoSelectFirst = false; + bool collapsiblePanes = false; double expandedBreakpoint = 720; bool handleBackGesture = true; int slideDurationMs = 300; @@ -164,6 +165,11 @@ class PackageSettings extends ChangeNotifier { resizeMode: resizeMode, entryStyle: entryStyle, widthMemory: widthMemory, + // Collapsible: open the clamps so the edge anchors are reachable and + // double-tap has a true zero to collapse to. + minListWidth: collapsiblePanes ? 0 : 200, + maxListRatio: collapsiblePanes ? 1.0 : 0.5, + collapseOnDoubleTap: collapsiblePanes, anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], ); @@ -331,6 +337,11 @@ class PackageSettingsPanel extends StatelessWidget { value: s.anchorsEnabled, onChanged: (v) => s.update((s) => s.anchorsEnabled = v), ), + _toggle( + label: 'Collapsible panes (double-tap divider)', + value: s.collapsiblePanes, + onChanged: (v) => s.update((s) => s.collapsiblePanes = v), + ), _choice( context: context, label: 'Expand-entry list style', diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 70419cb..17c0318 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -867,10 +867,14 @@ 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); + final target = _paneWidth.snapTarget(_lastExpandedWidth); if (target == null) return; + _animateDividerTo(target); + } + /// Animates the divider to [target] with the settle machinery. + void _animateDividerTo(double target) { + final availableWidth = _lastExpandedWidth; final begin = _paneWidth.width(availableWidth); final curve = CurvedAnimation( parent: _settleController, @@ -893,6 +897,31 @@ class _ListDetailLayoutState extends State> }); } + /// Pre-collapse position as a fraction of the window, so a restore + /// after a resize lands proportionally. + double? _preCollapseFraction; + + /// Double-tap on the divider: collapse to [PaneConfig.minListWidth], + /// or restore the pre-collapse position (default width when none). + void _handleDividerDoubleTap() { + final availableWidth = _lastExpandedWidth; + final current = _paneWidth.width(availableWidth); + final collapsed = widget.paneConfig.minListWidth; + if (current > collapsed + 0.5) { + _preCollapseFraction = current / availableWidth; + _animateDividerTo(collapsed); + } else { + final fraction = _preCollapseFraction; + final restore = fraction != null + ? fraction * availableWidth + : PaneWidthModel( + widget.paneConfig, + referenceWidth: _referenceWidth, + ).width(availableWidth); + _animateDividerTo(restore); + } + } + // =========================================================================== // BREAKPOINT CROSSINGS // =========================================================================== 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 d2150dc..54a2a59 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -151,6 +151,11 @@ extension _LayoutBuilders on _ListDetailLayoutState { onHorizontalDragUpdate: (d) => _handleDividerDragUpdate(d.primaryDelta ?? 0), onHorizontalDragEnd: (_) => _handleDividerDragEnd(), + // Registered only when enabled: a double-tap recognizer + // adds a disambiguation delay to other taps in the zone. + onDoubleTap: widget.paneConfig.collapseOnDoubleTap + ? _handleDividerDoubleTap + : null, child: SizedBox( width: widget.paneConfig.dividerHitWidth, child: dividerBuilder != null diff --git a/lib/src/core/shared/pane_config.dart b/lib/src/core/shared/pane_config.dart index 6f06a4c..d9a224e 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -33,6 +33,7 @@ class PaneConfig { this.settleDuration = const Duration(milliseconds: 220), this.settleCurve = Curves.easeOutCubic, this.dividerHitWidth = 24, + this.collapseOnDoubleTap = false, }); /// Default width of the list pane in logical pixels. @@ -74,6 +75,15 @@ class PaneConfig { /// Width of the divider's drag hit zone, centered on the pane border. final double dividerHitWidth; + /// Double-tapping the divider toggles the pane collapsed and back — + /// the macOS split-view gesture; Material's equivalent is pane + /// expansion anchors at the edges. Collapse animates to + /// [minListWidth], so a TRUE collapse needs `minListWidth: 0` + /// (drag-to-collapse additionally needs `maxListRatio: 1.0` and edge + /// anchors to reach the other side). Restoring returns to the + /// pre-collapse position, or [defaultListWidth] when there is none. + final bool collapseOnDoubleTap; + /// Sensible defaults for a standard list-detail layout. static const standard = PaneConfig(); @@ -97,7 +107,8 @@ class PaneConfig { other.widthMemory == widthMemory && other.settleDuration == settleDuration && other.settleCurve == settleCurve && - other.dividerHitWidth == dividerHitWidth; + other.dividerHitWidth == dividerHitWidth && + other.collapseOnDoubleTap == collapseOnDoubleTap; @override int get hashCode => Object.hash( @@ -112,5 +123,6 @@ class PaneConfig { settleDuration, settleCurve, dividerHitWidth, + collapseOnDoubleTap, ); } diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index 8084bb3..70b70fb 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -223,10 +223,40 @@ class _AdaptiveSplitState extends State /// Animates the divider to the nearest anchor. No-op without anchors. void _settleToNearestAnchor() { - final availableWidth = _lastExpandedWidth; - final target = _paneWidth.snapTarget(availableWidth); + final target = _paneWidth.snapTarget(_lastExpandedWidth); if (target == null) return; + _animateDividerTo(target); + } + + /// Pre-collapse position as a fraction of the window, so a restore + /// after a resize lands proportionally. + double? _preCollapseFraction; + + /// Double-tap on the divider: collapse the primary pane to + /// [PaneConfig.minListWidth], or restore the pre-collapse position + /// (default width when none). + void _handleDividerDoubleTap() { + final availableWidth = _lastExpandedWidth; + final current = _paneWidth.width(availableWidth); + final collapsed = widget.paneConfig.minListWidth; + if (current > collapsed + 0.5) { + _preCollapseFraction = current / availableWidth; + _animateDividerTo(collapsed); + } else { + final fraction = _preCollapseFraction; + final restore = fraction != null + ? fraction * availableWidth + : PaneWidthModel( + widget.paneConfig, + referenceWidth: _referenceWidth, + ).width(availableWidth); + _animateDividerTo(restore); + } + } + /// Animates the divider to [target] with the settle machinery. + void _animateDividerTo(double target) { + final availableWidth = _lastExpandedWidth; final begin = _paneWidth.width(availableWidth); final curve = CurvedAnimation( parent: _settleController, @@ -329,6 +359,11 @@ class _AdaptiveSplitState extends State onHorizontalDragUpdate: (d) => _handleDividerDragUpdate(d.primaryDelta ?? 0), onHorizontalDragEnd: (_) => _handleDividerDragEnd(), + // Registered only when enabled: a double-tap recognizer adds + // a disambiguation delay to other taps in the zone. + onDoubleTap: widget.paneConfig.collapseOnDoubleTap + ? _handleDividerDoubleTap + : null, child: SizedBox( width: widget.paneConfig.dividerHitWidth, child: dividerBuilder != null diff --git a/test/core/list_detail/pane_collapse_test.dart b/test/core/list_detail/pane_collapse_test.dart new file mode 100644 index 0000000..df91461 --- /dev/null +++ b/test/core/list_detail/pane_collapse_test.dart @@ -0,0 +1,92 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +/// Pane collapsing — Material's edge pane-expansion anchors, macOS's +/// split-view collapse: drag (or double-tap) the divider and a pane +/// gives up all its width, then comes back. +void main() { + const collapsible = PaneConfig( + minListWidth: 0, + maxListRatio: 1.0, + anchors: [ + PaneAnchor.proportion(0.0), + PaneAnchor.proportion(0.5), + PaneAnchor.proportion(1.0), + ], + initialAnchorIndex: 1, + collapseOnDoubleTap: true, + ); + + Widget layout() { + return Material( + child: ListDetailLayout( + controller: ListDetailController(initialSelection: 'a'), + paneConfig: collapsible, + 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)), + ), + ); + } + + double listWidth(WidgetTester tester) => + tester.getSize(find.byKey(const Key('list'))).width; + + Future doubleTapDivider(WidgetTester tester, double x) async { + await tester.tapAt(Offset(x, 400)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(Offset(x, 400)); + await tester.pumpAndSettle(); + } + + testWidgets('dragging to the edge collapses the pane; back restores', ( + tester, + ) async { + await pumpApp(tester, layout(), size: const Size(1000, 800)); + expect(listWidth(tester), 500); + + // Hard drag left: settles on the 0% anchor — the list is GONE. + await tester.dragFrom(const Offset(500, 400), const Offset(-450, 0)); + await tester.pumpAndSettle(); + expect(listWidth(tester), 0); + expect(tester.getSize(find.byKey(const Key('detail'))).width, 1000); + + // The divider survives at the edge — drag it back out. + await tester.dragFrom(const Offset(5, 400), const Offset(480, 0)); + await tester.pumpAndSettle(); + expect(listWidth(tester), 500); + + // Hard drag right: the DETAIL collapses instead. + await tester.dragFrom(const Offset(500, 400), const Offset(460, 0)); + await tester.pumpAndSettle(); + expect(listWidth(tester), 1000); + expect(tester.getSize(find.byKey(const Key('detail'))).width, 0); + }); + + testWidgets('double-tap collapses; double-tap again restores', ( + tester, + ) async { + await pumpApp(tester, layout(), size: const Size(1000, 800)); + + await doubleTapDivider(tester, 500); + expect(listWidth(tester), 0); + + await doubleTapDivider(tester, 5); + expect(listWidth(tester), 500); // back to the pre-collapse position + }); + + testWidgets('double-tap restore lands proportionally after a resize', ( + tester, + ) async { + await pumpApp(tester, layout(), size: const Size(1000, 800)); + await doubleTapDivider(tester, 500); // collapsed at 50% + + await resizeWindow(tester, const Size(800, 800)); + await doubleTapDivider(tester, 5); + expect(listWidth(tester), closeTo(400, 1)); // 50% of the new width + }); +} diff --git a/test/core/split/adaptive_split_test.dart b/test/core/split/adaptive_split_test.dart index 5f9cb7b..b489aeb 100644 --- a/test/core/split/adaptive_split_test.dart +++ b/test/core/split/adaptive_split_test.dart @@ -47,6 +47,35 @@ void main() { expect(find.text('primary exp'), findsOneWidget); }); + testWidgets('double-tap collapses the primary pane and restores it', ( + tester, + ) async { + await pumpApp( + tester, + buildSplit( + paneConfig: const PaneConfig( + minListWidth: 0, + maxListRatio: 1.0, + collapseOnDoubleTap: true, + ), + ), + size: expanded, + ); + final before = tester.getRect(find.byKey(const Key('primary'))).width; + + await tester.tapAt(Offset(before, 400)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(Offset(before, 400)); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const Key('primary'))).width, 0); + + await tester.tapAt(const Offset(5, 400)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(const Offset(5, 400)); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const Key('primary'))).width, before); + }); + testWidgets('widthMemory.resetOnReentry forgets the drag on re-entry', ( tester, ) async { From eab3e3d37911e5b87aec13375aaeba4fc8edb947 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 07:35:18 +0530 Subject: [PATCH 29/41] feat!: snap-collapse panes (VS Code spec) + DividerState contract --- CHANGELOG.md | 1 - README.md | 2 - docs/CAPABILITY_ROADMAP.md | 1 - example/lib/main.dart | 11 -- lib/adaptive_layouts.dart | 1 + .../components/dividers/handle_divider.dart | 106 +++++++++++---- .../components/dividers/material_divider.dart | 10 +- .../core/list_detail/list_detail_layout.dart | 40 ++---- .../list_detail_layout_builders.dart | 64 +++++++-- lib/src/core/shared/divider_builder.dart | 68 +++++++++- lib/src/core/shared/pane_collapse.dart | 45 +++++++ lib/src/core/shared/pane_config.dart | 27 ++-- lib/src/core/shared/pane_width_model.dart | 120 ++++++++++++++++- lib/src/core/split/adaptive_split.dart | 126 ++++++++++-------- .../dividers/handle_divider_test.dart | 3 +- .../dividers/material_divider_test.dart | 3 +- .../list_detail/list_detail_layout_test.dart | 75 ++++++++++- test/core/list_detail/pane_collapse_test.dart | 92 ------------- test/core/shared/pane_width_model_test.dart | 85 ++++++++++++ test/core/split/adaptive_split_test.dart | 29 ---- 20 files changed, 615 insertions(+), 294 deletions(-) create mode 100644 lib/src/core/shared/pane_collapse.dart delete mode 100644 test/core/list_detail/pane_collapse_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7131796..7093d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,6 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. -- **Pane collapsing:** edge anchors with open clamps settle a pane to zero width (Material's pane-expansion edges, macOS's split-view collapse); `PaneConfig(collapseOnDoubleTap: true)` adds double-tap-to-collapse with proportional restore. - **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. - **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 cb0b8d6..2c91ca1 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,6 @@ Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfo Entering expanded, the arriving list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer the list to lay out live and grow into its pane instead? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. -Panes can collapse — Material's pane-expansion anchors at the edges, macOS's split-view collapse. Open the clamps (`minListWidth: 0`, `maxListRatio: 1.0`) with edge anchors and a hard drag settles a pane to nothing; `PaneConfig(collapseOnDoubleTap: true)` adds the macOS gesture — double-tap the divider to collapse, double-tap again to restore the previous position. - The divider remembers. A dragged divider position survives compact spells, window resizes, and rebuilds — `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. Prefer a fresh divider on every return to the wide layout instead? `PaneConfig(widthMemory: PaneWidthMemory.resetOnReentry)`. ### Sizing the panes diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 8902e88..695bcf1 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -62,7 +62,6 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | -| Pane collapsing | DONE | Edge anchors + open clamps (`minListWidth: 0`, `maxListRatio: 1.0`) settle a pane to zero; `collapseOnDoubleTap` adds the macOS divider gesture with proportional restore. Both pane widgets | | Initial width from anchor index | DONE | `PaneConfig.initialAnchorIndex`, anchors non-empty | ## Components diff --git a/example/lib/main.dart b/example/lib/main.dart index f535446..61e3423 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -143,7 +143,6 @@ class PackageSettings extends ChangeNotifier { PaneWidthMemory widthMemory = PaneWidthMemory.persist; ExpandedEmptyBehavior emptyBehavior = ExpandedEmptyBehavior.placeholder; bool autoSelectFirst = false; - bool collapsiblePanes = false; double expandedBreakpoint = 720; bool handleBackGesture = true; int slideDurationMs = 300; @@ -165,11 +164,6 @@ class PackageSettings extends ChangeNotifier { resizeMode: resizeMode, entryStyle: entryStyle, widthMemory: widthMemory, - // Collapsible: open the clamps so the edge anchors are reachable and - // double-tap has a true zero to collapse to. - minListWidth: collapsiblePanes ? 0 : 200, - maxListRatio: collapsiblePanes ? 1.0 : 0.5, - collapseOnDoubleTap: collapsiblePanes, anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], ); @@ -337,11 +331,6 @@ class PackageSettingsPanel extends StatelessWidget { value: s.anchorsEnabled, onChanged: (v) => s.update((s) => s.anchorsEnabled = v), ), - _toggle( - label: 'Collapsible panes (double-tap divider)', - value: s.collapsiblePanes, - onChanged: (v) => s.update((s) => s.collapsiblePanes = v), - ), _choice( context: context, label: 'Expand-entry list style', diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 17d7101..4b8191a 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -35,6 +35,7 @@ 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_resize_mode.dart'; export 'src/core/shared/pane_width_memory.dart'; diff --git a/lib/src/components/dividers/handle_divider.dart b/lib/src/components/dividers/handle_divider.dart index 309a12a..c17a12c 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,23 @@ 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 +107,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/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 17c0318..02f1ade 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -12,6 +12,7 @@ import 'package:adaptive_layouts/src/core/list_detail/paint_visibility_detector. 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_width_memory.dart'; import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; @@ -851,12 +852,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) { @@ -867,14 +872,10 @@ class _ListDetailLayoutState extends State> /// Animates the divider to the nearest anchor. No-op without anchors. void _settleToNearestAnchor() { - final target = _paneWidth.snapTarget(_lastExpandedWidth); + final availableWidth = _lastExpandedWidth; + final target = _paneWidth.snapTarget(availableWidth); if (target == null) return; - _animateDividerTo(target); - } - /// Animates the divider to [target] with the settle machinery. - void _animateDividerTo(double target) { - final availableWidth = _lastExpandedWidth; final begin = _paneWidth.width(availableWidth); final curve = CurvedAnimation( parent: _settleController, @@ -897,31 +898,6 @@ class _ListDetailLayoutState extends State> }); } - /// Pre-collapse position as a fraction of the window, so a restore - /// after a resize lands proportionally. - double? _preCollapseFraction; - - /// Double-tap on the divider: collapse to [PaneConfig.minListWidth], - /// or restore the pre-collapse position (default width when none). - void _handleDividerDoubleTap() { - final availableWidth = _lastExpandedWidth; - final current = _paneWidth.width(availableWidth); - final collapsed = widget.paneConfig.minListWidth; - if (current > collapsed + 0.5) { - _preCollapseFraction = current / availableWidth; - _animateDividerTo(collapsed); - } else { - final fraction = _preCollapseFraction; - final restore = fraction != null - ? fraction * availableWidth - : PaneWidthModel( - widget.paneConfig, - referenceWidth: _referenceWidth, - ).width(availableWidth); - _animateDividerTo(restore); - } - } - // =========================================================================== // BREAKPOINT CROSSINGS // =========================================================================== 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 54a2a59..42d2109 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -53,8 +53,22 @@ extension _LayoutBuilders on _ListDetailLayoutState { final detailId = listOnly ? _visibleDetailId : selectedId; final dividerBuilder = widget.dividerBuilder; + final collapsed = _paneWidth.collapsed; + Widget list = widget.listBuilder(context, selectedId, _handleSelect); - if (entry < 1.0 && + if (collapsed == PaneSide.start) { + // A collapsed pane's content stays laid out at its minimum width — + // its last legal layout — and clips as the slot shrinks. Content + // never reflows below the floor the app designed for. + list = ClipRect( + child: OverflowBox( + minWidth: widget.paneConfig.minListWidth, + maxWidth: widget.paneConfig.minListWidth, + alignment: AlignmentDirectional.centerStart, + child: list, + ), + ); + } 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 @@ -73,6 +87,9 @@ extension _LayoutBuilders on _ListDetailLayoutState { ); } + // 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 @@ -93,7 +110,17 @@ extension _LayoutBuilders on _ListDetailLayoutState { ), ), ); - if (listOnly && pane < 1.0) { + if (collapsed == PaneSide.end) { + // Same clip-at-floor discipline as a collapsed start pane. + detailSlot = ClipRect( + child: OverflowBox( + minWidth: minDetailWidth, + maxWidth: minDetailWidth, + alignment: AlignmentDirectional.centerEnd, + child: detailSlot, + ), + ); + } 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 @@ -139,10 +166,15 @@ extension _LayoutBuilders on _ListDetailLayoutState { // 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. - start: listWidth - widget.paneConfig.dividerHitWidth / 2, + start: (listWidth - widget.paneConfig.dividerHitWidth / 2).clamp( + 0.0, + availableWidth - widget.paneConfig.dividerHitWidth, + ), top: 0, bottom: 0, child: GestureDetector( @@ -151,19 +183,10 @@ extension _LayoutBuilders on _ListDetailLayoutState { onHorizontalDragUpdate: (d) => _handleDividerDragUpdate(d.primaryDelta ?? 0), onHorizontalDragEnd: (_) => _handleDividerDragEnd(), - // Registered only when enabled: a double-tap recognizer - // adds a disambiguation delay to other taps in the zone. - onDoubleTap: widget.paneConfig.collapseOnDoubleTap - ? _handleDividerDoubleTap - : null, child: SizedBox( width: widget.paneConfig.dividerHitWidth, child: dividerBuilder != null - ? dividerBuilder( - context, - _isDividerDragging, - _isDividerSettling, - ) + ? dividerBuilder(context, _dividerState(availableWidth)) : null, ), ), @@ -320,6 +343,21 @@ extension _LayoutBuilders on _ListDetailLayoutState { // SHARED SLIDING DETAIL (used by inline and overlay compact modes) // =========================================================================== + /// The divider's interaction state for [DividerBuilder]s. + DividerState _dividerState(double availableWidth) { + 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, + ); + } + /// The sliding detail pane with swipe-to-dismiss gesture. /// Used by both [buildCompactLayout] (inline) and /// `buildCompactOverlayLayout` (in the overlay). diff --git a/lib/src/core/shared/divider_builder.dart b/lib/src/core/shared/divider_builder.dart index a9d01d0..5bf8e16 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 /// 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/pane_collapse.dart b/lib/src/core/shared/pane_collapse.dart new file mode 100644 index 0000000..695d3ad --- /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 `AdaptiveSplit` 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 d9a224e..04c29a4 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -2,6 +2,7 @@ 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'; @@ -33,7 +34,8 @@ class PaneConfig { this.settleDuration = const Duration(milliseconds: 220), this.settleCurve = Curves.easeOutCubic, this.dividerHitWidth = 24, - this.collapseOnDoubleTap = false, + this.collapsible = PaneCollapsible.none, + this.collapsedSize = 0, }); /// Default width of the list pane in logical pixels. @@ -75,14 +77,15 @@ class PaneConfig { /// Width of the divider's drag hit zone, centered on the pane border. final double dividerHitWidth; - /// Double-tapping the divider toggles the pane collapsed and back — - /// the macOS split-view gesture; Material's equivalent is pane - /// expansion anchors at the edges. Collapse animates to - /// [minListWidth], so a TRUE collapse needs `minListWidth: 0` - /// (drag-to-collapse additionally needs `maxListRatio: 1.0` and edge - /// anchors to reach the other side). Restoring returns to the - /// pre-collapse position, or [defaultListWidth] when there is none. - final bool collapseOnDoubleTap; + /// 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; /// Sensible defaults for a standard list-detail layout. static const standard = PaneConfig(); @@ -108,7 +111,8 @@ class PaneConfig { other.settleDuration == settleDuration && other.settleCurve == settleCurve && other.dividerHitWidth == dividerHitWidth && - other.collapseOnDoubleTap == collapseOnDoubleTap; + other.collapsible == collapsible && + other.collapsedSize == collapsedSize; @override int get hashCode => Object.hash( @@ -123,6 +127,7 @@ class PaneConfig { settleDuration, settleCurve, dividerHitWidth, - collapseOnDoubleTap, + collapsible, + collapsedSize, ); } diff --git a/lib/src/core/shared/pane_width_model.dart b/lib/src/core/shared/pane_width_model.dart index 74a5b54..8442b0f 100644 --- a/lib/src/core/shared/pane_width_model.dart +++ b/lib/src/core/shared/pane_width_model.dart @@ -1,3 +1,4 @@ +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'; @@ -74,26 +75,133 @@ 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 (config.collapsible.allows(PaneSide.start) && + raw < min - _collapseThreshold(PaneSide.start, availableWidth)) { + _collapsed = PaneSide.start; + return; + } + if (config.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 [PaneConfig.collapsible] disallows it. + void collapse(PaneSide side, double availableWidth) { + if (!config.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); + /// Sets the width to an exact pixel value (used by the settle animation). void setWidth(double widthPx, double availableWidth) { if (_isRatioMode) { diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index 70b70fb..4a8e059 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -2,6 +2,7 @@ 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_width_memory.dart'; import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; @@ -202,12 +203,16 @@ class _AdaptiveSplitState 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) { @@ -223,40 +228,10 @@ class _AdaptiveSplitState extends State /// Animates the divider to the nearest anchor. No-op without anchors. void _settleToNearestAnchor() { - final target = _paneWidth.snapTarget(_lastExpandedWidth); - if (target == null) return; - _animateDividerTo(target); - } - - /// Pre-collapse position as a fraction of the window, so a restore - /// after a resize lands proportionally. - double? _preCollapseFraction; - - /// Double-tap on the divider: collapse the primary pane to - /// [PaneConfig.minListWidth], or restore the pre-collapse position - /// (default width when none). - void _handleDividerDoubleTap() { final availableWidth = _lastExpandedWidth; - final current = _paneWidth.width(availableWidth); - final collapsed = widget.paneConfig.minListWidth; - if (current > collapsed + 0.5) { - _preCollapseFraction = current / availableWidth; - _animateDividerTo(collapsed); - } else { - final fraction = _preCollapseFraction; - final restore = fraction != null - ? fraction * availableWidth - : PaneWidthModel( - widget.paneConfig, - referenceWidth: _referenceWidth, - ).width(availableWidth); - _animateDividerTo(restore); - } - } + final target = _paneWidth.snapTarget(availableWidth); + if (target == null) return; - /// Animates the divider to [target] with the settle machinery. - void _animateDividerTo(double target) { - final availableWidth = _lastExpandedWidth; final begin = _paneWidth.width(availableWidth); final curve = CurvedAnimation( parent: _settleController, @@ -314,26 +289,63 @@ class _AdaptiveSplitState extends State ); } + /// The divider's interaction state for [DividerBuilder]s. + DividerState _dividerState(double availableWidth) { + 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, + ); + } + /// 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; - final primaryPane = SizedBox( - width: primaryWidth, - child: KeyedSubtree( - key: _primaryKey, - child: widget.primaryBuilder(context, true), - ), + // 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 = ClipRect( + child: OverflowBox( + minWidth: widget.paneConfig.minListWidth, + maxWidth: widget.paneConfig.minListWidth, + alignment: AlignmentDirectional.centerStart, + child: primaryContent, + ), + ); + } + final primaryPane = SizedBox(width: primaryWidth, child: primaryContent); - final secondaryPane = Expanded( - child: KeyedSubtree( - key: _secondaryKey, - child: widget.secondaryBuilder(context, true), - ), + Widget secondaryContent = KeyedSubtree( + key: _secondaryKey, + child: widget.secondaryBuilder(context, true), ); + if (collapsed == PaneSide.end) { + final minSecondary = + availableWidth * (1 - widget.paneConfig.maxListRatio); + secondaryContent = ClipRect( + child: OverflowBox( + minWidth: minSecondary, + maxWidth: minSecondary, + alignment: AlignmentDirectional.centerEnd, + child: secondaryContent, + ), + ); + } + final secondaryPane = Expanded(child: secondaryContent); final isStart = widget.primaryPosition == SplitPrimaryPosition.start; final first = isStart ? primaryPane : secondaryPane; @@ -346,11 +358,16 @@ class _AdaptiveSplitState extends State // 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, + 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: GestureDetector( @@ -359,19 +376,10 @@ class _AdaptiveSplitState extends State onHorizontalDragUpdate: (d) => _handleDividerDragUpdate(d.primaryDelta ?? 0), onHorizontalDragEnd: (_) => _handleDividerDragEnd(), - // Registered only when enabled: a double-tap recognizer adds - // a disambiguation delay to other taps in the zone. - onDoubleTap: widget.paneConfig.collapseOnDoubleTap - ? _handleDividerDoubleTap - : null, child: SizedBox( width: widget.paneConfig.dividerHitWidth, child: dividerBuilder != null - ? dividerBuilder( - context, - _isDividerDragging, - _isDividerSettling, - ) + ? dividerBuilder(context, _dividerState(availableWidth)) : null, ), ), diff --git a/test/components/dividers/handle_divider_test.dart b/test/components/dividers/handle_divider_test.dart index b30f860..29b1c67 100644 --- a/test/components/dividers/handle_divider_test.dart +++ b/test/components/dividers/handle_divider_test.dart @@ -12,7 +12,8 @@ void main() { height: 400, child: Builder( builder: (context) => - HandleDivider.builder(context, isDragging, isSettling), + 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..8b70a30 100644 --- a/test/components/dividers/material_divider_test.dart +++ b/test/components/dividers/material_divider_test.dart @@ -11,7 +11,8 @@ void main() { height: 400, child: Builder( builder: (context) => - MaterialDivider.builder(context, isDragging, isSettling), + MaterialDivider.builder(context, + DividerState(isDragging: isDragging, isSettling: isSettling)), ), ), ), diff --git a/test/core/list_detail/list_detail_layout_test.dart b/test/core/list_detail/list_detail_layout_test.dart index 57f6df9..2794d4b 100644 --- a/test/core/list_detail/list_detail_layout_test.dart +++ b/test/core/list_detail/list_detail_layout_test.dart @@ -198,6 +198,77 @@ void main() { ); }); + 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 { @@ -238,8 +309,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/pane_collapse_test.dart b/test/core/list_detail/pane_collapse_test.dart deleted file mode 100644 index df91461..0000000 --- a/test/core/list_detail/pane_collapse_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:adaptive_layouts/adaptive_layouts.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers.dart'; - -/// Pane collapsing — Material's edge pane-expansion anchors, macOS's -/// split-view collapse: drag (or double-tap) the divider and a pane -/// gives up all its width, then comes back. -void main() { - const collapsible = PaneConfig( - minListWidth: 0, - maxListRatio: 1.0, - anchors: [ - PaneAnchor.proportion(0.0), - PaneAnchor.proportion(0.5), - PaneAnchor.proportion(1.0), - ], - initialAnchorIndex: 1, - collapseOnDoubleTap: true, - ); - - Widget layout() { - return Material( - child: ListDetailLayout( - controller: ListDetailController(initialSelection: 'a'), - paneConfig: collapsible, - 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)), - ), - ); - } - - double listWidth(WidgetTester tester) => - tester.getSize(find.byKey(const Key('list'))).width; - - Future doubleTapDivider(WidgetTester tester, double x) async { - await tester.tapAt(Offset(x, 400)); - await tester.pump(const Duration(milliseconds: 80)); - await tester.tapAt(Offset(x, 400)); - await tester.pumpAndSettle(); - } - - testWidgets('dragging to the edge collapses the pane; back restores', ( - tester, - ) async { - await pumpApp(tester, layout(), size: const Size(1000, 800)); - expect(listWidth(tester), 500); - - // Hard drag left: settles on the 0% anchor — the list is GONE. - await tester.dragFrom(const Offset(500, 400), const Offset(-450, 0)); - await tester.pumpAndSettle(); - expect(listWidth(tester), 0); - expect(tester.getSize(find.byKey(const Key('detail'))).width, 1000); - - // The divider survives at the edge — drag it back out. - await tester.dragFrom(const Offset(5, 400), const Offset(480, 0)); - await tester.pumpAndSettle(); - expect(listWidth(tester), 500); - - // Hard drag right: the DETAIL collapses instead. - await tester.dragFrom(const Offset(500, 400), const Offset(460, 0)); - await tester.pumpAndSettle(); - expect(listWidth(tester), 1000); - expect(tester.getSize(find.byKey(const Key('detail'))).width, 0); - }); - - testWidgets('double-tap collapses; double-tap again restores', ( - tester, - ) async { - await pumpApp(tester, layout(), size: const Size(1000, 800)); - - await doubleTapDivider(tester, 500); - expect(listWidth(tester), 0); - - await doubleTapDivider(tester, 5); - expect(listWidth(tester), 500); // back to the pre-collapse position - }); - - testWidgets('double-tap restore lands proportionally after a resize', ( - tester, - ) async { - await pumpApp(tester, layout(), size: const Size(1000, 800)); - await doubleTapDivider(tester, 500); // collapsed at 50% - - await resizeWindow(tester, const Size(800, 800)); - await doubleTapDivider(tester, 5); - expect(listWidth(tester), closeTo(400, 1)); // 50% of the new width - }); -} 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/adaptive_split_test.dart index b489aeb..5f9cb7b 100644 --- a/test/core/split/adaptive_split_test.dart +++ b/test/core/split/adaptive_split_test.dart @@ -47,35 +47,6 @@ void main() { expect(find.text('primary exp'), findsOneWidget); }); - testWidgets('double-tap collapses the primary pane and restores it', ( - tester, - ) async { - await pumpApp( - tester, - buildSplit( - paneConfig: const PaneConfig( - minListWidth: 0, - maxListRatio: 1.0, - collapseOnDoubleTap: true, - ), - ), - size: expanded, - ); - final before = tester.getRect(find.byKey(const Key('primary'))).width; - - await tester.tapAt(Offset(before, 400)); - await tester.pump(const Duration(milliseconds: 80)); - await tester.tapAt(Offset(before, 400)); - await tester.pumpAndSettle(); - expect(tester.getRect(find.byKey(const Key('primary'))).width, 0); - - await tester.tapAt(const Offset(5, 400)); - await tester.pump(const Duration(milliseconds: 80)); - await tester.tapAt(const Offset(5, 400)); - await tester.pumpAndSettle(); - expect(tester.getRect(find.byKey(const Key('primary'))).width, before); - }); - testWidgets('widthMemory.resetOnReentry forgets the drag on re-entry', ( tester, ) async { From ef6c0582089ea6fd1932caddfe89fe07d757279d Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 08:03:02 +0530 Subject: [PATCH 30/41] feat: divider keyboard/a11y (WAI-ARIA splitter) + PaneScope + double-click reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaneDividerRegion consolidates all divider interactivity for both layouts: drag, double-click reset to default width, Tab focus, arrow-key resize, Enter collapse/restore, Home/End jumps, and screen-reader adjustable semantics (share value + stepped values). PaneScope exposes collapse state and collapse/restore actions to pane descendants — the hamburger recipe. AdaptiveSplit translates model space to directional sides at its boundary so PaneSide/PaneCollapsible stay directional with an end-positioned primary (state, config, jumps all mirrored). Settle-to-width extracted as the single settle primitive; model gains defaultWidth(); PaneConfig gains dividerSemanticsLabel. --- CHANGELOG.md | 5 +- CHANGELOG.pre.md | 5 +- README.md | 42 +++ docs/CAPABILITY_ROADMAP.md | 3 + docs/UPDATING.md | 37 +++ example/lib/main.dart | 29 +- lib/adaptive_layouts.dart | 2 + .../components/dividers/handle_divider.dart | 7 +- .../core/list_detail/list_detail_layout.dart | 239 ++++++++++----- .../list_detail_layout_builders.dart | 51 +++- lib/src/core/shared/pane_config.dart | 8 +- lib/src/core/shared/pane_divider_region.dart | 173 +++++++++++ lib/src/core/shared/pane_scope.dart | 66 +++++ lib/src/core/shared/pane_width_model.dart | 40 ++- lib/src/core/split/adaptive_split.dart | 209 +++++++++++-- .../dividers/handle_divider_test.dart | 7 +- .../dividers/material_divider_test.dart | 7 +- .../list_detail/divider_interaction_test.dart | 279 ++++++++++++++++++ .../list_detail/list_detail_layout_test.dart | 6 +- test/core/split/adaptive_split_test.dart | 98 ++++++ 20 files changed, 1181 insertions(+), 132 deletions(-) create mode 100644 lib/src/core/shared/pane_divider_region.dart create mode 100644 lib/src/core/shared/pane_scope.dart create mode 100644 test/core/list_detail/divider_interaction_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7093d32..cb198c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,9 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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. +- **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 `AdaptiveSplit`'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. +- **PaneScope:** descendants of either pane read the collapse state 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 03f312e..f175ff3 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -69,6 +69,9 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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. - **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. +- **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 `AdaptiveSplit`'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. +- **PaneScope:** descendants of either pane read the collapse state 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 2c91ca1..06134c5 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,48 @@ PaneConfig( PaneConfig(resizeMode: PaneResizeMode.pixels) ``` +### Snap-collapse and the divider's keyboard + +Panes can collapse — force the divider past a pane's minimum and it snaps +shut, VS Code style. Opt in per side: + +```dart +PaneConfig( + collapsible: PaneCollapsible.start, // none / start / end / both + collapsedSize: 56, // 0 hides fully; 56 keeps an icon rail +) +``` + +The mechanics follow desktop split views: 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 (the shipped `HandleDivider` turns into a pull tab), and the +surviving pane can offer its own affordance by reading `PaneScope`: + +```dart +// Inside a detail pane — the hamburger recipe: +final scope = PaneScope.maybeOf(context); +if (scope?.collapsed == PaneSide.start) + IconButton(icon: const Icon(Icons.menu), onPressed: scope!.restore) +``` + +`PaneScope` also exposes `collapse(PaneSide)` for app-driven collapse +buttons. Programmatic collapse/restore snap instantly; the drag path is +the animated one. + +The divider itself follows the WAI-ARIA window-splitter pattern — it's +focusable and screen-reader adjustable out of the box: + +| Input | Effect | +|---|---| +| 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%") | + +Localize the announcement via `PaneConfig(dividerSemanticsLabel: ...)`. + For the wide layout's "nothing selected" area, pass any builder — or the shipped one: ```dart diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 695bcf1..68c37d9 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -61,6 +61,9 @@ 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 | +| 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 `AdaptiveSplit` end-positioned primary | +| 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`/`isExpanded` + `collapse`/`restore` actions; the hamburger recipe | | Anchor snap points with settle animation | DONE | Nearest anchor on drag end; `isSettling` fed to divider builders | | Initial width from anchor index | DONE | `PaneConfig.initialAnchorIndex`, anchors non-empty | diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 476a9fe..06feed4 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -244,6 +244,43 @@ The machinery in `list_detail_layout.dart` holds: 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). + `AdaptiveSplit` 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 stays laid out at its minimum width** inside + `ClipRect` + `OverflowBox` (start pane clips at `minListWidth`, end + pane at `available * (1 - maxListRatio)`) — content never reflows + below its floor while the slot shrinks. +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. **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}/`. diff --git a/example/lib/main.dart b/example/lib/main.dart index 61e3423..2e0a5bb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -142,6 +142,8 @@ class PackageSettings extends ChangeNotifier { 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; @@ -165,6 +167,8 @@ class PackageSettings extends ChangeNotifier { entryStyle: entryStyle, widthMemory: widthMemory, anchors: anchorsEnabled ? PaneAnchor.listDetail : const [], + collapsible: collapsible, + collapsedSize: collapseToIconRail ? 56 : 0, ); CompactConfig get compactConfig => CompactConfig( @@ -355,6 +359,19 @@ class PackageSettingsPanel extends StatelessWidget { 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, @@ -2249,6 +2266,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 @@ -2256,7 +2277,13 @@ class _TicketPaneState extends State { icon: const Icon(Icons.arrow_back), onPressed: widget.onDismiss, ) - : null, + : (paneScope?.collapsed == PaneSide.start + ? IconButton( + icon: const Icon(Icons.menu), + tooltip: 'Show list', + onPressed: paneScope!.restore, + ) + : null), automaticallyImplyLeading: false, title: Row( children: [ diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 4b8191a..e42fa5a 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -37,6 +37,8 @@ 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'; diff --git a/lib/src/components/dividers/handle_divider.dart b/lib/src/components/dividers/handle_divider.dart index c17a12c..bee79f4 100644 --- a/lib/src/components/dividers/handle_divider.dart +++ b/lib/src/components/dividers/handle_divider.dart @@ -57,8 +57,11 @@ class _HandleDividerState extends State { final state = widget.state; if (state.collapsed != null) { - return _PullTab(collapsed: state.collapsed!, hovering: _isHovering, - onHover: (v) => setState(() => _isHovering = v)); + return _PullTab( + collapsed: state.collapsed!, + hovering: _isHovering, + onHover: (v) => setState(() => _isHovering = v), + ); } final isActive = _isHovering || state.isDragging || state.isFocused; diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 02f1ade..725ec88 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -14,6 +14,8 @@ 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'; @@ -872,11 +874,15 @@ 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: widget.paneConfig.settleCurve, @@ -898,6 +904,77 @@ 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, + collapse: _collapsePane, + restore: _restorePane, + ); + // =========================================================================== // BREAKPOINT CROSSINGS // =========================================================================== @@ -1023,87 +1100,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; + builder: (context, constraints) => PaneScope( + data: _paneScopeData(), + child: _buildForConstraints(context, constraints), + ), + ); + } - final crossing = _detectCrossing(isExpanded); + Widget _buildForConstraints( + BuildContext context, + BoxConstraints 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(); + final crossing = _detectCrossing(isExpanded); - _handleCrossing(crossing); + // Evaluate overlay visibility (immediate hide for inactive tabs). + if (_useOverlay) _paintVisibility.evaluate(); - // 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; - } - } + _handleCrossing(crossing); - 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, - ), - ); - } + // 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 (_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, - ); + 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 || _emptyPaneRetreating - ? 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 42d2109..0186b3d 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -88,7 +88,8 @@ extension _LayoutBuilders on _ListDetailLayoutState { } // The end pane's floor mirrors the start pane's ceiling. - final minDetailWidth = availableWidth * (1 - widget.paneConfig.maxListRatio); + final minDetailWidth = + availableWidth * (1 - widget.paneConfig.maxListRatio); Widget detailSlot; if (detailId != null) { @@ -177,17 +178,30 @@ extension _LayoutBuilders on _ListDetailLayoutState { ), top: 0, bottom: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => _handleDividerDragStart(), - onHorizontalDragUpdate: (d) => - _handleDividerDragUpdate(d.primaryDelta ?? 0), - onHorizontalDragEnd: (_) => _handleDividerDragEnd(), - child: SizedBox( - width: widget.paneConfig.dividerHitWidth, - child: dividerBuilder != null - ? dividerBuilder(context, _dividerState(availableWidth)) - : null, + 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, ), ), ), @@ -343,8 +357,18 @@ extension _LayoutBuilders on _ListDetailLayoutState { // SHARED SLIDING DETAIL (used by inline and overlay compact modes) // =========================================================================== + /// 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) { + final clamped = (width + delta).clamp( + widget.paneConfig.minListWidth, + availableWidth * widget.paneConfig.maxListRatio, + ); + return '${(clamped / availableWidth * 100).round()}%'; + } + /// The divider's interaction state for [DividerBuilder]s. - DividerState _dividerState(double availableWidth) { + DividerState _dividerState(double availableWidth, {bool isFocused = false}) { final collapsed = _paneWidth.collapsed; final width = _paneWidth.width(availableWidth); final max = availableWidth * widget.paneConfig.maxListRatio; @@ -355,6 +379,7 @@ extension _LayoutBuilders on _ListDetailLayoutState { collapsed == null && width <= widget.paneConfig.minListWidth + 0.5, atMaximum: collapsed == null && width >= max - 0.5, collapsed: collapsed, + isFocused: isFocused, ); } diff --git a/lib/src/core/shared/pane_config.dart b/lib/src/core/shared/pane_config.dart index 04c29a4..8db56e2 100644 --- a/lib/src/core/shared/pane_config.dart +++ b/lib/src/core/shared/pane_config.dart @@ -36,6 +36,7 @@ class PaneConfig { this.dividerHitWidth = 24, this.collapsible = PaneCollapsible.none, this.collapsedSize = 0, + this.dividerSemanticsLabel = 'Pane divider', }); /// Default width of the list pane in logical pixels. @@ -87,6 +88,9 @@ class PaneConfig { /// (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(); @@ -112,7 +116,8 @@ class PaneConfig { other.settleCurve == settleCurve && other.dividerHitWidth == dividerHitWidth && other.collapsible == collapsible && - other.collapsedSize == collapsedSize; + other.collapsedSize == collapsedSize && + other.dividerSemanticsLabel == dividerSemanticsLabel; @override int get hashCode => Object.hash( @@ -129,5 +134,6 @@ class PaneConfig { 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..4efc41c --- /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 +/// `AdaptiveSplit`: 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..25e0e45 --- /dev/null +++ b/lib/src/core/shared/pane_scope.dart @@ -0,0 +1,66 @@ +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) +/// IconButton(icon: const Icon(Icons.menu), onPressed: scope!.restore) +/// ``` +@immutable +class PaneScopeData { + /// Creates a scope snapshot with its actions. + const PaneScopeData({ + required this.collapsed, + required this.isExpanded, + 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; + + /// 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; +} diff --git a/lib/src/core/shared/pane_width_model.dart b/lib/src/core/shared/pane_width_model.dart index 8442b0f..d4890b9 100644 --- a/lib/src/core/shared/pane_width_model.dart +++ b/lib/src/core/shared/pane_width_model.dart @@ -18,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 (`AdaptiveSplit` 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. @@ -152,12 +162,12 @@ class PaneWidthModel { final min = config.minListWidth; final max = _maxWidth(availableWidth); - if (config.collapsible.allows(PaneSide.start) && + if (collapsible.allows(PaneSide.start) && raw < min - _collapseThreshold(PaneSide.start, availableWidth)) { _collapsed = PaneSide.start; return; } - if (config.collapsible.allows(PaneSide.end) && + if (collapsible.allows(PaneSide.end) && raw > max + _collapseThreshold(PaneSide.end, availableWidth)) { _collapsed = PaneSide.end; return; @@ -180,9 +190,9 @@ class PaneWidthModel { } /// Collapses [side] programmatically, caching the current width for - /// [restoreTarget]. No-op when [PaneConfig.collapsible] disallows it. + /// [restoreTarget]. No-op when [collapsible] disallows it. void collapse(PaneSide side, double availableWidth) { - if (!config.collapsible.allows(side) || _collapsed == side) return; + if (!collapsible.allows(side) || _collapsed == side) return; _cachedWidth = width(availableWidth); _collapsed = side; } @@ -202,6 +212,26 @@ class PaneWidthModel { 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). void setWidth(double widthPx, double availableWidth) { if (_isRatioMode) { diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index 4a8e059..0e39de0 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -4,6 +4,8 @@ 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'; @@ -157,6 +159,7 @@ class _AdaptiveSplitState extends State _paneWidth = PaneWidthModel( widget.paneConfig, referenceWidth: _referenceWidth, + collapsible: _modelCollapsible, ); _settleController = AnimationController( vsync: this, @@ -169,6 +172,10 @@ class _AdaptiveSplitState extends State 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(AdaptiveSplit oldWidget) { super.didUpdateWidget(oldWidget); @@ -181,6 +188,7 @@ class _AdaptiveSplitState extends State _paneWidth = PaneWidthModel( widget.paneConfig, referenceWidth: _referenceWidth, + collapsible: _modelCollapsible, ); } } @@ -197,6 +205,38 @@ class _AdaptiveSplitState extends State 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 // =========================================================================== @@ -228,11 +268,15 @@ class _AdaptiveSplitState 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: widget.paneConfig.settleCurve, @@ -254,6 +298,90 @@ class _AdaptiveSplitState extends State }); } + // =========================================================================== + // 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, + collapse: _collapsePane, + restore: _restorePane, + ); + // =========================================================================== // BUILD // =========================================================================== @@ -267,6 +395,7 @@ class _AdaptiveSplitState extends State widget.expandedBreakpoint, ); final isExpanded = constraints.maxWidth >= breakpoint; + _isExpanded = isExpanded; if (_hasBuiltOnce && !_wasExpandedLastBuild && isExpanded && @@ -277,30 +406,51 @@ class _AdaptiveSplitState extends State _paneWidth = PaneWidthModel( widget.paneConfig, referenceWidth: _referenceWidth, + collapsible: _modelCollapsible, ); } _wasExpandedLastBuild = isExpanded; _hasBuiltOnce = true; - return isExpanded - ? _buildExpandedLayout(constraints) - : _buildCompactLayout(); + return PaneScope( + data: _paneScopeData(), + child: isExpanded + ? _buildExpandedLayout(constraints) + : _buildCompactLayout(), + ); }, ); } - /// The divider's interaction state for [DividerBuilder]s. - DividerState _dividerState(double availableWidth) { + /// 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) { + final clamped = (width + delta).clamp( + widget.paneConfig.minListWidth, + availableWidth * widget.paneConfig.maxListRatio, + ); + 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: - collapsed == null && width <= widget.paneConfig.minListWidth + 0.5, - atMaximum: collapsed == null && width >= max - 0.5, - collapsed: collapsed, + atMinimum: _primaryAtStart ? atModelMin : atModelMax, + atMaximum: _primaryAtStart ? atModelMax : atModelMin, + collapsed: _visualCollapsed, + isFocused: isFocused, ); } @@ -370,17 +520,30 @@ class _AdaptiveSplitState extends State ), top: 0, bottom: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => _handleDividerDragStart(), - onHorizontalDragUpdate: (d) => - _handleDividerDragUpdate(d.primaryDelta ?? 0), - onHorizontalDragEnd: (_) => _handleDividerDragEnd(), - child: SizedBox( - width: widget.paneConfig.dividerHitWidth, - child: dividerBuilder != null - ? dividerBuilder(context, _dividerState(availableWidth)) - : null, + 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, ), ), ), diff --git a/test/components/dividers/handle_divider_test.dart b/test/components/dividers/handle_divider_test.dart index 29b1c67..0be844d 100644 --- a/test/components/dividers/handle_divider_test.dart +++ b/test/components/dividers/handle_divider_test.dart @@ -11,9 +11,10 @@ void main() { width: 24, height: 400, child: Builder( - builder: (context) => - HandleDivider.builder(context, - DividerState(isDragging: isDragging, isSettling: 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 8b70a30..bb69e73 100644 --- a/test/components/dividers/material_divider_test.dart +++ b/test/components/dividers/material_divider_test.dart @@ -10,9 +10,10 @@ void main() { width: 24, height: 400, child: Builder( - builder: (context) => - MaterialDivider.builder(context, - DividerState(isDragging: isDragging, isSettling: isSettling)), + builder: (context) => MaterialDivider.builder( + context, + DividerState(isDragging: isDragging, isSettling: isSettling), + ), ), ), ), 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..78aee74 --- /dev/null +++ b/test/core/list_detail/divider_interaction_test.dart @@ -0,0 +1,279 @@ +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('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_layout_test.dart b/test/core/list_detail/list_detail_layout_test.dart index 2794d4b..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, diff --git a/test/core/split/adaptive_split_test.dart b/test/core/split/adaptive_split_test.dart index 5f9cb7b..1669800 100644 --- a/test/core/split/adaptive_split_test.dart +++ b/test/core/split/adaptive_split_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'; @@ -151,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, + AdaptiveSplit( + 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); From 1435d262e07313e0f6abb352b32ed626a05a0dd0 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 08:10:45 +0530 Subject: [PATCH 31/41] =?UTF-8?q?feat:=20ThreePaneLayout=20=E2=80=94=20rol?= =?UTF-8?q?e-priority=20pane=20scaffold=20(Compose-shaped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up to three panes across two width thresholds: PaneRole priority decides who wins a partition slot (primary survives to the narrowest window), the pane list's order decides placement — decoupled, per Compose's ThreePaneScaffold. The top-priority visible pane flexes; the others hold draggable widths through the shared PaneWidthModel and the full PaneDividerRegion contract (drag, keyboard, double-click reset, screen readers). Hidden panes stay alive in Offstage with tickers paused, so pane state and dragged widths survive partition round trips. PaneSpec deliberately skips value equality (builder closures) — the layout compares sizing fields directly. --- CHANGELOG.md | 1 + CHANGELOG.pre.md | 1 + README.md | 31 ++ docs/CAPABILITY_ROADMAP.md | 6 + lib/adaptive_layouts.dart | 3 + lib/src/core/three_pane/pane_role.dart | 23 ++ lib/src/core/three_pane/pane_spec.dart | 37 ++ .../core/three_pane/three_pane_layout.dart | 363 ++++++++++++++++++ .../three_pane/three_pane_layout_test.dart | 177 +++++++++ 9 files changed, 642 insertions(+) create mode 100644 lib/src/core/three_pane/pane_role.dart create mode 100644 lib/src/core/three_pane/pane_spec.dart create mode 100644 lib/src/core/three_pane/three_pane_layout.dart create mode 100644 test/core/three_pane/three_pane_layout_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index cb198c9..b741403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,5 +78,6 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 `AdaptiveSplit`'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. - **PaneScope:** descendants of either pane read the collapse state and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. +- **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **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 f175ff3..58912bf 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -73,5 +73,6 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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 `AdaptiveSplit`'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. - **PaneScope:** descendants of either pane read the collapse state and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. +- **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **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 06134c5..ba083b1 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,37 @@ AdaptiveSplit( 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. +### Three panes by role priority + +`ThreePaneLayout` is the pane-scaffold shape from Material's adaptive +layouts: up to three panes that appear and yield as the window grows. +Two thresholds carve the width into partitions (one pane below the +expanded breakpoint, two below `largeBreakpoint`, three above). Roles +decide WHO wins a slot — `primary` survives to the narrowest window — +and the pane list's order decides WHERE, decoupled from priority: + +```dart +ThreePaneLayout( + panes: [ + PaneSpec(role: PaneRole.secondary, preferredWidth: 300, + builder: (_) => Outline()), + PaneSpec(role: PaneRole.primary, builder: (_) => Editor()), + PaneSpec(role: PaneRole.tertiary, preferredWidth: 280, + builder: (_) => Inspector()), + ], +) +``` + +The highest-priority visible pane flexes; the others hold draggable +widths (full divider contract — drag, keyboard, double-click reset, +screen readers). Hidden panes stay alive offstage with tickers paused, +so an inspector's scroll position survives the window shrinking and +growing back (`retainHiddenPanes: false` opts out). + +Use `ListDetailLayout` when compact needs list/detail *navigation*; +use `ThreePaneLayout` when panes are supporting surfaces that simply +yield to width. + ### A modal that swaps between dialog and bottom sheet `showAdaptiveModal` presents a real Material dialog on wide windows and a real Material bottom sheet on narrow ones — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply. 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, and a half-typed form field survives the trip. `ModalConfig(morph: false)` swaps instantly instead. diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 68c37d9..0bb2209 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -64,6 +64,12 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 `AdaptiveSplit` end-positioned primary | | 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`/`isExpanded` + `collapse`/`restore` actions; the hamburger recipe | +| ThreePaneLayout: role-priority partitions | DONE | 2-3 panes, two width thresholds; priority picks who shows, list order picks where; flexible pane = highest visible priority; shared width model + divider region per side pane | +| ThreePaneLayout: offstage state retention | DONE | Hidden panes live in Offstage + TickerMode(false); dragged widths and pane state survive partition round trips | +| ThreePaneLayout: levitate adapt strategy (overlay + scrim) | PLANNED | Compose's Levitate — tertiary floats over content on medium widths | +| ThreePaneLayout: partition-change motion | PLANNED | Animate panes in/out on threshold crossings, reusing the crossing-motion machinery | +| ThreePaneLayout: per-pane collapse | PLANNED | Reuse the model's snap-collapse once the multi-divider interplay is designed | +| ListDetailLayout re-derived on the pane-scaffold core | PLANNED | Parallel-copy migration; ListDetailLayout keeps its full compact-mode machinery until the core can express it | | Anchor snap points with settle animation | DONE | Nearest anchor on drag end; `isSettling` fed to divider builders | | Initial width from anchor index | DONE | `PaneConfig.initialAnchorIndex`, anchors non-empty | diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index e42fa5a..04db3bf 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -41,6 +41,9 @@ 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'; +export 'src/core/three_pane/pane_role.dart'; +export 'src/core/three_pane/pane_spec.dart'; +export 'src/core/three_pane/three_pane_layout.dart'; // Components — convenience library (replaceable) export 'src/components/dividers/handle_divider.dart'; diff --git a/lib/src/core/three_pane/pane_role.dart b/lib/src/core/three_pane/pane_role.dart new file mode 100644 index 0000000..f034c51 --- /dev/null +++ b/lib/src/core/three_pane/pane_role.dart @@ -0,0 +1,23 @@ +/// A pane's role in a multi-pane layout, carrying its display priority. +/// +/// When the window has fewer partitions than panes, the highest-priority +/// panes win the slots (the Material adaptive scaffold model: primary +/// content survives longest, tertiary/extra panes yield first). Roles +/// decide WHO shows; the pane list's order decides WHERE. +enum PaneRole { + /// The main content. Last pane standing on the narrowest windows. + primary(10), + + /// Supporting content (a list, an outline, a sidebar). Appears from + /// two partitions up. + secondary(5), + + /// Extra content (an inspector, metadata, a preview). Appears only + /// when a third partition is available. + tertiary(1); + + const PaneRole(this.priority); + + /// Higher wins a partition slot when slots are scarce. + final int priority; +} diff --git a/lib/src/core/three_pane/pane_spec.dart b/lib/src/core/three_pane/pane_spec.dart new file mode 100644 index 0000000..50d840a --- /dev/null +++ b/lib/src/core/three_pane/pane_spec.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +import 'package:adaptive_layouts/src/core/three_pane/pane_role.dart'; + +/// One pane in a `ThreePaneLayout`: its role, content, and sizing. +/// +/// Deliberately no `==` override: [builder] is a closure, and closures +/// constructed inline in `build` never compare equal — a value equality +/// that included them would be always-false, and one that skipped them +/// would call different panes "equal". The layout compares the sizing +/// fields directly instead, so inline-constructed specs never reset a +/// dragged divider. +@immutable +class PaneSpec { + /// Creates a pane spec. + const PaneSpec({ + required this.role, + required this.builder, + this.preferredWidth = 360, + this.minWidth = 200, + }); + + /// Decides which panes survive when partitions are scarce. + final PaneRole role; + + /// The pane's content. Built once; the live widget instance survives + /// partition changes via key reparenting. + final WidgetBuilder builder; + + /// Starting width when this pane is NOT the flexible one. The + /// highest-priority visible pane flexes to fill remaining space; + /// the others hold a draggable width starting here. + final double preferredWidth; + + /// Drag floor for this pane's divider. + final double minWidth; +} diff --git a/lib/src/core/three_pane/three_pane_layout.dart b/lib/src/core/three_pane/three_pane_layout.dart new file mode 100644 index 0000000..3be75ba --- /dev/null +++ b/lib/src/core/three_pane/three_pane_layout.dart @@ -0,0 +1,363 @@ +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_divider_region.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_resize_mode.dart'; +import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; +import 'package:adaptive_layouts/src/core/three_pane/pane_role.dart'; +import 'package:adaptive_layouts/src/core/three_pane/pane_spec.dart'; + +/// Up to three panes that appear and yield by role priority as the +/// window grows and shrinks — the Material adaptive pane-scaffold model. +/// +/// Two thresholds carve the width into partitions: below +/// [expandedBreakpoint] one pane shows, below [largeBreakpoint] two, +/// above it three. When partitions are scarce, the highest-priority +/// roles win the slots ([PaneRole.primary] survives to the narrowest +/// window); the [panes] list's order decides left-to-right placement, +/// decoupled from priority. +/// +/// The highest-priority visible pane flexes to fill remaining space; +/// the others hold a draggable width starting at their +/// [PaneSpec.preferredWidth]. Every divider carries the full interaction +/// contract (drag, keyboard, double-click reset, screen-reader +/// adjustment). Home/End and double-click snap instantly here — this +/// layout has no anchor system to settle against. +/// +/// Hidden panes stay alive offstage (tickers paused), so a pane's +/// scroll position, form drafts, and in-flight state survive partition +/// changes in both directions. +/// +/// ```dart +/// ThreePaneLayout( +/// panes: [ +/// PaneSpec(role: PaneRole.secondary, builder: (_) => Outline()), +/// PaneSpec(role: PaneRole.primary, builder: (_) => Editor()), +/// PaneSpec(role: PaneRole.tertiary, builder: (_) => Inspector()), +/// ], +/// ) +/// ``` +class ThreePaneLayout extends StatefulWidget { + /// Creates the layout. [panes] holds 2 or 3 specs in visual order. + const ThreePaneLayout({ + super.key, + required this.panes, + this.expandedBreakpoint, + this.largeBreakpoint = 1200, + this.dividerBuilder, + this.dividerHitWidth = 24, + this.dividerSemanticsLabel = 'Pane divider', + this.retainHiddenPanes = true, + }) : assert( + panes.length == 2 || panes.length == 3, + 'ThreePaneLayout takes 2 or 3 panes', + ); + + /// The panes, in the left-to-right (directional) order they occupy. + final List panes; + + /// Width from which two partitions are available. Null resolves the + /// app-wide default via [AdaptiveLayoutConfig]. + final double? expandedBreakpoint; + + /// Width from which three partitions are available. + final double largeBreakpoint; + + /// Visual for the dividers; null keeps invisible drag zones. + final DividerBuilder? dividerBuilder; + + /// Width of each divider's hit zone. + final double dividerHitWidth; + + /// Screen-reader label for the dividers. Localize by passing your own. + final String dividerSemanticsLabel; + + /// Keeps hidden panes alive offstage so their state survives + /// partition changes. Turn off to unmount them instead. + final bool retainHiddenPanes; + + @override + State createState() => _ThreePaneLayoutState(); +} + +class _ThreePaneLayoutState extends State { + late List _paneKeys; + + /// Width model per pane index. Only consulted while that pane is + /// visible and not the flexible one, but kept alive throughout so a + /// dragged width survives visibility changes. + late List _models; + + double _lastAvailableWidth = 0; + + @override + void initState() { + super.initState(); + _paneKeys = List.generate(widget.panes.length, (_) => GlobalKey()); + _models = List.generate(widget.panes.length, _buildModel); + } + + @override + void didUpdateWidget(ThreePaneLayout oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.panes.length != oldWidget.panes.length) { + _paneKeys = List.generate(widget.panes.length, (_) => GlobalKey()); + _models = List.generate(widget.panes.length, _buildModel); + return; + } + for (var i = 0; i < widget.panes.length; i++) { + // Sizing fields only — builders are closures that never compare + // equal when constructed inline, and a builder change doesn't + // invalidate a dragged width. + final spec = widget.panes[i]; + final old = oldWidget.panes[i]; + if (spec.preferredWidth != old.preferredWidth || + spec.minWidth != old.minWidth) { + _models[i] = _buildModel(i); + } + } + } + + PaneWidthModel _buildModel(int index) { + final spec = widget.panes[index]; + // Each side pane reuses the shared width model in pixel mode; the + // reference width is unused outside ratio mode. + return PaneWidthModel( + PaneConfig( + defaultListWidth: spec.preferredWidth, + minListWidth: spec.minWidth, + resizeMode: PaneResizeMode.pixels, + ), + referenceWidth: _referenceWidth, + ); + } + + double get _referenceWidth => + widget.expandedBreakpoint ?? + AdaptiveLayoutConfig.defaultExpandedBreakpoint; + + // =========================================================================== + // PARTITIONS AND VISIBILITY + // =========================================================================== + + int _partitions(double width) { + final expanded = AdaptiveLayoutConfig.resolveBreakpoint( + context, + widget.expandedBreakpoint, + ); + if (width < expanded) return 1; + if (width < widget.largeBreakpoint) return 2; + return 3; + } + + /// Pane indices that win a slot, in visual order. Priority decides + /// who; position decides where. Ties keep list order. + List _visibleIndices(int partitions) { + final byPriority = List.generate(widget.panes.length, (i) => i) + ..sort((a, b) { + final cmp = widget.panes[b].role.priority.compareTo( + widget.panes[a].role.priority, + ); + return cmp != 0 ? cmp : a.compareTo(b); + }); + final winners = byPriority.take(partitions).toList()..sort(); + return winners; + } + + /// The flexible pane: highest priority among the visible. + int _flexIndex(List visible) => visible.reduce( + (a, b) => + widget.panes[b].role.priority > widget.panes[a].role.priority ? b : a, + ); + + // =========================================================================== + // DIVIDER ACTIONS + // =========================================================================== + + /// Applies a LOGICAL (start-to-end) delta to pane [index]'s model. + /// [growsTowardEnd] is true when the pane sits before its divider. + void _dragModel(int index, double logicalDelta, bool growsTowardEnd) { + final directed = growsTowardEnd ? logicalDelta : -logicalDelta; + setState(() => _models[index].drag(directed, _lastAvailableWidth)); + } + + void _snapModel(int index, double target) { + setState(() => _models[index].setWidth(target, _lastAvailableWidth)); + } + + double _maxPaneWidth() => + _lastAvailableWidth * const PaneConfig().maxListRatio; + + // =========================================================================== + // BUILD + // =========================================================================== + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final partitions = _partitions(width); + final visible = _visibleIndices(partitions); + final hidden = List.generate( + widget.panes.length, + (i) => i, + ).where((i) => !visible.contains(i)).toList(); + + final layout = visible.length == 1 + ? _pane(visible.single) + : _buildMultiPane(context, width, visible); + + if (!widget.retainHiddenPanes || hidden.isEmpty) return layout; + return Stack( + children: [ + // Hidden panes stay alive offstage with tickers paused — + // their state survives partition changes. + for (final i in hidden) + Offstage( + child: TickerMode( + enabled: false, + child: SizedBox( + width: widget.panes[i].preferredWidth, + height: constraints.maxHeight, + child: _pane(i), + ), + ), + ), + layout, + ], + ); + }, + ); + } + + Widget _pane(int index) => KeyedSubtree( + key: _paneKeys[index], + child: Builder(builder: widget.panes[index].builder), + ); + + Widget _buildMultiPane( + BuildContext context, + double availableWidth, + List visible, + ) { + _lastAvailableWidth = availableWidth; + final flexIndex = _flexIndex(visible); + + // Side-pane widths from their models, scaled down together if they + // would squeeze the flexible pane below its own minimum. + final widths = { + for (final i in visible) + if (i != flexIndex) i: _models[i].width(availableWidth), + }; + final sideTotal = widths.values.fold(0.0, (a, b) => a + b); + final flexMin = widget.panes[flexIndex].minWidth; + if (sideTotal > availableWidth - flexMin && sideTotal > 0) { + final scale = (availableWidth - flexMin) / sideTotal; + widths.updateAll((_, w) => w * scale); + } + + final children = [ + for (final i in visible) + if (i == flexIndex) + Expanded(child: _pane(i)) + else + SizedBox(width: widths[i], child: _pane(i)), + ]; + + // Divider hit zones straddle each adjacent pane boundary. + final clampedSideTotal = widths.values.fold(0.0, (a, b) => a + b); + final flexWidth = availableWidth - clampedSideTotal; + final dividers = []; + var cursor = 0.0; + for (var slot = 0; slot < visible.length - 1; slot++) { + final index = visible[slot]; + cursor += index == flexIndex ? flexWidth : widths[index]!; + dividers.add( + _divider(context, availableWidth, visible, flexIndex, slot, cursor), + ); + } + + return Stack( + children: [ + Row(children: children), + ...dividers, + ], + ); + } + + Widget _divider( + BuildContext context, + double availableWidth, + List visible, + int flexIndex, + int slot, + double boundary, + ) { + // The divider resizes the non-flexible neighbor of this boundary. + final before = visible[slot]; + final after = visible[slot + 1]; + final controlled = before == flexIndex ? after : before; + final growsTowardEnd = controlled == before; + final isRtl = Directionality.of(context) == TextDirection.rtl; + + double controlledWidth() => _models[controlled].width(availableWidth); + String share(double delta) { + final clamped = (controlledWidth() + delta).clamp( + widget.panes[controlled].minWidth, + _maxPaneWidth(), + ); + return '${(clamped / availableWidth * 100).round()}%'; + } + + void applyPhysical(double physicalDelta) { + final logical = isRtl ? -physicalDelta : physicalDelta; + _dragModel(controlled, logical, growsTowardEnd); + } + + return PositionedDirectional( + start: (boundary - widget.dividerHitWidth / 2).clamp( + 0.0, + availableWidth - widget.dividerHitWidth, + ), + top: 0, + bottom: 0, + child: PaneDividerRegion( + hitWidth: widget.dividerHitWidth, + stateFor: (focused) => DividerState( + isDragging: _draggingSlot == slot, + atMinimum: + controlledWidth() <= widget.panes[controlled].minWidth + 0.5, + atMaximum: controlledWidth() >= _maxPaneWidth() - 0.5, + isFocused: focused, + ), + dividerBuilder: widget.dividerBuilder, + onDragStart: () { + _models[controlled].dragStart(availableWidth); + setState(() => _draggingSlot = slot); + }, + onDragDelta: applyPhysical, + onDragEnd: () { + _models[controlled].dragEnd(); + setState(() => _draggingSlot = null); + }, + onStep: applyPhysical, + // No collapse system here — Enter is a no-op by contract. + onToggleCollapse: () {}, + onJumpToMinimum: () => + _snapModel(controlled, widget.panes[controlled].minWidth), + onJumpToMaximum: () => _snapModel(controlled, _maxPaneWidth()), + onReset: () => + _snapModel(controlled, widget.panes[controlled].preferredWidth), + semanticsLabel: widget.dividerSemanticsLabel, + semanticsValue: share(0), + semanticsIncreasedValue: share(PaneDividerRegion.keyboardStep), + semanticsDecreasedValue: share(-PaneDividerRegion.keyboardStep), + ), + ); + } + + int? _draggingSlot; +} diff --git a/test/core/three_pane/three_pane_layout_test.dart b/test/core/three_pane/three_pane_layout_test.dart new file mode 100644 index 0000000..9c8d966 --- /dev/null +++ b/test/core/three_pane/three_pane_layout_test.dart @@ -0,0 +1,177 @@ +import 'package:adaptive_layouts/adaptive_layouts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers.dart'; + +/// Role-priority partitioning, order decoupling, per-divider resizing, +/// and offstage state retention for ThreePaneLayout. +void main() { + Widget buildLayout({double largeBreakpoint = 1200}) { + return ThreePaneLayout( + largeBreakpoint: largeBreakpoint, + panes: [ + PaneSpec( + role: PaneRole.secondary, + preferredWidth: 300, + builder: (context) => + const ColoredBox(key: Key('list'), color: Color(0xFF111111)), + ), + PaneSpec( + role: PaneRole.primary, + builder: (context) => const ColoredBox( + key: Key('editor'), + color: Color(0xFF222222), + child: CounterPane(), + ), + ), + PaneSpec( + role: PaneRole.tertiary, + preferredWidth: 260, + builder: (context) => + const ColoredBox(key: Key('inspector'), color: Color(0xFF333333)), + ), + ], + ); + } + + const compact = Size(500, 800); + const medium = Size(1000, 800); + const large = Size(1400, 800); + + bool onstage(WidgetTester tester, Key key) { + final finder = find.byKey(key, skipOffstage: true); + return tester.any(finder); + } + + group('partitions', () { + testWidgets('compact shows only the primary pane', (tester) async { + await pumpApp(tester, buildLayout(), size: compact); + expect(onstage(tester, const Key('editor')), isTrue); + expect(onstage(tester, const Key('list')), isFalse); + expect(onstage(tester, const Key('inspector')), isFalse); + expect(tester.getSize(find.byKey(const Key('editor'))).width, 500); + }); + + testWidgets('medium shows primary + secondary; tertiary yields', ( + tester, + ) async { + await pumpApp(tester, buildLayout(), size: medium); + expect(onstage(tester, const Key('editor')), isTrue); + expect(onstage(tester, const Key('list')), isTrue); + expect(onstage(tester, const Key('inspector')), isFalse); + // Visual order preserved: list (secondary) sits at the start even + // though the primary outranks it. + expect(tester.getTopLeft(find.byKey(const Key('list'))).dx, 0); + expect(tester.getSize(find.byKey(const Key('list'))).width, 300); + expect(tester.getSize(find.byKey(const Key('editor'))).width, 700); + }); + + testWidgets('large shows all three at their preferred widths', ( + tester, + ) async { + await pumpApp(tester, buildLayout(), size: large); + expect(onstage(tester, const Key('inspector')), isTrue); + expect(tester.getSize(find.byKey(const Key('list'))).width, 300); + expect(tester.getSize(find.byKey(const Key('inspector'))).width, 260); + expect( + tester.getSize(find.byKey(const Key('editor'))).width, + 1400 - 300 - 260, + ); + }); + }); + + group('dividers', () { + testWidgets('start divider resizes the secondary pane', (tester) async { + await pumpApp(tester, buildLayout(), size: large); + + await tester.dragFrom(const Offset(300, 400), const Offset(60, 0)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const Key('list'))).width, + closeTo(360, 30), + ); + }); + + testWidgets('end divider resizes the tertiary pane (inverted)', ( + tester, + ) async { + await pumpApp(tester, buildLayout(), size: large); + + // The tertiary boundary sits at 1400 - 260. Dragging toward the + // start GROWS the tertiary. + await tester.dragFrom(const Offset(1140, 400), const Offset(-60, 0)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const Key('inspector'))).width, + closeTo(320, 30), + ); + }); + + testWidgets('dragged width survives a partition round trip', ( + tester, + ) async { + await pumpApp(tester, buildLayout(), size: large); + await tester.dragFrom(const Offset(300, 400), const Offset(80, 0)); + await tester.pumpAndSettle(); + final dragged = tester.getSize(find.byKey(const Key('list'))).width; + + await pumpApp(tester, buildLayout(), size: compact); + await tester.pumpAndSettle(); + await pumpApp(tester, buildLayout(), size: large); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('list'))).width, dragged); + }); + }); + + group('state retention', () { + testWidgets('hidden panes keep their live state offstage', (tester) async { + await pumpApp(tester, buildLayout(), size: large); + + // Mutate state inside the primary pane, then hide the others and + // bring them back — and shrink so even the primary's neighbors + // cycle through offstage. + await tester.tap(find.text('count: 0')); + await tester.pump(); + expect(find.text('count: 1'), findsOneWidget); + + await pumpApp(tester, buildLayout(), size: compact); + await tester.pumpAndSettle(); + expect(find.text('count: 1'), findsOneWidget); + + await pumpApp(tester, buildLayout(), size: large); + await tester.pumpAndSettle(); + expect(find.text('count: 1'), findsOneWidget); + }); + }); + + group('two panes', () { + testWidgets('a two-pane list works and caps at two partitions', ( + tester, + ) async { + final layout = ThreePaneLayout( + panes: [ + PaneSpec( + role: PaneRole.secondary, + preferredWidth: 300, + builder: (context) => + const ColoredBox(key: Key('side'), color: Color(0xFF111111)), + ), + PaneSpec( + role: PaneRole.primary, + builder: (context) => + const ColoredBox(key: Key('main'), color: Color(0xFF222222)), + ), + ], + ); + await pumpApp(tester, layout, size: large); + expect(tester.getSize(find.byKey(const Key('side'))).width, 300); + expect(tester.getSize(find.byKey(const Key('main'))).width, 1100); + + await pumpApp(tester, layout, size: compact); + await tester.pumpAndSettle(); + expect(onstage(tester, const Key('main')), isTrue); + expect(onstage(tester, const Key('side')), isFalse); + }); + }); +} From d078f4ef3bd0e3c67a004ffb43f8fbf4b6466029 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 08:13:23 +0530 Subject: [PATCH 32/41] =?UTF-8?q?feat(example):=20Workbench=20tab=20?= =?UTF-8?q?=E2=80=94=20ThreePaneLayout=20demo=20under=20Ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/main.dart | 79 +++++++++++++++++++++++++++++++++++++++- example/lib/main.gr.dart | 16 ++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 2e0a5bb..de8ef70 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -731,6 +731,7 @@ abstract final class Paths { // Ops domain static const ops = 'ops'; static const monitor = 'monitor'; + static const workbench = 'workbench'; static const runs = 'runs'; static const builds = 'builds'; static const buildWithParam = ':${Params.buildId}'; @@ -917,6 +918,10 @@ class AppRouter extends RootStackRouter { path: Paths.monitor, initial: true, ), + AutoRoute( + page: WorkbenchTabRoute.page, + path: Paths.workbench, + ), AutoRoute( page: RunsShellRoute.page, path: Paths.runs, @@ -2796,9 +2801,15 @@ class OpsDomainScreen extends StatelessWidget { @override Widget build(BuildContext context) { return DomainTabsRouter( - routes: const [MonitorTabRoute(), RunsShellRoute(), SetupTabRoute()], + routes: const [ + MonitorTabRoute(), + WorkbenchTabRoute(), + RunsShellRoute(), + SetupTabRoute(), + ], tabs: [ domainTab(icon: Icons.monitor_heart_outlined, label: 'Monitor'), + domainTab(icon: Icons.view_week_outlined, label: 'Workbench'), domainTab(icon: Icons.play_circle_outline, label: 'Runs'), domainTab(icon: Icons.tune_outlined, label: 'Setup'), ], @@ -2806,6 +2817,72 @@ class OpsDomainScreen extends StatelessWidget { } } +/// ThreePaneLayout demo: outline + editor + inspector, appearing and +/// yielding by role priority as the window crosses 720 and 1200. +@RoutePage() +class WorkbenchTabScreen extends StatelessWidget { + const WorkbenchTabScreen({super.key}); + + Widget _panel(BuildContext context, String title, IconData icon) { + final colorScheme = Theme.of(context).colorScheme; + return ColoredBox( + color: colorScheme.surfaceContainerLow, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(icon, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + Text(title, style: Theme.of(context).textTheme.titleSmall), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView( + padding: const EdgeInsets.all(12), + children: [ + for (var i = 1; i <= 30; i++) + ListTile(dense: true, title: Text('$title item $i')), + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return ThreePaneLayout( + dividerBuilder: PackageSettings.instance.dividerBuilder, + panes: [ + PaneSpec( + role: PaneRole.secondary, + preferredWidth: 280, + minWidth: 200, + builder: (context) => + _panel(context, 'Outline', Icons.segment_outlined), + ), + PaneSpec( + role: PaneRole.primary, + builder: (context) => + _panel(context, 'Editor', Icons.edit_note_outlined), + ), + PaneSpec( + role: PaneRole.tertiary, + preferredWidth: 280, + minWidth: 200, + builder: (context) => + _panel(context, 'Inspector', Icons.tune_outlined), + ), + ], + ); + } +} + /// Full-screen tab (no list-detail). Starting a deploy feeds the persistent /// status strip at the top of the app. @RoutePage() diff --git a/example/lib/main.gr.dart b/example/lib/main.gr.dart index 65e1a59..f6e79db 100644 --- a/example/lib/main.gr.dart +++ b/example/lib/main.gr.dart @@ -1185,6 +1185,22 @@ class WorkPrefsTabRoute extends PageRouteInfo { ); } +/// generated route for +/// [WorkbenchTabScreen] +class WorkbenchTabRoute extends PageRouteInfo { + const WorkbenchTabRoute({List? children}) + : super(WorkbenchTabRoute.name, initialChildren: children); + + static const String name = 'WorkbenchTabRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const WorkbenchTabScreen(); + }, + ); +} + /// generated route for /// [WorkspaceDetailScreen] class WorkspaceDetailRoute extends PageRouteInfo { From 4c92042584cb23dc89eb54b77df16b7d5ac04649 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 10:25:32 +0530 Subject: [PATCH 33/41] =?UTF-8?q?feat:=20collapsed=20icon-rail=20slots=20?= =?UTF-8?q?=E2=80=94=20rail=20content=20at=20real=20width,=20pane=20parked?= =?UTF-8?q?=20alive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collapsedListBuilder/collapsedDetailBuilder (and AdaptiveSplit's primary/secondary equivalents): when a pane snaps shut, the app can render purpose-built rail content laid out at the actual collapsedSize instead of the clipped-at-minimum default. The real pane parks in Offstage with tickers paused, state alive; the list pane gains its own reparenting GlobalKey so collapse/restore/mode switches move the live element instead of remounting it. Example: tickets list collapses to a VS Code-style 56px rail — expand chevron plus per-ticket icons that restore and navigate. --- CHANGELOG.md | 1 + CHANGELOG.pre.md | 1 + README.md | 17 ++++ docs/CAPABILITY_ROADMAP.md | 1 + docs/UPDATING.md | 8 +- example/lib/main.dart | 88 +++++++++++++++++-- .../core/list_detail/list_detail_layout.dart | 25 ++++++ .../list_detail_layout_builders.dart | 70 +++++++++------ lib/src/core/split/adaptive_split.dart | 66 ++++++++++---- .../list_detail/divider_interaction_test.dart | 63 +++++++++++++ 10 files changed, 293 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b741403..0bbb7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 `AdaptiveSplit`'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 `AdaptiveSplit`'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 and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. - **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/CHANGELOG.pre.md b/CHANGELOG.pre.md index 58912bf..470573b 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -72,6 +72,7 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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 `AdaptiveSplit`'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 `AdaptiveSplit`'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 and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. - **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **Controller:** `ListDetailController` with an animation-aware `isDetailVisible` for app-shell timing; router-agnostic — the example ships full URL-sync reference wiring. diff --git a/README.md b/README.md index ba083b1..46ed311 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,23 @@ focusable and screen-reader adjustable out of the box: Localize the announcement via `PaneConfig(dividerSemanticsLabel: ...)`. +With a non-zero `collapsedSize`, the collapsed pane clips its normal +content at its minimum width by default. For a real icon rail, give the +slot purpose-built content — it lays out at the actual collapsed width, +and the pane parks offstage with its state alive until restored: + +```dart +ListDetailLayout( + collapsedListBuilder: (context) => MyIconRail( + onExpand: PaneScope.of(context).restore, + ), + ... +) +``` + +(`collapsedDetailBuilder` covers the end side; `AdaptiveSplit` has +`collapsedPrimaryBuilder` / `collapsedSecondaryBuilder`.) + For the wide layout's "nothing selected" area, pass any builder — or the shipped one: ```dart diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 0bb2209..bd8530f 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -62,6 +62,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | | 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 `AdaptiveSplit` 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`/`isExpanded` + `collapse`/`restore` actions; the hamburger recipe | | ThreePaneLayout: role-priority partitions | DONE | 2-3 panes, two width thresholds; priority picks who shows, list order picks where; flexible pane = highest visible priority; shared width model + divider region per side pane | diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 06feed4..35a6a8d 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -277,7 +277,13 @@ The machinery in `list_detail_layout.dart` holds: 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. **The divider stays grabbable when parked.** The region's position is +8. **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. **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. diff --git a/example/lib/main.dart b/example/lib/main.dart index de8ef70..a9ae3be 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -833,7 +833,6 @@ class AppRouter extends RootStackRouter { page: RootShellRoute.page, path: Paths.root, children: [ - // App shell — domain navigation (bottom nav / rail). AutoRoute( page: AppShellRoute.page, @@ -918,10 +917,7 @@ class AppRouter extends RootStackRouter { path: Paths.monitor, initial: true, ), - AutoRoute( - page: WorkbenchTabRoute.page, - path: Paths.workbench, - ), + AutoRoute(page: WorkbenchTabRoute.page, path: Paths.workbench), AutoRoute( page: RunsShellRoute.page, path: Paths.runs, @@ -1117,6 +1113,8 @@ class ListDetailRouter extends StatefulWidget { final ListPaneBuilder listBuilder; final DetailPaneBuilder detailBuilder; final WidgetBuilder? emptyStateBuilder; + final WidgetBuilder? collapsedListBuilder; + final WidgetBuilder? collapsedDetailBuilder; /// When provided, auto-clears selection if the entity no longer exists. final bool Function(String id)? selectedIdExists; @@ -1134,6 +1132,8 @@ class ListDetailRouter extends StatefulWidget { required this.listBuilder, required this.detailBuilder, this.emptyStateBuilder, + this.collapsedListBuilder, + this.collapsedDetailBuilder, this.selectedIdExists, this.firstItemId, }); @@ -1273,6 +1273,8 @@ class _ListDetailRouterState extends State { listBuilder: widget.listBuilder, detailBuilder: widget.detailBuilder, emptyStateBuilder: widget.emptyStateBuilder, + collapsedListBuilder: widget.collapsedListBuilder, + collapsedDetailBuilder: widget.collapsedDetailBuilder, dividerBuilder: settings.dividerBuilder, paneConfig: settings.paneConfig, compactConfig: settings.compactConfig, @@ -2216,12 +2218,88 @@ 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, + onPressed: () { + scope.restore(); + 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. diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 725ec88..9c88a9d 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -88,6 +88,8 @@ class ListDetailLayout extends StatefulWidget { required this.listBuilder, required this.detailBuilder, this.emptyStateBuilder, + this.collapsedListBuilder, + this.collapsedDetailBuilder, this.dividerBuilder, this.expandedBreakpoint, this.paneConfig = const PaneConfig(), @@ -109,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; @@ -244,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) // 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 0186b3d..7e605c9 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -55,18 +55,13 @@ extension _LayoutBuilders on _ListDetailLayoutState { final collapsed = _paneWidth.collapsed; - Widget list = widget.listBuilder(context, selectedId, _handleSelect); + Widget list = _keyedList(selectedId); if (collapsed == PaneSide.start) { - // A collapsed pane's content stays laid out at its minimum width — - // its last legal layout — and clips as the slot shrinks. Content - // never reflows below the floor the app designed for. - list = ClipRect( - child: OverflowBox( - minWidth: widget.paneConfig.minListWidth, - maxWidth: widget.paneConfig.minListWidth, - alignment: AlignmentDirectional.centerStart, - child: list, - ), + list = _collapsedSlot( + pane: list, + paneLayoutWidth: widget.paneConfig.minListWidth, + railBuilder: widget.collapsedListBuilder, + alignment: AlignmentDirectional.centerStart, ); } else if (entry < 1.0 && widget.paneConfig.entryStyle == ExpandedEntryStyle.reveal) { @@ -112,14 +107,11 @@ extension _LayoutBuilders on _ListDetailLayoutState { ), ); if (collapsed == PaneSide.end) { - // Same clip-at-floor discipline as a collapsed start pane. - detailSlot = ClipRect( - child: OverflowBox( - minWidth: minDetailWidth, - maxWidth: minDetailWidth, - alignment: AlignmentDirectional.centerEnd, - child: detailSlot, - ), + 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 @@ -233,7 +225,7 @@ extension _LayoutBuilders on _ListDetailLayoutState { Positioned.fill( child: ExcludeSemantics( excluding: showDetail, - child: widget.listBuilder(context, selectedId, _handleSelect), + child: _keyedList(selectedId), ), ), // Detail — slides over list, stays during dismiss animation @@ -300,7 +292,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, @@ -341,22 +333,50 @@ extension _LayoutBuilders on _ListDetailLayoutState { // LIST — the bridge only keeps the element alive, invisibly. return Stack( children: [ - Positioned.fill( - child: widget.listBuilder(context, selectedId, _handleSelect), - ), + Positioned.fill(child: _keyedList(selectedId)), Offstage(child: keyed), ], ); } return keyed; } - return widget.listBuilder(context, selectedId, _handleSelect); + 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, + }) { + final held = OverflowBox( + minWidth: paneLayoutWidth, + maxWidth: paneLayoutWidth, + alignment: alignment, + child: pane, + ); + if (railBuilder == null) return ClipRect(child: held); + // 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: held)), + 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) { diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index 0e39de0..46bcd8d 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -83,6 +83,8 @@ class AdaptiveSplit extends StatefulWidget { this.paneConfig = const PaneConfig(), this.dividerBuilder, this.compactSpacing = 0, + this.collapsedPrimaryBuilder, + this.collapsedSecondaryBuilder, }); /// Builder for the primary content pane. @@ -115,6 +117,16 @@ class AdaptiveSplit extends StatefulWidget { /// 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() => _AdaptiveSplitState(); } @@ -422,6 +434,34 @@ class _AdaptiveSplitState extends State ); } + /// 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, + }) { + final held = OverflowBox( + minWidth: paneLayoutWidth, + maxWidth: paneLayoutWidth, + alignment: alignment, + child: pane, + ); + if (railBuilder == null) return ClipRect(child: held); + // 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: held)), + 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. @@ -468,13 +508,11 @@ class _AdaptiveSplitState extends State child: widget.primaryBuilder(context, true), ); if (collapsed == PaneSide.start) { - primaryContent = ClipRect( - child: OverflowBox( - minWidth: widget.paneConfig.minListWidth, - maxWidth: widget.paneConfig.minListWidth, - alignment: AlignmentDirectional.centerStart, - child: primaryContent, - ), + primaryContent = _collapsedSlot( + pane: primaryContent, + paneLayoutWidth: widget.paneConfig.minListWidth, + railBuilder: widget.collapsedPrimaryBuilder, + alignment: AlignmentDirectional.centerStart, ); } final primaryPane = SizedBox(width: primaryWidth, child: primaryContent); @@ -484,15 +522,11 @@ class _AdaptiveSplitState extends State child: widget.secondaryBuilder(context, true), ); if (collapsed == PaneSide.end) { - final minSecondary = - availableWidth * (1 - widget.paneConfig.maxListRatio); - secondaryContent = ClipRect( - child: OverflowBox( - minWidth: minSecondary, - maxWidth: minSecondary, - alignment: AlignmentDirectional.centerEnd, - child: secondaryContent, - ), + secondaryContent = _collapsedSlot( + pane: secondaryContent, + paneLayoutWidth: availableWidth * (1 - widget.paneConfig.maxListRatio), + railBuilder: widget.collapsedSecondaryBuilder, + alignment: AlignmentDirectional.centerEnd, ); } final secondaryPane = Expanded(child: secondaryContent); diff --git a/test/core/list_detail/divider_interaction_test.dart b/test/core/list_detail/divider_interaction_test.dart index 78aee74..e17fa79 100644 --- a/test/core/list_detail/divider_interaction_test.dart +++ b/test/core/list_detail/divider_interaction_test.dart @@ -254,6 +254,69 @@ void main() { }); }); + 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) => 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); + }); + }); + group('semantics', () { testWidgets('divider is an adjustable element with a share value', ( tester, From 806b8b09dbd4bdb4730867514181e97611f51332 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 10:33:20 +0530 Subject: [PATCH 34/41] fix: rail taps navigate without expanding; gate detail affordance; empty pane collapses into rail slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rail item taps select without restoring (the Discord model — only the chevron expands). PaneScope exposes collapsedSize so the surviving pane shows its own restore affordance only when the neighbor is FULLY hidden (a visible rail already carries the expand control); the example swaps the hamburger for a view-sidebar icon under that gate. The empty placeholder branch now collapses through the same rail/clip slot as content instead of squishing raw into the parked width. --- CHANGELOG.md | 2 +- CHANGELOG.pre.md | 2 +- README.md | 11 ++-- example/lib/main.dart | 17 +++--- .../core/list_detail/list_detail_layout.dart | 1 + .../list_detail_layout_builders.dart | 11 +++- lib/src/core/shared/pane_scope.dart | 18 +++++-- lib/src/core/split/adaptive_split.dart | 1 + .../list_detail/divider_interaction_test.dart | 54 +++++++++++++++---- 9 files changed, 92 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bbb7a9..7920c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,7 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 `AdaptiveSplit`'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 `AdaptiveSplit`'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 and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. +- **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. - **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **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 470573b..ddd6916 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -73,7 +73,7 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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 `AdaptiveSplit`'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 `AdaptiveSplit`'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 and call `collapse`/`restore` — the hamburger-in-the-surviving-pane recipe, without threading callbacks. +- **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. - **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **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 46ed311..d68e3b2 100644 --- a/README.md +++ b/README.md @@ -229,10 +229,15 @@ grabbable (the shipped `HandleDivider` turns into a pull tab), and the surviving pane can offer its own affordance by reading `PaneScope`: ```dart -// Inside a detail pane — the hamburger recipe: +// Inside a detail pane — the show-sidebar recipe. Gate on +// collapsedSize == 0: a visible icon rail already carries the expand +// control, so only a FULLY hidden pane needs this affordance. final scope = PaneScope.maybeOf(context); -if (scope?.collapsed == PaneSide.start) - IconButton(icon: const Icon(Icons.menu), onPressed: scope!.restore) +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 diff --git a/example/lib/main.dart b/example/lib/main.dart index a9ae3be..50bcc2e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2286,10 +2286,9 @@ class CollapsedIconRail extends StatelessWidget { icon: Icon(item.icon, size: 20), tooltip: item.tooltip, color: colorScheme.onSurfaceVariant, - onPressed: () { - scope.restore(); - item.onTap(); - }, + // Navigate without expanding — a rail is a mini + // list (the Discord model). Only the chevron expands. + onPressed: item.onTap, ), ], ), @@ -2360,11 +2359,15 @@ class _TicketPaneState extends State { icon: const Icon(Icons.arrow_back), onPressed: widget.onDismiss, ) - : (paneScope?.collapsed == PaneSide.start + // 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.menu), + icon: const Icon(Icons.view_sidebar_outlined), tooltip: 'Show list', - onPressed: paneScope!.restore, + onPressed: paneScope.restore, ) : null), automaticallyImplyLeading: false, diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 9c88a9d..7733581 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -996,6 +996,7 @@ class _ListDetailLayoutState extends State> PaneScopeData _paneScopeData() => PaneScopeData( collapsed: _isExpanded ? _paneWidth.collapsed : null, isExpanded: _isExpanded, + collapsedSize: widget.paneConfig.collapsedSize, collapse: _collapsePane, restore: _restorePane, ); 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 7e605c9..68d367c 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -133,7 +133,16 @@ extension _LayoutBuilders on _ListDetailLayoutState { } else { detailSlot = widget.emptyStateBuilder?.call(context) ?? const SizedBox.shrink(); - if (pane < 1.0) { + 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. diff --git a/lib/src/core/shared/pane_scope.dart b/lib/src/core/shared/pane_scope.dart index 25e0e45..dd5cd25 100644 --- a/lib/src/core/shared/pane_scope.dart +++ b/lib/src/core/shared/pane_scope.dart @@ -11,8 +11,11 @@ import 'package:adaptive_layouts/src/core/shared/pane_collapse.dart'; /// /// ```dart /// final scope = PaneScope.maybeOf(context); -/// if (scope?.collapsed == PaneSide.start) -/// IconButton(icon: const Icon(Icons.menu), onPressed: scope!.restore) +/// if (scope?.collapsed == PaneSide.start && scope!.collapsedSize == 0) +/// IconButton( +/// icon: const Icon(Icons.view_sidebar_outlined), +/// onPressed: scope.restore, +/// ) /// ``` @immutable class PaneScopeData { @@ -20,6 +23,7 @@ class PaneScopeData { const PaneScopeData({ required this.collapsed, required this.isExpanded, + required this.collapsedSize, required this.collapse, required this.restore, }); @@ -31,6 +35,13 @@ class PaneScopeData { /// 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; @@ -62,5 +73,6 @@ class PaneScope extends InheritedWidget { @override bool updateShouldNotify(PaneScope oldWidget) => data.collapsed != oldWidget.data.collapsed || - data.isExpanded != oldWidget.data.isExpanded; + data.isExpanded != oldWidget.data.isExpanded || + data.collapsedSize != oldWidget.data.collapsedSize; } diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index 46bcd8d..ba802db 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -390,6 +390,7 @@ class _AdaptiveSplitState extends State PaneScopeData _paneScopeData() => PaneScopeData( collapsed: _isExpanded ? _visualCollapsed : null, isExpanded: _isExpanded, + collapsedSize: widget.paneConfig.collapsedSize, collapse: _collapsePane, restore: _restorePane, ); diff --git a/test/core/list_detail/divider_interaction_test.dart b/test/core/list_detail/divider_interaction_test.dart index e17fa79..ea01887 100644 --- a/test/core/list_detail/divider_interaction_test.dart +++ b/test/core/list_detail/divider_interaction_test.dart @@ -266,15 +266,20 @@ void main() { const CounterPane(label: 'list'), detailBuilder: (context, id, mode, onDismiss) => const ColoredBox(key: Key('detail'), color: Color(0xFF333333)), - collapsedListBuilder: (context) => 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, - ), - ), + 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, + ), + ); + }, ); } @@ -315,6 +320,37 @@ void main() { 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); + }); }); group('semantics', () { From ebb979ae5d306d0dc425cad10d490f9378441759 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 10:38:52 +0530 Subject: [PATCH 35/41] =?UTF-8?q?fix:=20min-wins=20guard=20on=20share-perc?= =?UTF-8?q?ent=20bounds=20=E2=80=94=20clamp=20throws=20on=20inverted=20lim?= =?UTF-8?q?its?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded geometry renders at compact width during the empty-pane retreat; there maxListRatio*width can drop below minListWidth and the screen-reader share strings crashed on double.clamp's inverted-bounds ArgumentError. All three layouts now guard like PaneWidthModel does (ceiling at or below floor pins to the floor). Regression test covers the exact repro: both+56 rail, collapse empty detail, resize compact. --- docs/UPDATING.md | 8 ++++- .../list_detail_layout_builders.dart | 13 ++++--- lib/src/core/split/adaptive_split.dart | 11 +++--- .../core/three_pane/three_pane_layout.dart | 10 +++--- .../list_detail/divider_interaction_test.dart | 36 +++++++++++++++++++ 5 files changed, 65 insertions(+), 13 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 35a6a8d..ec029ae 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -283,7 +283,13 @@ The machinery in `list_detail_layout.dart` holds: 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. **The divider stays grabbable when parked.** The region's position is +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. 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 68d367c..93b0770 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -389,10 +389,15 @@ extension _LayoutBuilders on _ListDetailLayoutState { /// 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) { - final clamped = (width + delta).clamp( - widget.paneConfig.minListWidth, - availableWidth * widget.paneConfig.maxListRatio, - ); + // 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()}%'; } diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index ba802db..364579c 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -467,10 +467,13 @@ class _AdaptiveSplitState extends State /// [delta], clamped to the pane limits, for the screen-reader value /// contract. String _paneSharePercent(double width, double delta, double availableWidth) { - final clamped = (width + delta).clamp( - widget.paneConfig.minListWidth, - availableWidth * widget.paneConfig.maxListRatio, - ); + // 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()}%'; } diff --git a/lib/src/core/three_pane/three_pane_layout.dart b/lib/src/core/three_pane/three_pane_layout.dart index 3be75ba..eafb9ab 100644 --- a/lib/src/core/three_pane/three_pane_layout.dart +++ b/lib/src/core/three_pane/three_pane_layout.dart @@ -305,10 +305,12 @@ class _ThreePaneLayoutState extends State { double controlledWidth() => _models[controlled].width(availableWidth); String share(double delta) { - final clamped = (controlledWidth() + delta).clamp( - widget.panes[controlled].minWidth, - _maxPaneWidth(), - ); + // Min-wins guard against inverted bounds on narrow windows. + final floor = widget.panes[controlled].minWidth; + final ceiling = _maxPaneWidth(); + final clamped = ceiling <= floor + ? floor + : (controlledWidth() + delta).clamp(floor, ceiling); return '${(clamped / availableWidth * 100).round()}%'; } diff --git a/test/core/list_detail/divider_interaction_test.dart b/test/core/list_detail/divider_interaction_test.dart index ea01887..bd29690 100644 --- a/test/core/list_detail/divider_interaction_test.dart +++ b/test/core/list_detail/divider_interaction_test.dart @@ -351,6 +351,42 @@ void main() { 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); + }); }); group('semantics', () { From c86c4312032a00b481aef4bc55942f8e9170744a Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 11:10:47 +0530 Subject: [PATCH 36/41] =?UTF-8?q?feat:=20parked=20panes=20arrive=20docked?= =?UTF-8?q?=20=E2=80=94=20crossings=20skip=20the=20collapse=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsed pane no longer animates through the crossing: the rail docks at its parked width from the first expanded frame (fixed end slot + background spacer while the list slides in; divider anchored to the docked boundary), a parked LIST skips the entry slide entirely, and the empty-pane reveal/retreat is skipped when parked. The collapse animation belongs to the moment the user collapsed; a window resize isn't that moment — and route mode's popping route already animates the full-screen detail away. Rail entrance flourishes are the app's own business inside its rail builder. Also keeps the reflow-to-floor clip (live reflow down to the floor width, rigid below it) for parked panes without a rail. --- docs/UPDATING.md | 11 ++- .../core/list_detail/list_detail_layout.dart | 14 ++- .../list_detail_layout_builders.dart | 93 ++++++++++++++----- lib/src/core/split/adaptive_split.dart | 35 +++++-- pubspec.lock | 40 ++++---- .../list_detail/divider_interaction_test.dart | 45 +++++++++ 6 files changed, 190 insertions(+), 48 deletions(-) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index ec029ae..259f262 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -277,7 +277,16 @@ The machinery in `list_detail_layout.dart` holds: 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. **Rail slots replace the clip only when the app opts in.** A +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` + diff --git a/lib/src/core/list_detail/list_detail_layout.dart b/lib/src/core/list_detail/list_detail_layout.dart index 7733581..fe3c245 100644 --- a/lib/src/core/list_detail/list_detail_layout.dart +++ b/lib/src/core/list_detail/list_detail_layout.dart @@ -1075,7 +1075,14 @@ class _ListDetailLayoutState extends State> // 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) { @@ -1109,10 +1116,13 @@ class _ListDetailLayoutState extends State> referenceWidth: _referenceWidth, ); } - if (_controller.hasSelection) { + 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. + // 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()); } 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 93b0770..698450f 100644 --- a/lib/src/core/list_detail/list_detail_layout_builders.dart +++ b/lib/src/core/list_detail/list_detail_layout_builders.dart @@ -31,7 +31,13 @@ extension _LayoutBuilders on _ListDetailLayoutState { 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. - final entry = _expandEntryController.isAnimating + // 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 = @@ -40,7 +46,8 @@ extension _LayoutBuilders on _ListDetailLayoutState { // 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 = listOnly || _detailPaneController.isAnimating + final pane = + collapsed == null && (listOnly || _detailPaneController.isAnimating) ? widget.compactConfig.curve.transform(_detailPaneController.value) : 1.0; final finalListWidth = _paneWidth.width(availableWidth); @@ -53,8 +60,6 @@ extension _LayoutBuilders on _ListDetailLayoutState { final detailId = listOnly ? _visibleDetailId : selectedId; final dividerBuilder = widget.dividerBuilder; - final collapsed = _paneWidth.collapsed; - Widget list = _keyedList(selectedId); if (collapsed == PaneSide.start) { list = _collapsedSlot( @@ -159,12 +164,27 @@ extension _LayoutBuilders on _ListDetailLayoutState { return Stack( children: [ - Row( - children: [ - SizedBox(width: listWidth, child: list), - Expanded(child: detailSlot), - ], - ), + 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. @@ -172,11 +192,17 @@ extension _LayoutBuilders on _ListDetailLayoutState { // fully on-screen so it stays grabbable. if (!listOnly || detailId != null) PositionedDirectional( - // Hit area centered on the pane border. - start: (listWidth - widget.paneConfig.dividerHitWidth / 2).clamp( - 0.0, - availableWidth - widget.paneConfig.dividerHitWidth, - ), + // 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( @@ -368,19 +394,42 @@ extension _LayoutBuilders on _ListDetailLayoutState { required WidgetBuilder? railBuilder, required AlignmentDirectional alignment, }) { - final held = OverflowBox( - minWidth: paneLayoutWidth, - maxWidth: paneLayoutWidth, - alignment: alignment, - child: pane, + // 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 ClipRect(child: held); + 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: held)), + Offstage( + child: TickerMode( + enabled: false, + child: OverflowBox( + minWidth: paneLayoutWidth, + maxWidth: paneLayoutWidth, + alignment: alignment, + child: pane, + ), + ), + ), Builder(builder: railBuilder), ], ); diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/adaptive_split.dart index 364579c..e2df87f 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/adaptive_split.dart @@ -445,19 +445,40 @@ class _AdaptiveSplitState extends State required WidgetBuilder? railBuilder, required AlignmentDirectional alignment, }) { - final held = OverflowBox( - minWidth: paneLayoutWidth, - maxWidth: paneLayoutWidth, - alignment: alignment, - child: pane, + // 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 ClipRect(child: held); + 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: held)), + Offstage( + child: TickerMode( + enabled: false, + child: OverflowBox( + minWidth: paneLayoutWidth, + maxWidth: paneLayoutWidth, + alignment: alignment, + child: pane, + ), + ), + ), Builder(builder: railBuilder), ], ); 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/test/core/list_detail/divider_interaction_test.dart b/test/core/list_detail/divider_interaction_test.dart index bd29690..290245e 100644 --- a/test/core/list_detail/divider_interaction_test.dart +++ b/test/core/list_detail/divider_interaction_test.dart @@ -387,6 +387,51 @@ void main() { 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', () { From 7d301a9efc569af7cdd605513acad14565d3a882 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 12:10:27 +0530 Subject: [PATCH 37/41] =?UTF-8?q?feat!:=20remove=20ThreePaneLayout=20?= =?UTF-8?q?=E2=80=94=20the=20package=20grows=20by=20extraction,=20not=20pr?= =?UTF-8?q?ediction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreePaneLayout was built on a guessed customer with zero real consumers, and a generic pane scaffold trying to satisfy every imaginable pane expectation becomes config soup that serves none. The package returns to its three proven, universal widgets (ListDetailLayout, AdaptiveSplit, showAdaptiveModal). Multi-pane shapes get built app-side first, sharp and specific; a widget graduates into the package only when a second consumer proves the generic shape. The collapse-vs-summon design insight (wide-view divider mechanics vs narrow-view floating presentation) is banked for when that day comes. --- CHANGELOG.md | 1 - CHANGELOG.pre.md | 1 - README.md | 31 -- docs/CAPABILITY_ROADMAP.md | 6 - example/lib/main.dart | 76 +--- example/lib/main.gr.dart | 16 - lib/adaptive_layouts.dart | 3 - lib/src/core/three_pane/pane_role.dart | 23 -- lib/src/core/three_pane/pane_spec.dart | 37 -- .../core/three_pane/three_pane_layout.dart | 365 ------------------ .../three_pane/three_pane_layout_test.dart | 177 --------- 11 files changed, 1 insertion(+), 735 deletions(-) delete mode 100644 lib/src/core/three_pane/pane_role.dart delete mode 100644 lib/src/core/three_pane/pane_spec.dart delete mode 100644 lib/src/core/three_pane/three_pane_layout.dart delete mode 100644 test/core/three_pane/three_pane_layout_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7920c81..ba96727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,5 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 `AdaptiveSplit`'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. -- **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **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 ddd6916..04944b0 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -74,6 +74,5 @@ First prerelease — adaptive layout widgets that morph between phone and deskto - **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 `AdaptiveSplit`'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. -- **Three panes:** `ThreePaneLayout` — up to three panes appearing and yielding by role priority across two width thresholds (the Material pane-scaffold model). Priority decides who shows, list order decides where; the top-priority visible pane flexes while the others hold draggable widths with the full divider contract. Hidden panes stay alive offstage (tickers paused) so their state survives partition changes. - **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 d68e3b2..d76222b 100644 --- a/README.md +++ b/README.md @@ -298,37 +298,6 @@ AdaptiveSplit( 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. -### Three panes by role priority - -`ThreePaneLayout` is the pane-scaffold shape from Material's adaptive -layouts: up to three panes that appear and yield as the window grows. -Two thresholds carve the width into partitions (one pane below the -expanded breakpoint, two below `largeBreakpoint`, three above). Roles -decide WHO wins a slot — `primary` survives to the narrowest window — -and the pane list's order decides WHERE, decoupled from priority: - -```dart -ThreePaneLayout( - panes: [ - PaneSpec(role: PaneRole.secondary, preferredWidth: 300, - builder: (_) => Outline()), - PaneSpec(role: PaneRole.primary, builder: (_) => Editor()), - PaneSpec(role: PaneRole.tertiary, preferredWidth: 280, - builder: (_) => Inspector()), - ], -) -``` - -The highest-priority visible pane flexes; the others hold draggable -widths (full divider contract — drag, keyboard, double-click reset, -screen readers). Hidden panes stay alive offstage with tickers paused, -so an inspector's scroll position survives the window shrinking and -growing back (`retainHiddenPanes: false` opts out). - -Use `ListDetailLayout` when compact needs list/detail *navigation*; -use `ThreePaneLayout` when panes are supporting surfaces that simply -yield to width. - ### A modal that swaps between dialog and bottom sheet `showAdaptiveModal` presents a real Material dialog on wide windows and a real Material bottom sheet on narrow ones — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply. 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, and a half-typed form field survives the trip. `ModalConfig(morph: false)` swaps instantly instead. diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index bd8530f..129fdbf 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -65,12 +65,6 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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`/`isExpanded` + `collapse`/`restore` actions; the hamburger recipe | -| ThreePaneLayout: role-priority partitions | DONE | 2-3 panes, two width thresholds; priority picks who shows, list order picks where; flexible pane = highest visible priority; shared width model + divider region per side pane | -| ThreePaneLayout: offstage state retention | DONE | Hidden panes live in Offstage + TickerMode(false); dragged widths and pane state survive partition round trips | -| ThreePaneLayout: levitate adapt strategy (overlay + scrim) | PLANNED | Compose's Levitate — tertiary floats over content on medium widths | -| ThreePaneLayout: partition-change motion | PLANNED | Animate panes in/out on threshold crossings, reusing the crossing-motion machinery | -| ThreePaneLayout: per-pane collapse | PLANNED | Reuse the model's snap-collapse once the multi-divider interplay is designed | -| ListDetailLayout re-derived on the pane-scaffold core | PLANNED | Parallel-copy migration; ListDetailLayout keeps its full compact-mode machinery until the core can express it | | Anchor snap points with settle animation | DONE | Nearest anchor on drag end; `isSettling` fed to divider builders | | Initial width from anchor index | DONE | `PaneConfig.initialAnchorIndex`, anchors non-empty | diff --git a/example/lib/main.dart b/example/lib/main.dart index 50bcc2e..0b88760 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -731,7 +731,6 @@ abstract final class Paths { // Ops domain static const ops = 'ops'; static const monitor = 'monitor'; - static const workbench = 'workbench'; static const runs = 'runs'; static const builds = 'builds'; static const buildWithParam = ':${Params.buildId}'; @@ -917,7 +916,6 @@ class AppRouter extends RootStackRouter { path: Paths.monitor, initial: true, ), - AutoRoute(page: WorkbenchTabRoute.page, path: Paths.workbench), AutoRoute( page: RunsShellRoute.page, path: Paths.runs, @@ -2882,15 +2880,9 @@ class OpsDomainScreen extends StatelessWidget { @override Widget build(BuildContext context) { return DomainTabsRouter( - routes: const [ - MonitorTabRoute(), - WorkbenchTabRoute(), - RunsShellRoute(), - SetupTabRoute(), - ], + routes: const [MonitorTabRoute(), RunsShellRoute(), SetupTabRoute()], tabs: [ domainTab(icon: Icons.monitor_heart_outlined, label: 'Monitor'), - domainTab(icon: Icons.view_week_outlined, label: 'Workbench'), domainTab(icon: Icons.play_circle_outline, label: 'Runs'), domainTab(icon: Icons.tune_outlined, label: 'Setup'), ], @@ -2898,72 +2890,6 @@ class OpsDomainScreen extends StatelessWidget { } } -/// ThreePaneLayout demo: outline + editor + inspector, appearing and -/// yielding by role priority as the window crosses 720 and 1200. -@RoutePage() -class WorkbenchTabScreen extends StatelessWidget { - const WorkbenchTabScreen({super.key}); - - Widget _panel(BuildContext context, String title, IconData icon) { - final colorScheme = Theme.of(context).colorScheme; - return ColoredBox( - color: colorScheme.surfaceContainerLow, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(icon, size: 18, color: colorScheme.primary), - const SizedBox(width: 8), - Text(title, style: Theme.of(context).textTheme.titleSmall), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: ListView( - padding: const EdgeInsets.all(12), - children: [ - for (var i = 1; i <= 30; i++) - ListTile(dense: true, title: Text('$title item $i')), - ], - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return ThreePaneLayout( - dividerBuilder: PackageSettings.instance.dividerBuilder, - panes: [ - PaneSpec( - role: PaneRole.secondary, - preferredWidth: 280, - minWidth: 200, - builder: (context) => - _panel(context, 'Outline', Icons.segment_outlined), - ), - PaneSpec( - role: PaneRole.primary, - builder: (context) => - _panel(context, 'Editor', Icons.edit_note_outlined), - ), - PaneSpec( - role: PaneRole.tertiary, - preferredWidth: 280, - minWidth: 200, - builder: (context) => - _panel(context, 'Inspector', Icons.tune_outlined), - ), - ], - ); - } -} - /// Full-screen tab (no list-detail). Starting a deploy feeds the persistent /// status strip at the top of the app. @RoutePage() diff --git a/example/lib/main.gr.dart b/example/lib/main.gr.dart index f6e79db..65e1a59 100644 --- a/example/lib/main.gr.dart +++ b/example/lib/main.gr.dart @@ -1185,22 +1185,6 @@ class WorkPrefsTabRoute extends PageRouteInfo { ); } -/// generated route for -/// [WorkbenchTabScreen] -class WorkbenchTabRoute extends PageRouteInfo { - const WorkbenchTabRoute({List? children}) - : super(WorkbenchTabRoute.name, initialChildren: children); - - static const String name = 'WorkbenchTabRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const WorkbenchTabScreen(); - }, - ); -} - /// generated route for /// [WorkspaceDetailScreen] class WorkspaceDetailRoute extends PageRouteInfo { diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index 04db3bf..e42fa5a 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -41,9 +41,6 @@ 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'; -export 'src/core/three_pane/pane_role.dart'; -export 'src/core/three_pane/pane_spec.dart'; -export 'src/core/three_pane/three_pane_layout.dart'; // Components — convenience library (replaceable) export 'src/components/dividers/handle_divider.dart'; diff --git a/lib/src/core/three_pane/pane_role.dart b/lib/src/core/three_pane/pane_role.dart deleted file mode 100644 index f034c51..0000000 --- a/lib/src/core/three_pane/pane_role.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// A pane's role in a multi-pane layout, carrying its display priority. -/// -/// When the window has fewer partitions than panes, the highest-priority -/// panes win the slots (the Material adaptive scaffold model: primary -/// content survives longest, tertiary/extra panes yield first). Roles -/// decide WHO shows; the pane list's order decides WHERE. -enum PaneRole { - /// The main content. Last pane standing on the narrowest windows. - primary(10), - - /// Supporting content (a list, an outline, a sidebar). Appears from - /// two partitions up. - secondary(5), - - /// Extra content (an inspector, metadata, a preview). Appears only - /// when a third partition is available. - tertiary(1); - - const PaneRole(this.priority); - - /// Higher wins a partition slot when slots are scarce. - final int priority; -} diff --git a/lib/src/core/three_pane/pane_spec.dart b/lib/src/core/three_pane/pane_spec.dart deleted file mode 100644 index 50d840a..0000000 --- a/lib/src/core/three_pane/pane_spec.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import 'package:adaptive_layouts/src/core/three_pane/pane_role.dart'; - -/// One pane in a `ThreePaneLayout`: its role, content, and sizing. -/// -/// Deliberately no `==` override: [builder] is a closure, and closures -/// constructed inline in `build` never compare equal — a value equality -/// that included them would be always-false, and one that skipped them -/// would call different panes "equal". The layout compares the sizing -/// fields directly instead, so inline-constructed specs never reset a -/// dragged divider. -@immutable -class PaneSpec { - /// Creates a pane spec. - const PaneSpec({ - required this.role, - required this.builder, - this.preferredWidth = 360, - this.minWidth = 200, - }); - - /// Decides which panes survive when partitions are scarce. - final PaneRole role; - - /// The pane's content. Built once; the live widget instance survives - /// partition changes via key reparenting. - final WidgetBuilder builder; - - /// Starting width when this pane is NOT the flexible one. The - /// highest-priority visible pane flexes to fill remaining space; - /// the others hold a draggable width starting here. - final double preferredWidth; - - /// Drag floor for this pane's divider. - final double minWidth; -} diff --git a/lib/src/core/three_pane/three_pane_layout.dart b/lib/src/core/three_pane/three_pane_layout.dart deleted file mode 100644 index eafb9ab..0000000 --- a/lib/src/core/three_pane/three_pane_layout.dart +++ /dev/null @@ -1,365 +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_divider_region.dart'; -import 'package:adaptive_layouts/src/core/shared/pane_resize_mode.dart'; -import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; -import 'package:adaptive_layouts/src/core/three_pane/pane_role.dart'; -import 'package:adaptive_layouts/src/core/three_pane/pane_spec.dart'; - -/// Up to three panes that appear and yield by role priority as the -/// window grows and shrinks — the Material adaptive pane-scaffold model. -/// -/// Two thresholds carve the width into partitions: below -/// [expandedBreakpoint] one pane shows, below [largeBreakpoint] two, -/// above it three. When partitions are scarce, the highest-priority -/// roles win the slots ([PaneRole.primary] survives to the narrowest -/// window); the [panes] list's order decides left-to-right placement, -/// decoupled from priority. -/// -/// The highest-priority visible pane flexes to fill remaining space; -/// the others hold a draggable width starting at their -/// [PaneSpec.preferredWidth]. Every divider carries the full interaction -/// contract (drag, keyboard, double-click reset, screen-reader -/// adjustment). Home/End and double-click snap instantly here — this -/// layout has no anchor system to settle against. -/// -/// Hidden panes stay alive offstage (tickers paused), so a pane's -/// scroll position, form drafts, and in-flight state survive partition -/// changes in both directions. -/// -/// ```dart -/// ThreePaneLayout( -/// panes: [ -/// PaneSpec(role: PaneRole.secondary, builder: (_) => Outline()), -/// PaneSpec(role: PaneRole.primary, builder: (_) => Editor()), -/// PaneSpec(role: PaneRole.tertiary, builder: (_) => Inspector()), -/// ], -/// ) -/// ``` -class ThreePaneLayout extends StatefulWidget { - /// Creates the layout. [panes] holds 2 or 3 specs in visual order. - const ThreePaneLayout({ - super.key, - required this.panes, - this.expandedBreakpoint, - this.largeBreakpoint = 1200, - this.dividerBuilder, - this.dividerHitWidth = 24, - this.dividerSemanticsLabel = 'Pane divider', - this.retainHiddenPanes = true, - }) : assert( - panes.length == 2 || panes.length == 3, - 'ThreePaneLayout takes 2 or 3 panes', - ); - - /// The panes, in the left-to-right (directional) order they occupy. - final List panes; - - /// Width from which two partitions are available. Null resolves the - /// app-wide default via [AdaptiveLayoutConfig]. - final double? expandedBreakpoint; - - /// Width from which three partitions are available. - final double largeBreakpoint; - - /// Visual for the dividers; null keeps invisible drag zones. - final DividerBuilder? dividerBuilder; - - /// Width of each divider's hit zone. - final double dividerHitWidth; - - /// Screen-reader label for the dividers. Localize by passing your own. - final String dividerSemanticsLabel; - - /// Keeps hidden panes alive offstage so their state survives - /// partition changes. Turn off to unmount them instead. - final bool retainHiddenPanes; - - @override - State createState() => _ThreePaneLayoutState(); -} - -class _ThreePaneLayoutState extends State { - late List _paneKeys; - - /// Width model per pane index. Only consulted while that pane is - /// visible and not the flexible one, but kept alive throughout so a - /// dragged width survives visibility changes. - late List _models; - - double _lastAvailableWidth = 0; - - @override - void initState() { - super.initState(); - _paneKeys = List.generate(widget.panes.length, (_) => GlobalKey()); - _models = List.generate(widget.panes.length, _buildModel); - } - - @override - void didUpdateWidget(ThreePaneLayout oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.panes.length != oldWidget.panes.length) { - _paneKeys = List.generate(widget.panes.length, (_) => GlobalKey()); - _models = List.generate(widget.panes.length, _buildModel); - return; - } - for (var i = 0; i < widget.panes.length; i++) { - // Sizing fields only — builders are closures that never compare - // equal when constructed inline, and a builder change doesn't - // invalidate a dragged width. - final spec = widget.panes[i]; - final old = oldWidget.panes[i]; - if (spec.preferredWidth != old.preferredWidth || - spec.minWidth != old.minWidth) { - _models[i] = _buildModel(i); - } - } - } - - PaneWidthModel _buildModel(int index) { - final spec = widget.panes[index]; - // Each side pane reuses the shared width model in pixel mode; the - // reference width is unused outside ratio mode. - return PaneWidthModel( - PaneConfig( - defaultListWidth: spec.preferredWidth, - minListWidth: spec.minWidth, - resizeMode: PaneResizeMode.pixels, - ), - referenceWidth: _referenceWidth, - ); - } - - double get _referenceWidth => - widget.expandedBreakpoint ?? - AdaptiveLayoutConfig.defaultExpandedBreakpoint; - - // =========================================================================== - // PARTITIONS AND VISIBILITY - // =========================================================================== - - int _partitions(double width) { - final expanded = AdaptiveLayoutConfig.resolveBreakpoint( - context, - widget.expandedBreakpoint, - ); - if (width < expanded) return 1; - if (width < widget.largeBreakpoint) return 2; - return 3; - } - - /// Pane indices that win a slot, in visual order. Priority decides - /// who; position decides where. Ties keep list order. - List _visibleIndices(int partitions) { - final byPriority = List.generate(widget.panes.length, (i) => i) - ..sort((a, b) { - final cmp = widget.panes[b].role.priority.compareTo( - widget.panes[a].role.priority, - ); - return cmp != 0 ? cmp : a.compareTo(b); - }); - final winners = byPriority.take(partitions).toList()..sort(); - return winners; - } - - /// The flexible pane: highest priority among the visible. - int _flexIndex(List visible) => visible.reduce( - (a, b) => - widget.panes[b].role.priority > widget.panes[a].role.priority ? b : a, - ); - - // =========================================================================== - // DIVIDER ACTIONS - // =========================================================================== - - /// Applies a LOGICAL (start-to-end) delta to pane [index]'s model. - /// [growsTowardEnd] is true when the pane sits before its divider. - void _dragModel(int index, double logicalDelta, bool growsTowardEnd) { - final directed = growsTowardEnd ? logicalDelta : -logicalDelta; - setState(() => _models[index].drag(directed, _lastAvailableWidth)); - } - - void _snapModel(int index, double target) { - setState(() => _models[index].setWidth(target, _lastAvailableWidth)); - } - - double _maxPaneWidth() => - _lastAvailableWidth * const PaneConfig().maxListRatio; - - // =========================================================================== - // BUILD - // =========================================================================== - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final partitions = _partitions(width); - final visible = _visibleIndices(partitions); - final hidden = List.generate( - widget.panes.length, - (i) => i, - ).where((i) => !visible.contains(i)).toList(); - - final layout = visible.length == 1 - ? _pane(visible.single) - : _buildMultiPane(context, width, visible); - - if (!widget.retainHiddenPanes || hidden.isEmpty) return layout; - return Stack( - children: [ - // Hidden panes stay alive offstage with tickers paused — - // their state survives partition changes. - for (final i in hidden) - Offstage( - child: TickerMode( - enabled: false, - child: SizedBox( - width: widget.panes[i].preferredWidth, - height: constraints.maxHeight, - child: _pane(i), - ), - ), - ), - layout, - ], - ); - }, - ); - } - - Widget _pane(int index) => KeyedSubtree( - key: _paneKeys[index], - child: Builder(builder: widget.panes[index].builder), - ); - - Widget _buildMultiPane( - BuildContext context, - double availableWidth, - List visible, - ) { - _lastAvailableWidth = availableWidth; - final flexIndex = _flexIndex(visible); - - // Side-pane widths from their models, scaled down together if they - // would squeeze the flexible pane below its own minimum. - final widths = { - for (final i in visible) - if (i != flexIndex) i: _models[i].width(availableWidth), - }; - final sideTotal = widths.values.fold(0.0, (a, b) => a + b); - final flexMin = widget.panes[flexIndex].minWidth; - if (sideTotal > availableWidth - flexMin && sideTotal > 0) { - final scale = (availableWidth - flexMin) / sideTotal; - widths.updateAll((_, w) => w * scale); - } - - final children = [ - for (final i in visible) - if (i == flexIndex) - Expanded(child: _pane(i)) - else - SizedBox(width: widths[i], child: _pane(i)), - ]; - - // Divider hit zones straddle each adjacent pane boundary. - final clampedSideTotal = widths.values.fold(0.0, (a, b) => a + b); - final flexWidth = availableWidth - clampedSideTotal; - final dividers = []; - var cursor = 0.0; - for (var slot = 0; slot < visible.length - 1; slot++) { - final index = visible[slot]; - cursor += index == flexIndex ? flexWidth : widths[index]!; - dividers.add( - _divider(context, availableWidth, visible, flexIndex, slot, cursor), - ); - } - - return Stack( - children: [ - Row(children: children), - ...dividers, - ], - ); - } - - Widget _divider( - BuildContext context, - double availableWidth, - List visible, - int flexIndex, - int slot, - double boundary, - ) { - // The divider resizes the non-flexible neighbor of this boundary. - final before = visible[slot]; - final after = visible[slot + 1]; - final controlled = before == flexIndex ? after : before; - final growsTowardEnd = controlled == before; - final isRtl = Directionality.of(context) == TextDirection.rtl; - - double controlledWidth() => _models[controlled].width(availableWidth); - String share(double delta) { - // Min-wins guard against inverted bounds on narrow windows. - final floor = widget.panes[controlled].minWidth; - final ceiling = _maxPaneWidth(); - final clamped = ceiling <= floor - ? floor - : (controlledWidth() + delta).clamp(floor, ceiling); - return '${(clamped / availableWidth * 100).round()}%'; - } - - void applyPhysical(double physicalDelta) { - final logical = isRtl ? -physicalDelta : physicalDelta; - _dragModel(controlled, logical, growsTowardEnd); - } - - return PositionedDirectional( - start: (boundary - widget.dividerHitWidth / 2).clamp( - 0.0, - availableWidth - widget.dividerHitWidth, - ), - top: 0, - bottom: 0, - child: PaneDividerRegion( - hitWidth: widget.dividerHitWidth, - stateFor: (focused) => DividerState( - isDragging: _draggingSlot == slot, - atMinimum: - controlledWidth() <= widget.panes[controlled].minWidth + 0.5, - atMaximum: controlledWidth() >= _maxPaneWidth() - 0.5, - isFocused: focused, - ), - dividerBuilder: widget.dividerBuilder, - onDragStart: () { - _models[controlled].dragStart(availableWidth); - setState(() => _draggingSlot = slot); - }, - onDragDelta: applyPhysical, - onDragEnd: () { - _models[controlled].dragEnd(); - setState(() => _draggingSlot = null); - }, - onStep: applyPhysical, - // No collapse system here — Enter is a no-op by contract. - onToggleCollapse: () {}, - onJumpToMinimum: () => - _snapModel(controlled, widget.panes[controlled].minWidth), - onJumpToMaximum: () => _snapModel(controlled, _maxPaneWidth()), - onReset: () => - _snapModel(controlled, widget.panes[controlled].preferredWidth), - semanticsLabel: widget.dividerSemanticsLabel, - semanticsValue: share(0), - semanticsIncreasedValue: share(PaneDividerRegion.keyboardStep), - semanticsDecreasedValue: share(-PaneDividerRegion.keyboardStep), - ), - ); - } - - int? _draggingSlot; -} diff --git a/test/core/three_pane/three_pane_layout_test.dart b/test/core/three_pane/three_pane_layout_test.dart deleted file mode 100644 index 9c8d966..0000000 --- a/test/core/three_pane/three_pane_layout_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:adaptive_layouts/adaptive_layouts.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers.dart'; - -/// Role-priority partitioning, order decoupling, per-divider resizing, -/// and offstage state retention for ThreePaneLayout. -void main() { - Widget buildLayout({double largeBreakpoint = 1200}) { - return ThreePaneLayout( - largeBreakpoint: largeBreakpoint, - panes: [ - PaneSpec( - role: PaneRole.secondary, - preferredWidth: 300, - builder: (context) => - const ColoredBox(key: Key('list'), color: Color(0xFF111111)), - ), - PaneSpec( - role: PaneRole.primary, - builder: (context) => const ColoredBox( - key: Key('editor'), - color: Color(0xFF222222), - child: CounterPane(), - ), - ), - PaneSpec( - role: PaneRole.tertiary, - preferredWidth: 260, - builder: (context) => - const ColoredBox(key: Key('inspector'), color: Color(0xFF333333)), - ), - ], - ); - } - - const compact = Size(500, 800); - const medium = Size(1000, 800); - const large = Size(1400, 800); - - bool onstage(WidgetTester tester, Key key) { - final finder = find.byKey(key, skipOffstage: true); - return tester.any(finder); - } - - group('partitions', () { - testWidgets('compact shows only the primary pane', (tester) async { - await pumpApp(tester, buildLayout(), size: compact); - expect(onstage(tester, const Key('editor')), isTrue); - expect(onstage(tester, const Key('list')), isFalse); - expect(onstage(tester, const Key('inspector')), isFalse); - expect(tester.getSize(find.byKey(const Key('editor'))).width, 500); - }); - - testWidgets('medium shows primary + secondary; tertiary yields', ( - tester, - ) async { - await pumpApp(tester, buildLayout(), size: medium); - expect(onstage(tester, const Key('editor')), isTrue); - expect(onstage(tester, const Key('list')), isTrue); - expect(onstage(tester, const Key('inspector')), isFalse); - // Visual order preserved: list (secondary) sits at the start even - // though the primary outranks it. - expect(tester.getTopLeft(find.byKey(const Key('list'))).dx, 0); - expect(tester.getSize(find.byKey(const Key('list'))).width, 300); - expect(tester.getSize(find.byKey(const Key('editor'))).width, 700); - }); - - testWidgets('large shows all three at their preferred widths', ( - tester, - ) async { - await pumpApp(tester, buildLayout(), size: large); - expect(onstage(tester, const Key('inspector')), isTrue); - expect(tester.getSize(find.byKey(const Key('list'))).width, 300); - expect(tester.getSize(find.byKey(const Key('inspector'))).width, 260); - expect( - tester.getSize(find.byKey(const Key('editor'))).width, - 1400 - 300 - 260, - ); - }); - }); - - group('dividers', () { - testWidgets('start divider resizes the secondary pane', (tester) async { - await pumpApp(tester, buildLayout(), size: large); - - await tester.dragFrom(const Offset(300, 400), const Offset(60, 0)); - await tester.pumpAndSettle(); - expect( - tester.getSize(find.byKey(const Key('list'))).width, - closeTo(360, 30), - ); - }); - - testWidgets('end divider resizes the tertiary pane (inverted)', ( - tester, - ) async { - await pumpApp(tester, buildLayout(), size: large); - - // The tertiary boundary sits at 1400 - 260. Dragging toward the - // start GROWS the tertiary. - await tester.dragFrom(const Offset(1140, 400), const Offset(-60, 0)); - await tester.pumpAndSettle(); - expect( - tester.getSize(find.byKey(const Key('inspector'))).width, - closeTo(320, 30), - ); - }); - - testWidgets('dragged width survives a partition round trip', ( - tester, - ) async { - await pumpApp(tester, buildLayout(), size: large); - await tester.dragFrom(const Offset(300, 400), const Offset(80, 0)); - await tester.pumpAndSettle(); - final dragged = tester.getSize(find.byKey(const Key('list'))).width; - - await pumpApp(tester, buildLayout(), size: compact); - await tester.pumpAndSettle(); - await pumpApp(tester, buildLayout(), size: large); - await tester.pumpAndSettle(); - expect(tester.getSize(find.byKey(const Key('list'))).width, dragged); - }); - }); - - group('state retention', () { - testWidgets('hidden panes keep their live state offstage', (tester) async { - await pumpApp(tester, buildLayout(), size: large); - - // Mutate state inside the primary pane, then hide the others and - // bring them back — and shrink so even the primary's neighbors - // cycle through offstage. - await tester.tap(find.text('count: 0')); - await tester.pump(); - expect(find.text('count: 1'), findsOneWidget); - - await pumpApp(tester, buildLayout(), size: compact); - await tester.pumpAndSettle(); - expect(find.text('count: 1'), findsOneWidget); - - await pumpApp(tester, buildLayout(), size: large); - await tester.pumpAndSettle(); - expect(find.text('count: 1'), findsOneWidget); - }); - }); - - group('two panes', () { - testWidgets('a two-pane list works and caps at two partitions', ( - tester, - ) async { - final layout = ThreePaneLayout( - panes: [ - PaneSpec( - role: PaneRole.secondary, - preferredWidth: 300, - builder: (context) => - const ColoredBox(key: Key('side'), color: Color(0xFF111111)), - ), - PaneSpec( - role: PaneRole.primary, - builder: (context) => - const ColoredBox(key: Key('main'), color: Color(0xFF222222)), - ), - ], - ); - await pumpApp(tester, layout, size: large); - expect(tester.getSize(find.byKey(const Key('side'))).width, 300); - expect(tester.getSize(find.byKey(const Key('main'))).width, 1100); - - await pumpApp(tester, layout, size: compact); - await tester.pumpAndSettle(); - expect(onstage(tester, const Key('main')), isTrue); - expect(onstage(tester, const Key('side')), isFalse); - }); - }); -} From 1d0fa6994ec5cd96a2caaa7dd36453f92fee76af Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 12:16:19 +0530 Subject: [PATCH 38/41] feat!: rename AdaptiveSplit to SplitLayout The Adaptive prefix was redundant inside adaptive_layouts, and the family now reads with one naming rule: ListDetailLayout for selection-driven pairs, SplitLayout for peer panes. Files, tests, and every doc reference swept; zero grep hits for the old name. --- CHANGELOG.md | 6 +++--- CHANGELOG.pre.md | 6 +++--- README.md | 8 ++++---- docs/ARCHITECTURE.md | 8 ++++---- docs/CAPABILITY_ROADMAP.md | 6 +++--- docs/UPDATING.md | 2 +- lib/adaptive_layouts.dart | 2 +- lib/src/core/shared/divider_builder.dart | 2 +- lib/src/core/shared/pane_collapse.dart | 2 +- lib/src/core/shared/pane_divider_region.dart | 2 +- lib/src/core/shared/pane_width_model.dart | 4 ++-- .../{adaptive_split.dart => split_layout.dart} | 14 +++++++------- ...tive_split_test.dart => split_layout_test.dart} | 4 ++-- 13 files changed, 33 insertions(+), 33 deletions(-) rename lib/src/core/split/{adaptive_split.dart => split_layout.dart} (98%) rename test/core/split/{adaptive_split_test.dart => split_layout_test.dart} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba96727..146891d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ 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. @@ -75,9 +75,9 @@ First release — adaptive layout widgets that morph between phone and desktop f - **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 `AdaptiveSplit`'s end-positioned primary. +- **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 `AdaptiveSplit`'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. +- **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 04944b0..32f9f38 100644 --- a/CHANGELOG.pre.md +++ b/CHANGELOG.pre.md @@ -64,15 +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. - **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 `AdaptiveSplit`'s end-positioned primary. +- **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 `AdaptiveSplit`'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. +- **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 d76222b..b444424 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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. -Three layouts carry the package today: `ListDetailLayout` (list + selected detail, the messaging-app shape), `AdaptiveSplit` (two always-present panes, the player shape), and `showAdaptiveModal` (a real Material dialog on wide windows that is a real Material bottom sheet on narrow ones). Every layout that joins them follows the same rules: router-agnostic, state-management-agnostic, and the smallest possible integration surface — a plain `ChangeNotifier` controller, or just an awaited future. +Three layouts carry the package today: `ListDetailLayout` (list + selected detail, the messaging-app shape), `SplitLayout` (two always-present panes, the player shape), and `showAdaptiveModal` (a real Material dialog on wide windows that is a real Material bottom sheet on narrow ones). Every layout that joins them follows the same rules: router-agnostic, state-management-agnostic, and the smallest possible integration surface — a plain `ChangeNotifier` controller, or just an awaited future. > **The guarantee that makes this package exist:** pane and modal content *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. @@ -271,7 +271,7 @@ ListDetailLayout( ) ``` -(`collapsedDetailBuilder` covers the end side; `AdaptiveSplit` has +(`collapsedDetailBuilder` covers the end side; `SplitLayout` has `collapsedPrimaryBuilder` / `collapsedSecondaryBuilder`.) For the wide layout's "nothing selected" area, pass any builder — or the shipped one: @@ -285,10 +285,10 @@ emptyStateBuilder: IconMessageEmpty.of( ### Two panes without a selection -`AdaptiveSplit` is the sibling for screens where both panes always exist — a player with its queue, an editor with its preview: +`SplitLayout` is the sibling for screens where both panes always exist — a player with its queue, an editor with its preview: ```dart -AdaptiveSplit( +SplitLayout( primaryBuilder: (context, isExpanded) => PlayerHero(), secondaryBuilder: (context, isExpanded) => QueueList(), dividerBuilder: HandleDivider.builder, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d962092..814bf1d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,7 +47,7 @@ lib/ │ ├── pane_resize_mode.dart # ratio vs pixels │ └── pane_width_model.dart # width/drag/clamp/snap 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 @@ -77,7 +77,7 @@ 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 = @@ -198,7 +198,7 @@ machinery is in [`UPDATING.md`](UPDATING.md). ## 4. State preservation across the morph -Detail (and both `AdaptiveSplit` panes) are mounted under stable +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 @@ -237,7 +237,7 @@ as pane content does between compact and expanded builds. 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. +RTL-aware, and inverted for an end-positioned `SplitLayout` primary. --- diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 129fdbf..237ed9c 100644 --- a/docs/CAPABILITY_ROADMAP.md +++ b/docs/CAPABILITY_ROADMAP.md @@ -29,11 +29,11 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | | 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 + AdaptiveSplit) | +| 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 | |---|---|---| @@ -61,7 +61,7 @@ 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 | -| 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 `AdaptiveSplit` end-positioned primary | +| 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`/`isExpanded` + `collapse`/`restore` actions; the hamburger recipe | diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 259f262..e9f19dc 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -258,7 +258,7 @@ The machinery in `list_detail_layout.dart` holds: 3. **`PaneSide` / `PaneCollapsible` are DIRECTIONAL in every public surface** (config, `DividerState.collapsed`, `PaneScope`). The width model works in model space (start = the measured pane). - `AdaptiveSplit` with an end-positioned primary translates at its + `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 diff --git a/lib/adaptive_layouts.dart b/lib/adaptive_layouts.dart index e42fa5a..bc493a3 100644 --- a/lib/adaptive_layouts.dart +++ b/lib/adaptive_layouts.dart @@ -28,7 +28,7 @@ export 'src/core/list_detail/compact_config.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/adaptive_split.dart'; +export 'src/core/split/split_layout.dart'; // Core — shared configuration + vocabulary export 'src/core/shared/adaptive_layout_config.dart'; diff --git a/lib/src/core/shared/divider_builder.dart b/lib/src/core/shared/divider_builder.dart index 5bf8e16..16b38b8 100644 --- a/lib/src/core/shared/divider_builder.dart +++ b/lib/src/core/shared/divider_builder.dart @@ -65,7 +65,7 @@ class DividerState { /// 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. typedef DividerBuilder = diff --git a/lib/src/core/shared/pane_collapse.dart b/lib/src/core/shared/pane_collapse.dart index 695d3ad..095d12b 100644 --- a/lib/src/core/shared/pane_collapse.dart +++ b/lib/src/core/shared/pane_collapse.dart @@ -1,7 +1,7 @@ /// 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 `AdaptiveSplit` the start pane is whichever pane sits +/// 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. diff --git a/lib/src/core/shared/pane_divider_region.dart b/lib/src/core/shared/pane_divider_region.dart index 4efc41c..3f2338c 100644 --- a/lib/src/core/shared/pane_divider_region.dart +++ b/lib/src/core/shared/pane_divider_region.dart @@ -4,7 +4,7 @@ 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 -/// `AdaptiveSplit`: drag gestures, double-click reset, keyboard resizing, +/// `SplitLayout`: drag gestures, double-click reset, keyboard resizing, /// and screen-reader semantics — the WAI-ARIA window-splitter pattern /// mapped to Flutter. /// diff --git a/lib/src/core/shared/pane_width_model.dart b/lib/src/core/shared/pane_width_model.dart index d4890b9..4b654c0 100644 --- a/lib/src/core/shared/pane_width_model.dart +++ b/lib/src/core/shared/pane_width_model.dart @@ -4,7 +4,7 @@ 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]. /// @@ -29,7 +29,7 @@ class PaneWidthModel { /// 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 (`AdaptiveSplit` with an end-positioned primary) + /// directional start (`SplitLayout` with an end-positioned primary) /// pass a flipped value so [PaneConfig.collapsible] stays directional. final PaneCollapsible collapsible; diff --git a/lib/src/core/split/adaptive_split.dart b/lib/src/core/split/split_layout.dart similarity index 98% rename from lib/src/core/split/adaptive_split.dart rename to lib/src/core/split/split_layout.dart index e2df87f..2e1f480 100644 --- a/lib/src/core/split/adaptive_split.dart +++ b/lib/src/core/split/split_layout.dart @@ -13,7 +13,7 @@ import 'package:adaptive_layouts/src/core/shared/pane_width_model.dart'; // BUILDER TYPEDEFS // ============================================================================= -/// Builder for a pane in an [AdaptiveSplit]. +/// Builder for a pane in an [SplitLayout]. /// /// [isExpanded] is true when in side-by-side expanded layout, /// false when in compact (stacked) layout. @@ -61,7 +61,7 @@ enum SplitCompactBehavior { /// ## Usage /// /// ```dart -/// AdaptiveSplit( +/// SplitLayout( /// primaryBuilder: (context, isExpanded) => AlbumArt(), /// secondaryBuilder: (context, isExpanded) => QueueOrVisualizer(), /// dividerBuilder: HandleDivider.builder, @@ -71,9 +71,9 @@ enum SplitCompactBehavior { /// ), /// ) /// ``` -class AdaptiveSplit extends StatefulWidget { +class SplitLayout extends StatefulWidget { /// Creates an adaptive two-pane split layout. - const AdaptiveSplit({ + const SplitLayout({ super.key, required this.primaryBuilder, required this.secondaryBuilder, @@ -128,14 +128,14 @@ class AdaptiveSplit extends StatefulWidget { final WidgetBuilder? collapsedSecondaryBuilder; @override - State createState() => _AdaptiveSplitState(); + State createState() => _SplitLayoutState(); } // ============================================================================= // STATE // ============================================================================= -class _AdaptiveSplitState extends State +class _SplitLayoutState extends State with SingleTickerProviderStateMixin { // --------------------------------------------------------------------------- // Divider drag state — width/clamp/snap logic lives in the shared model; @@ -189,7 +189,7 @@ class _AdaptiveSplitState extends State bool _isExpanded = false; @override - void didUpdateWidget(AdaptiveSplit oldWidget) { + 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 diff --git a/test/core/split/adaptive_split_test.dart b/test/core/split/split_layout_test.dart similarity index 99% rename from test/core/split/adaptive_split_test.dart rename to test/core/split/split_layout_test.dart index 1669800..241a117 100644 --- a/test/core/split/adaptive_split_test.dart +++ b/test/core/split/split_layout_test.dart @@ -12,7 +12,7 @@ void main() { PaneConfig paneConfig = const PaneConfig(), DividerBuilder? dividerBuilder, }) { - return AdaptiveSplit( + return SplitLayout( primaryPosition: primaryPosition, compactBehavior: compactBehavior, paneConfig: paneConfig, @@ -212,7 +212,7 @@ void main() { testWidgets('PaneScope actions speak directional sides', (tester) async { await pumpApp( tester, - AdaptiveSplit( + SplitLayout( primaryPosition: SplitPrimaryPosition.end, paneConfig: const PaneConfig(collapsible: PaneCollapsible.end), primaryBuilder: (context, isExpanded) => From 725b9e6b1670249e38d385fd7467ccadd6f8b039 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 12:40:40 +0530 Subject: [PATCH 39/41] docs: rewrite README around the three widgets Restructured from one flat Usage pile into widget-per-section: a which-widget decision table up front, ListDetailLayout / SplitLayout / showAdaptiveModal each self-contained, the shared divider system in its own clearly-both-layouts section, and all resize behavior consolidated under one 'How resizing behaves' contract list. Reference tables fold into details blocks; the why-explanations keep their collapsed homes. --- README.md | 342 +++++++++++++++++++++++++++++------------------------- 1 file changed, 181 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index b444424..15a3e8a 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ 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. -Three layouts carry the package today: `ListDetailLayout` (list + selected detail, the messaging-app shape), `SplitLayout` (two always-present panes, the player shape), and `showAdaptiveModal` (a real Material dialog on wide windows that is a real Material bottom sheet on narrow ones). Every layout that joins them follows the same rules: router-agnostic, state-management-agnostic, and the smallest possible integration surface — a plain `ChangeNotifier` controller, or just an awaited future. +Router-agnostic and state-management-agnostic: the integration surface is a plain `ChangeNotifier` controller, or just an awaited future. -> **The guarantee that makes this package exist:** pane and modal content *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. +> **The guarantee that makes this package exist:** pane and modal content *instances* survive the compact ↔ expanded morph. The standard adaptive components (Compose's `ListDetailPaneScaffold`, route-based detail pages) rebuild content 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) @@ -24,15 +24,20 @@ Three layouts carry the package today: `ListDetailLayout` (list + selected detai 👀 Peek inside - [Install](#install) -- [Quick start](#quick-start) -- [Usage](#usage) +- [Which widget?](#which-widget) +- [ListDetailLayout](#listdetaillayout) + - [Quick start](#quick-start) - [The controller](#the-controller) - - [Covering the bottom nav (overlay mode)](#covering-the-bottom-nav-overlay-mode) - - [Sizing the panes](#sizing-the-panes) - - [Two panes without a selection](#two-panes-without-a-selection) - - [A modal that swaps between dialog and bottom sheet](#a-modal-that-swaps-between-dialog-and-bottom-sheet) - - [One breakpoint for the whole app](#one-breakpoint-for-the-whole-app) -- [Why widget-level morphing](#why-widget-level-morphing) + - [Compact: three detail modes](#compact-three-detail-modes) + - [Expanded: the empty pane](#expanded-the-empty-pane) +- [SplitLayout](#splitlayout) +- [The divider (both layouts)](#the-divider-both-layouts) + - [Sizing](#sizing) + - [Snap-collapse and icon rails](#snap-collapse-and-icon-rails) + - [Keyboard and screen readers](#keyboard-and-screen-readers) +- [showAdaptiveModal](#showadaptivemodal) +- [How resizing behaves](#how-resizing-behaves) +- [One breakpoint for the whole app](#one-breakpoint-for-the-whole-app) - [The example app](#the-example-app) - [Platform support](#platform-support) - [Not in the box](#not-in-the-box) @@ -54,32 +59,44 @@ Pure Flutter — no native code, no assets, no setup, any platform. --- -## Quick start +## Which widget? -Two builders, and the layout handles the rest — breakpoint switching, slide animation, swipe-to-dismiss, back-gesture handling, state preservation: +Three widgets, one question each: + +| You have | Use | Compact becomes | +|---|---|---| +| A list that **drives** a detail (chats, tickets, inbox) | [`ListDetailLayout`](#listdetaillayout) | navigation: the detail slides over, or becomes a real page | +| Two panes that are **peers** — nobody selects anybody (player + queue, editor + preview) | [`SplitLayout`](#splitlayout) | a vertical stack, or the primary alone | +| A one-off surface the user summons (form, picker, confirmation) | [`showAdaptiveModal`](#showadaptivemodal) | a real bottom sheet (a real dialog when wide) | + +All three share the same breakpoint (default 720, [configurable app-wide](#one-breakpoint-for-the-whole-app)), and all three keep live widget state across the morph. Everything below the breakpoint is called **compact**; at or above it, **expanded**. + +--- + +## ListDetailLayout + +### Quick start + +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 expanded onTap: onSelect, // tapping a row opens its detail ), detailBuilder: (context, id, mode, onDismiss) => ChatScreen( id: id, - showBackButton: mode == DetailLayoutMode.stacked, // phone: back arrow - showCloseButton: mode == DetailLayoutMode.sideBySide, // wide: close X + showBackButton: mode == DetailLayoutMode.stacked, // compact: back arrow + showCloseButton: mode == DetailLayoutMode.sideBySide, // expanded: 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. - ---- - -## Usage +Compact: the detail slides over the list (`DetailLayoutMode.stacked`). Expanded: the panes share the width (`DetailLayoutMode.sideBySide`). The `mode` argument tells your detail which affordance to show — everything else is the same widget. ### The controller @@ -88,74 +105,35 @@ 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: three 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. - -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. +- **`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. -And when the compact detail should be a real page — real platform transitions, **predictive back**, Cupertino edge swipes, all from your app's `PageTransitionsTheme`: +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. -```dart -ListDetailLayout( - compactDetailMode: CompactDetailMode.route, - ... -) -``` - -Selecting pushes a genuine page route holding the detail; back is the route's own (no interception). Resize across the breakpoint and the same detail element reparents between the route and the side-by-side pane — the state guarantee holds through real navigation. Hidden kept-alive tabs remove their route (keeping the selection and the detail's state) and restore it instantly when shown again — including when the breakpoint crossing itself happens while the tab is hidden. - -One app-side note: every route push makes Flutter scan the shell for `Hero` tags, kept-alive tabs included. If several `FloatingActionButton`s coexist under one page (one per tab), give them explicit `heroTag`s — the shared default tag asserts on the first push. - -### The empty detail pane — three schools - -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` (`IconMessageEmpty` ships as a convenience). The Apple Mail / Outlook reading-pane shape. -2. **Auto-select** — never show emptiness: when the list loads and nothing is selected, select the first (or last-used) item from your controller: `controller.select(items.first.id)`. The Notes / Slack shape. 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 app ships the recipe behind a ⚙ toggle. -3. **On-demand pane** — the list owns the full width until a selection summons the pane, which reveals from the end edge; dismissing hands the width back. Material's "supporting pane" shape (Gmail without a reading pane): - -```dart -ListDetailLayout( - expandedEmptyBehavior: ExpandedEmptyBehavior.listOnly, - ... -) -``` - -A 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 entirely. - -### Picking a compact detail mode - -All three modes keep the state guarantee across resizes. They differ in what the open detail covers and who owns the back gesture: +
+🧰 The full mode comparison table | | `inline` | `overlay` | `route` | |---|---|---|---| @@ -168,19 +146,61 @@ All three modes keep the state guarantee across resizes. They differ in what the | 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 | +| 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. + +
+ +### Expanded: the empty 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 + ``` -Rule of thumb: `inline` when the surrounding chrome should stay present, `overlay` for a full-screen feel without real navigation, `route` when the detail should behave like a native page and inherit every platform back-gesture convention as it evolves. The per-value doc comments on `CompactDetailMode` carry the full contracts. + 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. -Breakpoint crossings animate the pane re-arrangement in every mode — fold/unfold, rotation, split-screen snap, or dragging the window edge across the threshold. Only the pane geometry itself tracks the drag without motion (a lagging pane would fight your hand); the arrangement flip is always animated, the way Compose's canonical scaffolds and desktop sidebars behave. That includes the EMPTY placeholder pane: with nothing selected it reveals from the end edge on expand and retreats into it on shrink. +--- + +## SplitLayout + +Two panes that are peers — both always exist, neither drives the other: + +```dart +SplitLayout( + primaryBuilder: (context, isExpanded) => PlayerHero(), + secondaryBuilder: (context, isExpanded) => QueueList(), + dividerBuilder: HandleDivider.builder, + compactBehavior: SplitCompactBehavior.stack, // or .hidden +) +``` -Entering expanded, the arriving list is laid out at its final width and slides in clipped — content never reflows mid-entry, the way a desktop sidebar arrives. Prefer the list to lay out live and grow into its pane instead? `PaneConfig(entryStyle: ExpandedEntryStyle.resize)`. +Expanded: side by side with the draggable divider (primary at the start or end via `primaryPosition`). Compact: a vertical stack, or the primary alone. Both panes keep their state across the morph. -The divider remembers. A dragged divider position survives compact spells, window resizes, and rebuilds — `PaneConfig` compares by value, so constructing it inline in `build` never resets the width model. Prefer a fresh divider on every return to the wide layout instead? `PaneConfig(widthMemory: PaneWidthMemory.resetOnReentry)`. +Everything in [the divider section](#the-divider-both-layouts) — sizing, anchors, collapse, rails, keyboard — applies here identically (`collapsedPrimaryBuilder` / `collapsedSecondaryBuilder` are the rail slots). -### Sizing the panes +--- + +## The divider (both layouts) -`PaneConfig` is pure data; the divider visual is a builder you pick or write: +`ListDetailLayout` and `SplitLayout` share one divider system: the same width model, the same gestures, the same accessibility. Configured through `PaneConfig` (pure data) plus a divider visual you pick or write. + +### Sizing ```dart ListDetailLayout( @@ -194,9 +214,10 @@ 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. -Two extras for desktop-grade feel: +
+🧰 Anchors, resize modes, width memory ```dart // Snap points: on release, the divider animates to the nearest anchor. @@ -208,12 +229,19 @@ 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) ``` -### Snap-collapse and the divider's keyboard +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 -Panes can collapse — force the divider past a pane's minimum and it snaps -shut, VS Code style. Opt in per side: +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 PaneConfig( @@ -222,45 +250,9 @@ PaneConfig( ) ``` -The mechanics follow desktop split views: 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 (the shipped `HandleDivider` turns into a pull tab), and the -surviving pane can offer its own affordance by reading `PaneScope`: - -```dart -// Inside a detail pane — the show-sidebar recipe. Gate on -// collapsedSize == 0: a visible icon rail already carries the expand -// control, so only a FULLY hidden pane needs this 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/restore snap instantly; the drag path is -the animated one. - -The divider itself follows the WAI-ARIA window-splitter pattern — it's -focusable and screen-reader adjustable out of the box: - -| Input | Effect | -|---|---| -| 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%") | - -Localize the announcement via `PaneConfig(dividerSemanticsLabel: ...)`. +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. -With a non-zero `collapsedSize`, the collapsed pane clips its normal -content at its minimum width by default. For a real icon rail, give the -slot purpose-built content — it lays out at the actual collapsed width, -and the pane parks offstage with its state alive until restored: +**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 ListDetailLayout( @@ -271,36 +263,44 @@ ListDetailLayout( ) ``` -(`collapsedDetailBuilder` covers the end side; `SplitLayout` has -`collapsedPrimaryBuilder` / `collapsedSecondaryBuilder`.) +Without a rail builder, the collapsed pane shows its normal content clipped at its minimum width. -For the wide layout's "nothing selected" area, pass any builder — or the shipped one: +**The scope.** Any widget inside either pane can read the collapse state and act on it through `PaneScope` — the show-sidebar recipe: ```dart -emptyStateBuilder: IconMessageEmpty.of( - icon: Icons.chat_bubble_outline, - message: 'Select a conversation', -) +// 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, + ) ``` -### Two panes without a selection +`PaneScope` also exposes `collapse(PaneSide)` for app-driven collapse buttons. Programmatic collapse and restore snap instantly; the drag path is the animated one. -`SplitLayout` is the sibling for screens where both panes always exist — a player with its queue, an editor with its preview: +### Keyboard and screen readers -```dart -SplitLayout( - primaryBuilder: (context, isExpanded) => PlayerHero(), - secondaryBuilder: (context, isExpanded) => QueueList(), - dividerBuilder: HandleDivider.builder, - compactBehavior: SplitCompactBehavior.stack, // or .hidden -) -``` +The divider follows the WAI-ARIA window-splitter pattern out of the box: + +| 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%") | + +Localize the announcement via `PaneConfig(dividerSemanticsLabel: ...)`. -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. +--- -### A modal that swaps between dialog and bottom sheet +## showAdaptiveModal -`showAdaptiveModal` presents a real Material dialog on wide windows and a real Material bottom sheet on narrow ones — `DialogRoute` and `ModalBottomSheetRoute` underneath, so your `DialogTheme` / `BottomSheetTheme`, Material's drag physics, and back handling all apply. 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, and a half-typed form field survives the trip. `ModalConfig(morph: false)` swaps instantly instead. +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( @@ -313,55 +313,75 @@ final choice = await showAdaptiveModal( ); ``` -`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. 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). +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. -The returned future completes with the pop result no matter how many form swaps happened while the modal was open. Each form keeps its own Material surface tone (`surfaceContainerHigh` for dialogs, `surfaceContainerLow` for sheets, themable as usual); `ModalConfig(backgroundColor: ...)` pins one color across both forms when the crossfade is unwanted. 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. +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. -### One breakpoint for the whole app +
+🧰 What ModalConfig forwards, and what it deliberately doesn't -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. +`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. -```dart -AdaptiveLayoutConfig( - expandedBreakpoint: 800, - child: MaterialApp(...), -) -``` +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. + +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). + +
--- -## Why widget-level morphing +## How resizing behaves + +The rules that hold across every widget and mode: + +- **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) always animates, in both directions — including the empty placeholder pane. 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: the rail docks at its 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 not push the detail as a route on phones? 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. -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. +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. (`CompactDetailMode.route` then earns the platform behaviors back on top, reparenting through a real route.)
🧩 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, including the invariants to keep when editing this machinery, is in [`docs/UPDATING.md`](docs/UPDATING.md). +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 (`DialogRoute`, `ModalBottomSheetRoute`), so theming, drag-to-dismiss 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 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 animation is Material's container-transform pattern with one upgrade Material itself doesn't have: `Hero` and `OpenContainer` both rebuild the content they animate, while this flight carries the *live element* — the surface lerps rect, shape, color, and elevation between the two forms with your widget's state intact inside it. The destination route lays out a same-size placeholder whose live rect steers the landing each frame, so keyboard insets and content reflow are tracked automatically. +The swap animation is Material's container-transform pattern with one upgrade Material itself doesn't have: `Hero` and `OpenContainer` both rebuild the content they animate, while this flight carries the *live element* — the surface lerps rect, shape, color, and elevation between the two forms with your widget's state intact inside it.
--- +## One breakpoint for the whole app + +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(...), +) +``` + +--- + ## The example app -[`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. +[`example/`](example/) is a full app, not a snippet gallery: three domains behind an adaptive shell (bottom nav ↔ rail), nested tab routers, URL-synced list-details in every compact mode, collapsible panes with icon rails, 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. ```sh cd example @@ -384,7 +404,7 @@ Pure Flutter, no conditional imports, no platform code: ## Not in the box - **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. --- From 22173b0c579721c208c21500a8e6c29bd159296b Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 12:44:14 +0530 Subject: [PATCH 40/41] docs: fix fossil README block arguing against route mode we now ship The 'why not push a route' explanation predated CompactDetailMode.route and read as the package arguing against its own feature. Reframed as the real layering: widget-level state engine as the foundation, route mode earning platform behaviors on top via the keyed-element handoff that a route-first design cannot perform. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 15a3e8a..2760cf4 100644 --- a/README.md +++ b/README.md @@ -340,11 +340,13 @@ The rules that hold across every widget and mode: - **Parked panes don't dance.** A collapsed pane arrives already collapsed: the rail docks at its 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 not push the detail as a route on phones? +🧩 Why the foundation is widget-level morphing, not routes -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. +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. That's the approach the standard components take, and it's why their state guarantee is weaker. -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. (`CompactDetailMode.route` then earns the platform behaviors back on top, reparenting through a real route.) +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 — an offstage bridge holds the element alive during the frames between resize and push, so the key is claimed by 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.
From 57008791820d25a763aa1142e1908133558bb48a Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Sun, 19 Jul 2026 12:56:45 +0530 Subject: [PATCH 41/41] =?UTF-8?q?docs:=20full=20audit=20=E2=80=94=20fleet?= =?UTF-8?q?=20README=20structure,=20fossil=20claims=20fixed,=20docs=20de-d?= =?UTF-8?q?rifted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README rebuilt on the fleet skeleton (mural's): opening paragraph, the why-a-package question quote, Quick start with the three-doors trio, one core-concept section (The morph), a Usage umbrella of scenario subsections, internals folded after Platform support, example app as a Docs-table row. The guarantee quote no longer reads as arguing against route mode. ARCHITECTURE: file tree caught up (route machinery, collapse, divider region, scope, memory, entry style), PaneConfig field list and settle knobs current, list-pane key noted, divider-interaction and collapse/PaneScope architecture documented, route design-decision row updated to the layering. ROADMAP: real test counts (174 + 12 journeys), configurable hit zone, parked-pane crossing exception, settle knobs, PaneScope collapsedSize. UPDATING: invariant 4 rewritten to the reflow-to-floor truth, list key in the morph invariants, stale 24px/12px failure-mode row fixed. --- README.md | 222 +++++++++++++++++-------------------- docs/ARCHITECTURE.md | 64 ++++++++--- docs/CAPABILITY_ROADMAP.md | 14 +-- docs/UPDATING.md | 21 ++-- 4 files changed, 172 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 2760cf4..f91724f 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,9 @@ license: MIT

-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. +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. -Router-agnostic and state-management-agnostic: the integration surface is a plain `ChangeNotifier` controller, or just an awaited future. - -> **The guarantee that makes this package exist:** pane and modal content *instances* survive the compact ↔ expanded morph. The standard adaptive components (Compose's `ListDetailPaneScaffold`, route-based detail pages) rebuild content from saved state instead — cursor position, scroll offset, and in-flight animations reset. Here they don't. +> **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. > **Status:** 0.x. Pre-1.0 the minor version is the breaking axis — pin `^0.N.0` and read the changelog on minor bumps. @@ -24,25 +22,21 @@ Router-agnostic and state-management-agnostic: the integration surface is a plai 👀 Peek inside - [Install](#install) -- [Which widget?](#which-widget) -- [ListDetailLayout](#listdetaillayout) - - [Quick start](#quick-start) +- [Quick start](#quick-start) +- [The morph](#the-morph) +- [Usage](#usage) - [The controller](#the-controller) - - [Compact: three detail modes](#compact-three-detail-modes) - - [Expanded: the empty pane](#expanded-the-empty-pane) -- [SplitLayout](#splitlayout) -- [The divider (both layouts)](#the-divider-both-layouts) - - [Sizing](#sizing) + - [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) - [Snap-collapse and icon rails](#snap-collapse-and-icon-rails) - - [Keyboard and screen readers](#keyboard-and-screen-readers) -- [showAdaptiveModal](#showadaptivemodal) -- [How resizing behaves](#how-resizing-behaves) -- [One breakpoint for the whole app](#one-breakpoint-for-the-whole-app) -- [The example app](#the-example-app) + - [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) - [Platform support](#platform-support) - [Not in the box](#not-in-the-box) - [Docs](#docs) -- [License](#license) @@ -55,27 +49,11 @@ dependencies: adaptive_layouts: ``` -Pure Flutter — no native code, no assets, no setup, any platform. - ---- - -## Which widget? - -Three widgets, one question each: - -| You have | Use | Compact becomes | -|---|---|---| -| A list that **drives** a detail (chats, tickets, inbox) | [`ListDetailLayout`](#listdetaillayout) | navigation: the detail slides over, or becomes a real page | -| Two panes that are **peers** — nobody selects anybody (player + queue, editor + preview) | [`SplitLayout`](#splitlayout) | a vertical stack, or the primary alone | -| A one-off surface the user summons (form, picker, confirmation) | [`showAdaptiveModal`](#showadaptivemodal) | a real bottom sheet (a real dialog when wide) | - -All three share the same breakpoint (default 720, [configurable app-wide](#one-breakpoint-for-the-whole-app)), and all three keep live widget state across the morph. Everything below the breakpoint is called **compact**; at or above it, **expanded**. +Nothing else to do, on any platform. Pure Flutter: no native code, no assets, no setup. --- -## ListDetailLayout - -### Quick start +## Quick start Two builders, and the layout handles the rest — breakpoint switching, slide animation, swipe-to-dismiss, back gestures, state preservation: @@ -84,19 +62,62 @@ import 'package:adaptive_layouts/adaptive_layouts.dart'; ListDetailLayout( listBuilder: (context, selectedId, onSelect) => ChatList( - selectedId: selectedId, // highlight the open row on expanded + 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, // compact: back arrow - showCloseButton: mode == DetailLayoutMode.sideBySide, // expanded: close X + showBackButton: mode == DetailLayoutMode.stacked, // narrow: back arrow + showCloseButton: mode == DetailLayoutMode.sideBySide, // wide: close X onBack: onDismiss, ), ) ``` -Compact: the detail slides over the list (`DetailLayoutMode.stacked`). Expanded: 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. + +
+ +--- + +## Usage ### The controller @@ -115,9 +136,9 @@ controller.isDetailVisible; // visual state — stays true until the exit URL sync sits on top of these members; the [example app](example/) ships a complete `ListDetailRouter` for auto_route as reference wiring. -### Compact: three detail modes +### Compact detail modes -`compactDetailMode` decides what the open detail is, on compact widths: +`compactDetailMode` decides what the open detail is on compact widths: ```dart ListDetailLayout( @@ -133,7 +154,9 @@ ListDetailLayout( 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.
-🧰 The full mode comparison table +🧰 the full mode comparison table + +
| | `inline` | `overlay` | `route` | |---|---|---|---| @@ -152,7 +175,7 @@ The per-value doc comments on `CompactDetailMode` carry the full contracts.
-### Expanded: the empty pane +### 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: @@ -175,11 +198,9 @@ When nothing is selected at expanded width, list-detail apps follow one of three 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. ---- - -## SplitLayout +### Two peer panes — SplitLayout -Two panes that are peers — both always exist, neither drives the other: +Both panes always exist, neither drives the other: ```dart SplitLayout( @@ -190,17 +211,11 @@ SplitLayout( ) ``` -Expanded: side by side with the draggable divider (primary at the start or end via `primaryPosition`). Compact: a vertical stack, or the primary alone. Both panes keep their state across the morph. +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). -Everything in [the divider section](#the-divider-both-layouts) — sizing, anchors, collapse, rails, keyboard — applies here identically (`collapsedPrimaryBuilder` / `collapsedSecondaryBuilder` are the rail slots). +### Sizing the panes ---- - -## The divider (both layouts) - -`ListDetailLayout` and `SplitLayout` share one divider system: the same width model, the same gestures, the same accessibility. Configured through `PaneConfig` (pure data) plus a divider visual you pick or write. - -### Sizing +`PaneConfig` is pure data; the divider visual is a builder you pick or write: ```dart ListDetailLayout( @@ -217,7 +232,9 @@ ListDetailLayout( 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 +🧰 anchors, resize modes, width memory + +
```dart // Snap points: on release, the divider animates to the nearest anchor. @@ -281,7 +298,7 @@ if (scope?.collapsed == PaneSide.start && scope!.collapsedSize == 0) `PaneScope` also exposes `collapse(PaneSide)` for app-driven collapse buttons. Programmatic collapse and restore snap instantly; the drag path is the animated one. -### Keyboard and screen readers +### The divider's keyboard and screen readers The divider follows the WAI-ARIA window-splitter pattern out of the box: @@ -296,9 +313,7 @@ The divider follows the WAI-ARIA window-splitter pattern out of the box: Localize the announcement via `PaneConfig(dividerSemanticsLabel: ...)`. ---- - -## showAdaptiveModal +### 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: @@ -318,7 +333,9 @@ Resize across the breakpoint while it is open and the modal plays a container tr 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.
-🧰 What ModalConfig forwards, and what it deliberately doesn't +🧰 what ModalConfig forwards, and what it deliberately doesn't + +
`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. @@ -328,47 +345,7 @@ Two params are withheld on purpose: the sheet's `clipBehavior` (the container tr
---- - -## How resizing behaves - -The rules that hold across every widget and mode: - -- **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) always animates, in both directions — including the empty placeholder pane. 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: the rail docks at its 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. That's the approach the standard components take, and it's why their state guarantee is weaker. - -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 — an offstage bridge holds the element alive during the frames between resize and push, so the key is claimed by 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. - -
- -
-🧩 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 animation is Material's container-transform pattern with one upgrade Material itself doesn't have: `Hero` and `OpenContainer` both rebuild the content they animate, while this flight carries the *live element* — the surface lerps rect, shape, color, and elevation between the two forms with your widget's state intact inside it. - -
- ---- - -## One breakpoint for the whole app +### One breakpoint for the whole app 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. @@ -381,18 +358,6 @@ AdaptiveLayoutConfig( --- -## The example app - -[`example/`](example/) is a full app, not a snippet gallery: three domains behind an adaptive shell (bottom nav ↔ rail), nested tab routers, URL-synced list-details in every compact mode, collapsible panes with icon rails, 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. - -```sh -cd example -flutter run -d chrome # the URL bar shows the deep-link sync live -flutter test # the journey suite -``` - ---- - ## Platform support Pure Flutter, no conditional imports, no platform code: @@ -401,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). - **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. --- @@ -418,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 814bf1d..cc1ee13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 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 @@ -30,6 +30,8 @@ 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 @@ -41,11 +43,16 @@ lib/ │ └── 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/ └── split_layout.dart # generic two-pane split widget ``` @@ -198,8 +205,8 @@ machinery is in [`UPDATING.md`](UPDATING.md). ## 4. State preservation across the morph -Detail (and both `SplitLayout` 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 @@ -219,8 +226,13 @@ 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: @@ -230,15 +242,35 @@ as pane content does between compact and expanded builds. - **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 +- **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. + --- ## 6. Configuration resolution @@ -257,7 +289,7 @@ 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. | diff --git a/docs/CAPABILITY_ROADMAP.md b/docs/CAPABILITY_ROADMAP.md index 237ed9c..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 | @@ -27,7 +27,7 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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 | +| 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 | @@ -64,8 +64,8 @@ while an active row is not `DONE` or `WONT_DO`. Statuses: `DONE` · | 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`/`isExpanded` + `collapse`/`restore` actions; the hamburger recipe | -| Anchor snap points with settle animation | DONE | Nearest anchor on drag end; `isSettling` fed to divider builders | +| 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 @@ -80,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 e9f19dc..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 @@ -263,10 +264,13 @@ The machinery in `list_detail_layout.dart` holds: 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 stays laid out at its minimum width** inside - `ClipRect` + `OverflowBox` (start pane clips at `minListWidth`, end - pane at `available * (1 - maxListRatio)`) — content never reflows - below its floor while the slot shrinks. +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. @@ -312,7 +316,8 @@ The machinery in `list_detail_layout.dart` holds: 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`. ## §6 — Adding a config field @@ -367,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 |