Skip to content

LayrzScaffoldShell

Kenny Mochizuki Escalona edited this page Aug 27, 2026 · 5 revisions

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).


Overview

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.

Design Principles

  • Generic over item type T: Lists and detail views work with any domain object.
  • Container-driven presentations: Breakpoints resolve via LayoutBuilder constraints, 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: LayrzScaffoldTile is an abstract base class with titleRichText, subtitleRichText, and actions getters. Consumers subclass it or use the provided LayrzScaffoldValueTile for 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.

API Structure

Core Constructor

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>();
}

LayrzScaffoldController

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();
    }
  }
}

LayrzScaffoldTile Abstraction

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 [];
}

LayrzScaffoldValueTile

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));
}

Presentations

Expanded (md/lg/xl)

  • 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

Narrow (sm/xs)

  • 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 Navigator ancestor (e.g. inside LayrzApp). 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 a go_router ShellRoute, which builds its own nested Navigator for the routes it hosts. Presenting on that nested Navigator instead put the sheet's Overlay entry inside the page's own LayrzLayout SelectableRegion and bounded by LayrzLayout'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 cover LayrzLayout'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. DetailPane wraps its content in its own SelectableRegion, 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.

Usage Examples

Basic List-Detail

@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),
  );
}

Custom Tile with Rich Text

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),

With Consumer-Owned Filtering

@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),
  );
}

Accessibility

  • 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

Constraints and Limitations

  • 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 Navigator ancestor; 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 StateNotifier or equivalent
  • Sheet is not customizable: Narrow band always uses LayrzBottomSheet defaults; no public parameters control sheet size, snaps, or drag handle

Related


Last updated: 2026-08-26
Status: Implementation underway (M5)

Clone this wiki locally