-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzScaffoldShell
A generic, adaptive list-detail shell supporting two-pane desktop and single-pane mobile presentations. Container-driven via LayoutBuilder breakpoints. Consumer owns filtering, detail header, and state management.
Metadata
Mirrors: ThemedScaffoldView (layrz_theme)
Phase: M5 (Layout, navigation, feedback)
Domain: Scaffolds
Primitive: Hand-rolled (two columns on desktop, single pane with back affordance on mobile)
Status: Confirmed scope (D37).
LayrzScaffoldShell<T> is a generic scaffold for displaying a list and a detail view side-by-side (desktop) or toggled (mobile). It is not a router or navigator; the consumer owns routing, state management, and filtering.
-
Generic over item type
T: Lists and detail views work with any domain object. -
Container-driven presentations: Breakpoints resolve via
LayoutBuilderconstraints, not viewport dimensions. Allows responsive nesting in sub-windows. -
Consumer-owned filtering: The shell reports search text via
onSearch(String)callback; the consumer filters and returns the new list. The shell does not filter. -
Tile abstraction:
LayrzScaffoldTileis an abstract base class withtitleRichText,subtitleRichText, andactionsgetters. Consumers subclass it or use the providedLayrzScaffoldValueTilefor simple cases. - No tile keying: Rows are not keyed by tile; consumer owns key strategy.
-
State is caller-owned:
LayrzScaffoldController<T>holds the opened item. Consumer creates, owns, and disposes the controller.
class LayrzScaffoldShell<T> extends StatefulWidget {
/// Mandatory controller holding the currently selected item.
///
/// The consumer creates, owns, and disposes this controller.
/// It holds [LayrzScaffoldController.selectedItem], which the shell
/// toggles when a list row is tapped.
///
/// Multiple shells can share a single controller for lockstep selection.
/// When the controller is disposed before the shell unmounts, the shell
/// remains functional and does not throw.
final LayrzScaffoldController<T> controller;
/// The list of items to display in the list pane.
///
/// Type is [List<T>], allowing any domain object.
/// The list is typically filtered by the consumer based on [onSearch] callback.
/// Changing this list rebuilds the detail pane and scrolls the list to the top.
final List<T> items;
/// Builder returning a [LayrzScaffoldTile] describing each list row.
///
/// Called for every item in [items].
/// The returned tile provides [titleRichText], [subtitleRichText], and [actions].
///
/// Example:
/// ```dart
/// onBuild: (item) => LayrzScaffoldValueTile(
/// title: item.name,
/// subtitle: item.description,
/// )
/// ```
final LayrzScaffoldTile Function(T item) onBuild;
/// Builder rendering the entire detail pane.
///
/// Called with the currently selected item (from [controller.selectedItem]).
/// Returns the full detail view, including header, body, footer, tabs, etc.
/// The detail area is opaque to the shell; consumer owns all layout.
///
/// When no item is selected (controller.selectedItem is null), this is not called.
/// Instead, a placeholder "Select an item" message is shown.
///
/// Example:
/// ```dart
/// onDetailsBuild: (item) => Column(
/// children: [
/// Text(item.name, style: TextStyle(fontSize: 18)),
/// Expanded(child: Text(item.body)),
/// ],
/// )
/// ```
final Widget Function(T item) onDetailsBuild;
/// Callback when the search field text changes.
///
/// The shell does not filter; instead, it reports the search text.
/// The consumer filters [items] and returns the new list on the next rebuild.
///
/// Example:
/// ```dart
/// onSearch: (query) => setState(() => _searchQuery = query)
/// ```
final ValueChanged<String>? onSearch;
/// Whether to show the search field at the top of the list pane.
///
/// When true, a search field appears; [onSearch] is called on text changes.
/// Defaults to true.
final bool showSearch;
/// Creates a new [LayrzScaffoldShell<T>] with the given properties.
const LayrzScaffoldShell({
super.key,
required this.controller,
required this.items,
required this.onBuild,
required this.onDetailsBuild,
this.onSearch,
this.showSearch = true,
});
@override
State<LayrzScaffoldShell<T>> createState() => _LayrzScaffoldShellState<T>();
}A value notifier holding the currently selected item.
class LayrzScaffoldController<T> extends ChangeNotifier {
/// The currently selected item, or null if no item is selected.
T? get selectedItem => _selectedItem;
T? _selectedItem;
/// Set the selected item.
void setSelectedItem(T? item) {
if (_selectedItem != item) {
_selectedItem = item;
notifyListeners();
}
}
/// Clear the selection.
void clear() {
if (_selectedItem != null) {
_selectedItem = null;
notifyListeners();
}
}
}An abstract base class describing a list row.
abstract class LayrzScaffoldTile {
/// The primary text for this row, rendered as a [RichText].
RichText get titleRichText;
/// Secondary text for this row, rendered as a [RichText]. Nullable.
RichText? get subtitleRichText => null;
/// Optional action buttons or icons to display on the right side of the row.
/// List of [LayrzButton] or similar.
List<Widget> get actions => const [];
}A concrete implementation for simple cases.
class LayrzScaffoldValueTile extends LayrzScaffoldTile {
/// Primary text.
final String title;
/// Secondary text (optional).
final String? subtitle;
/// Action buttons (optional).
final List<Widget> actions;
/// Equality override for precise change detection.
@override
bool operator ==(Object other) => identical(this, other) ||
other is LayrzScaffoldValueTile &&
title == other.title &&
subtitle == other.subtitle &&
actions.length == other.actions.length;
@override
int get hashCode => Object.hash(title, subtitle, actions.length);
const LayrzScaffoldValueTile({
required this.title,
this.subtitle,
this.actions = const [],
});
@override
RichText get titleRichText => RichText(
text: TextSpan(text: title),
);
@override
RichText? get subtitleRichText => subtitle == null
? null
: RichText(text: TextSpan(text: subtitle));
}- List pane: Left side, fixed or proportional width (typically 30%)
- Detail pane: Right side, grows to fill remaining space
- Both panes visible simultaneously
- Selecting a row in the list updates the detail pane without navigation
- List pane always visible: The list is never hidden or replaced
- Detail in modal sheet: Opening an item presents the detail in a LayrzBottomSheet layered over the still-visible list, initially occupying half the screen
- Sheet defaults: 50% initialSize, snaps to 50% and 95%, maxSize 95%, drag handle on
-
Dismissing the sheet: Dragging the handle down past the 50% snap point, a barrier tap, Escape, or system back all call
controller.close(), de-highlighting the list row - Selection persistence: Selection survives a breakpoint crossing (narrow → wide pops the sheet but keeps the detail; wide → narrow auto-opens the sheet for the already-selected item)
-
Navigator requirement: The narrow band now pushes a route, so the shell requires a
Navigatorancestor (e.g. insideLayrzApp). A debug assert fires if missing; no release crash -
Detail sheet is presented on the root navigator (
useRootNavigator: true), not the nearest one. This matters under ago_routerShellRoute, which builds its own nestedNavigatorfor the routes it hosts. Presenting on that nested Navigator instead put the sheet'sOverlayentry inside the page's ownLayrzLayoutSelectableRegionand bounded byLayrzLayout's body rather than the full screen — with two consequences: a gesture on the sheet's own text could resolve against a list row on the page behind it instead of the sheet, and the sheet's barrier could not coverLayrzLayout's own chrome (e.g. the narrow-mode top bar), so a tap there reached the chrome's controls instead of being blocked. Pushing to the root navigator puts the sheet genuinely above the app's chrome and outside the page's selection scope, fixing both. -
Detail pane content is independently selectable.
DetailPanewraps its content in its ownSelectableRegion, scoped so a selection there cannot reach the page behind it — in both the side-by-side (expanded) layout and the narrow sheet presentation alike.
@override
Widget build(BuildContext context) {
return LayrzScaffoldShell<User>(
controller: _controller,
items: _users, // Filtered by consumer based on _searchQuery
onBuild: (user) => LayrzScaffoldValueTile(
title: user.name,
subtitle: user.email,
),
onDetailsBuild: (user) => Column(
children: [
Text(user.name, style: TextStyle(fontSize: 18)),
Text('Email: ${user.email}'),
Text('Phone: ${user.phone}'),
],
),
onSearch: (query) => setState(() => _searchQuery = query),
);
}class UserTile extends LayrzScaffoldTile {
final User user;
UserTile(this.user);
@override
RichText get titleRichText => RichText(
text: TextSpan(
children: [
TextSpan(text: user.name, style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: ' (${user.role})', style: TextStyle(color: Colors.grey)),
],
),
);
@override
RichText? get subtitleRichText => RichText(
text: TextSpan(text: user.email),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserTile &&
user.id == other.user.id;
@override
int get hashCode => user.id.hashCode;
}
// Usage:
onBuild: (user) => UserTile(user),@override
Widget build(BuildContext context) {
final filtered = _users.where(
(user) => user.name.toLowerCase().contains(_searchQuery.toLowerCase()),
).toList();
return LayrzScaffoldShell<User>(
controller: _controller,
items: filtered,
onBuild: (user) => LayrzScaffoldValueTile(
title: user.name,
subtitle: user.email,
),
onDetailsBuild: _buildUserDetail,
onSearch: (query) => setState(() => _searchQuery = query),
);
}- Keyboard navigation: Tab cycles through list rows; Enter selects; Arrow keys navigate
- Screen readers: Each row is announced with title and subtitle
- Back affordance (mobile): Clear and reachable; no gesture confusion
- No automatic grouping: Consumer provides a flat list; grouping is done externally
- No built-in sorting: Consumers sort before passing [items]
- No detail-pane tabs, docked inspector, or breadcrumbs: Consumer owns the entire detail area
-
Navigator requirement: Narrow band (sm/xs) requires a
Navigatorancestor; missing one fires a debug assert and renders only the list without detail capability -
No automatic state restoration: App backgrounding does not preserve sheet scroll or selection; consumer owns restoration via
StateNotifieror equivalent - Sheet is not customizable: Narrow band always uses LayrzBottomSheet defaults; no public parameters control sheet size, snaps, or drag handle
- Component Catalog (Maps Layrz scaffolds across all components)
- Input Contract (Search field styling)
- Theming (Token system and customization)
- LayrzBottomSheet (The narrow-band detail sheet's underlying surface)
- Repo: D37 — LayrzLayout and LayrzScaffoldShell Scope
- Repo: D8 — LayrzLayout Ships Exactly One Layout Design
Last updated: 2026-08-26
Status: Implementation underway (M5)
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider