Skip to content

LayrzScaffoldShell

Kenny Mochizuki Escalona edited this page Aug 25, 2026 · 3 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

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-23
Status: Implementation underway (M5)

Clone this wiki locally