-
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
LayrzBottomSheetlayered 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
@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
LayrzBottomSheetdefaults; 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)
- Repo: D37 — LayrzLayout and LayrzScaffoldShell Scope
- Repo: D8 — LayrzLayout Ships Exactly One Layout Design
Last updated: 2026-08-23
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