Files
jpmschweitzerandClaude Opus 4.5 0c3239f4b0 docs: add Phase 0 documentation
- docs/ARCHITECTURE.md - Clean Architecture patterns and conventions
- docs/API_INTEGRATION.md - Core API and Tatlock API endpoints
- docs/DEPLOYMENT.md - Docker, NPM, Portainer setup
- docs/DATAGRID.md - DataGrid component specification
- docs/THEMING.md - Material 3 theming guide

Also update seed color to teal for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 16:26:44 +01:00

14 KiB

DataGrid Component System

This document specifies the reusable DataGrid component for consistent table UIs across all features.

Overview

The DataGrid system provides a declarative, type-safe way to display tabular data with:

  • Configurable columns with custom renderers
  • Row selection (single and multi-select)
  • Sorting and searching
  • Per-row and bulk actions
  • Pagination and infinite scroll
  • Integration with Core API

Inspired by: fframe ListGrid

Quick Start

DataGrid<Container>(
  config: DataGridConfig(
    columns: [
      DataGridColumn(
        header: 'Name',
        valueBuilder: (c) => c.name,
        sortable: true,
        sortField: 'name',
      ),
      DataGridColumn(
        header: 'Status',
        valueBuilder: (c) => c.status.name,
        cellBuilder: (context, c) => ContainerStatusBadge(status: c.status),
      ),
      DataGridColumn(
        header: 'Image',
        valueBuilder: (c) => c.image,
        width: DataGridColumnWidth.flex(2),
      ),
    ],
    actions: [
      DataGridAction(
        icon: Icons.play_arrow,
        label: 'Start',
        onTap: (c) => ref.read(containerActionsProvider).start(c.id),
        showWhen: (c) => c.status == ContainerStatus.stopped,
      ),
      DataGridAction(
        icon: Icons.stop,
        label: 'Stop',
        onTap: (c) => ref.read(containerActionsProvider).stop(c.id),
        showWhen: (c) => c.status == ContainerStatus.running,
        destructive: true,
        requiresConfirmation: true,
      ),
    ],
    rowsSelectable: true,
    enableSearch: true,
    onRowTap: (c) => context.push('/containers/${c.id}'),
  ),
  source: ContainersDataSource(ref.watch(apiClientProvider)),
)

Configuration Classes

DataGridConfig

Main configuration for a data grid instance.

class DataGridConfig<T> {
  const DataGridConfig({
    required this.columns,
    this.actions = const [],
    this.bulkActions = const [],
    this.rowsSelectable = false,
    this.showHeader = true,
    this.showFooter = true,
    this.enableSearch = false,
    this.searchableColumns = const [],
    this.defaultSortColumn,
    this.defaultSortDescending = false,
    this.emptyStateBuilder,
    this.loadingBuilder,
    this.errorBuilder,
    this.onRowTap,
    this.cellPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
    this.headerHeight = 48.0,
    this.rowHeight,
    this.dataMode = const DataGridDataMode.all(),
  });

  /// Column definitions
  final List<DataGridColumn<T>> columns;

  /// Per-row actions (shown in action menu)
  final List<DataGridAction<T>> actions;

  /// Bulk actions (shown when rows selected)
  final List<DataGridBulkAction<T>> bulkActions;

  /// Enable row selection checkboxes
  final bool rowsSelectable;

  /// Show header row with column names
  final bool showHeader;

  /// Show footer with row count/pagination
  final bool showFooter;

  /// Enable search bar
  final bool enableSearch;

  /// Column indices to search (empty = all searchable columns)
  final List<int> searchableColumns;

  /// Default sort column index
  final int? defaultSortColumn;

  /// Default sort direction
  final bool defaultSortDescending;

  /// Custom empty state widget
  final Widget Function(BuildContext)? emptyStateBuilder;

  /// Custom loading widget
  final Widget Function(BuildContext)? loadingBuilder;

  /// Custom error widget
  final Widget Function(BuildContext, Object error)? errorBuilder;

  /// Row tap callback
  final void Function(T item)? onRowTap;

  /// Cell padding
  final EdgeInsetsGeometry cellPadding;

  /// Header row height
  final double headerHeight;

  /// Data row height (null = intrinsic)
  final double? rowHeight;

  /// Data loading mode
  final DataGridDataMode dataMode;
}

DataGridColumn

Column definition with rendering options.

class DataGridColumn<T> {
  const DataGridColumn({
    required this.header,
    required this.valueBuilder,
    this.cellBuilder,
    this.cellControlsBuilder,
    this.width = const DataGridColumnWidth.flex(1),
    this.alignment = DataGridColumnAlignment.start,
    this.sortable = false,
    this.sortField,
    this.searchable = false,
    this.visible = true,
    this.tooltip,
  });

  /// Column header text
  final String header;

  /// Extract string value from item (for sorting/searching)
  final String Function(T item) valueBuilder;

  /// Custom cell widget builder (overrides default text)
  final Widget Function(BuildContext context, T item)? cellBuilder;

  /// Additional controls to show on hover/focus
  final Widget Function(BuildContext context, T item)? cellControlsBuilder;

  /// Column width specification
  final DataGridColumnWidth width;

  /// Cell content alignment
  final DataGridColumnAlignment alignment;

  /// Enable sorting on this column
  final bool sortable;

  /// API field name for sorting (defaults to column index)
  final String? sortField;

  /// Include in search
  final bool searchable;

  /// Column visibility
  final bool visible;

  /// Tooltip builder for cell
  final String Function(T item)? tooltip;
}

DataGridColumnWidth

Column width specification using sealed classes.

sealed class DataGridColumnWidth {
  const DataGridColumnWidth._();

  /// Fixed pixel width
  const factory DataGridColumnWidth.fixed(double width) = _FixedWidth;

  /// Flex factor (like Expanded)
  const factory DataGridColumnWidth.flex(int flex) = _FlexWidth;

  /// Fraction of available width (0.0 - 1.0)
  const factory DataGridColumnWidth.fraction(double fraction) = _FractionWidth;
}

// Usage:
DataGridColumnWidth.fixed(100)    // Always 100px
DataGridColumnWidth.flex(2)       // 2x flex factor
DataGridColumnWidth.fraction(0.3) // 30% of available width

DataGridColumnAlignment

enum DataGridColumnAlignment {
  start,
  center,
  end,
}

DataGridAction

Per-row action definition.

class DataGridAction<T> {
  const DataGridAction({
    required this.icon,
    required this.label,
    required this.onTap,
    this.showWhen,
    this.destructive = false,
    this.requiresConfirmation = false,
    this.confirmationMessage,
  });

  /// Action icon
  final IconData icon;

  /// Action label (shown in menu)
  final String label;

  /// Action callback
  final Future<void> Function(T item) onTap;

  /// Conditional visibility
  final bool Function(T item)? showWhen;

  /// Show in red (destructive action)
  final bool destructive;

  /// Show confirmation dialog before executing
  final bool requiresConfirmation;

  /// Custom confirmation message
  final String? confirmationMessage;
}

DataGridBulkAction

Action on multiple selected rows.

class DataGridBulkAction<T> {
  const DataGridBulkAction({
    required this.icon,
    required this.label,
    required this.onTap,
    this.minSelected = 1,
    this.maxSelected,
    this.destructive = false,
    this.requiresConfirmation = false,
  });

  /// Action icon
  final IconData icon;

  /// Action label
  final String label;

  /// Action callback with selected items
  final Future<void> Function(List<T> items) onTap;

  /// Minimum items required
  final int minSelected;

  /// Maximum items allowed (null = unlimited)
  final int? maxSelected;

  /// Show in red
  final bool destructive;

  /// Show confirmation dialog
  final bool requiresConfirmation;
}

DataGridDataMode

Data loading strategy.

sealed class DataGridDataMode {
  const DataGridDataMode._();

  /// Load all data at once
  const factory DataGridDataMode.all() = _AllDataMode;

  /// Traditional pagination
  const factory DataGridDataMode.paginated({
    int pageSize,
  }) = _PaginatedDataMode;

  /// Infinite scroll
  const factory DataGridDataMode.infinite({
    int initialLoad,
    int loadMoreThreshold,
  }) = _InfiniteDataMode;
}

Data Source

DataGridSource Interface

abstract class DataGridSource<T> {
  /// Fetch data with optional filtering/sorting
  Future<DataGridResult<T>> fetch({
    String? searchQuery,
    String? sortField,
    bool sortDescending = false,
    int? offset,
    int? limit,
  });

  /// Get total count (for pagination)
  Future<int> count({String? searchQuery});
}

DataGridResult

class DataGridResult<T> {
  const DataGridResult({
    required this.items,
    required this.totalCount,
    this.hasMore = false,
  });

  final List<T> items;
  final int totalCount;
  final bool hasMore;
}

CoreApiDataSource

Pre-built adapter for Core API endpoints.

class CoreApiDataSource<T> extends DataGridSource<T> {
  CoreApiDataSource({
    required this.client,
    required this.endpoint,
    required this.fromJson,
    this.searchParam = 'search',
    this.sortParam = 'sort',
    this.orderParam = 'order',
    this.offsetParam = 'offset',
    this.limitParam = 'limit',
  });

  final ApiClient client;
  final String endpoint;
  final T Function(Map<String, dynamic>) fromJson;
  final String searchParam;
  final String sortParam;
  final String orderParam;
  final String offsetParam;
  final String limitParam;

  @override
  Future<DataGridResult<T>> fetch({
    String? searchQuery,
    String? sortField,
    bool sortDescending = false,
    int? offset,
    int? limit,
  }) async {
    final params = <String, dynamic>{};

    if (searchQuery != null && searchQuery.isNotEmpty) {
      params[searchParam] = searchQuery;
    }
    if (sortField != null) {
      params[sortParam] = sortField;
      params[orderParam] = sortDescending ? 'desc' : 'asc';
    }
    if (offset != null) params[offsetParam] = offset;
    if (limit != null) params[limitParam] = limit;

    final response = await client.get(endpoint, queryParameters: params);
    final items = (response.data as List).map((j) => fromJson(j)).toList();

    return DataGridResult(
      items: items,
      totalCount: items.length, // Or from response headers
      hasMore: items.length == limit,
    );
  }
}

State Management

DataGridNotifier

Riverpod notifier for grid state.

@riverpod
class DataGridNotifier<T> extends _$DataGridNotifier<T> {
  @override
  DataGridState<T> build(DataGridSource<T> source, DataGridConfig<T> config) {
    _loadData();
    return DataGridState.loading();
  }

  Future<void> _loadData() async {
    state = DataGridState.loading();
    try {
      final result = await source.fetch(
        searchQuery: _searchQuery,
        sortField: _sortField,
        sortDescending: _sortDescending,
      );
      state = DataGridState.loaded(
        items: result.items,
        totalCount: result.totalCount,
      );
    } catch (e) {
      state = DataGridState.error(e);
    }
  }

  void search(String query) { ... }
  void sort(String field, bool descending) { ... }
  void selectRow(T item) { ... }
  void selectAll() { ... }
  void clearSelection() { ... }
  Future<void> refresh() { ... }
}

DataGridState

@freezed
class DataGridState<T> with _$DataGridState<T> {
  const factory DataGridState.loading() = _Loading;

  const factory DataGridState.loaded({
    required List<T> items,
    required int totalCount,
    @Default({}) Set<T> selectedItems,
    String? searchQuery,
    String? sortField,
    @Default(false) bool sortDescending,
  }) = _Loaded;

  const factory DataGridState.error(Object error) = _Error;
}

Widget Structure

lib/shared/components/data_grid/
├── data_grid.dart                    # Main widget
├── data_grid_config.dart             # Configuration classes
├── data_grid_column.dart             # Column definition
├── data_grid_action.dart             # Action definitions
├── data_grid_source.dart             # Data source interface
├── data_grid_provider.dart           # Riverpod state
├── data_grid_state.dart              # Freezed state class
├── widgets/
│   ├── data_grid_header.dart         # Header row with sort indicators
│   ├── data_grid_row.dart            # Data row
│   ├── data_grid_cell.dart           # Cell wrapper
│   ├── data_grid_checkbox.dart       # Selection checkbox
│   ├── data_grid_actions_menu.dart   # Row actions popup
│   ├── data_grid_bulk_actions.dart   # Bulk action bar
│   ├── data_grid_search_bar.dart     # Search input
│   ├── data_grid_footer.dart         # Footer with count/pagination
│   └── data_grid_empty_state.dart    # Empty state display
└── adapters/
    └── core_api_source.dart          # Core API adapter

Usage Examples

Basic Table

DataGrid<User>(
  config: DataGridConfig(
    columns: [
      DataGridColumn(header: 'Name', valueBuilder: (u) => u.name),
      DataGridColumn(header: 'Email', valueBuilder: (u) => u.email),
    ],
  ),
  source: UsersDataSource(),
)

With Actions and Selection

DataGrid<Container>(
  config: DataGridConfig(
    columns: [...],
    rowsSelectable: true,
    actions: [
      DataGridAction(
        icon: Icons.restart_alt,
        label: 'Restart',
        onTap: (c) async => await restartContainer(c.id),
        requiresConfirmation: true,
      ),
    ],
    bulkActions: [
      DataGridBulkAction(
        icon: Icons.delete,
        label: 'Delete Selected',
        onTap: (items) async => await deleteContainers(items),
        destructive: true,
        requiresConfirmation: true,
      ),
    ],
  ),
  source: ContainersDataSource(),
)

With Custom Cell Rendering

DataGridColumn<Device>(
  header: 'State',
  valueBuilder: (d) => d.state,
  cellBuilder: (context, device) => Switch(
    value: device.state == 'on',
    onChanged: (v) => toggleDevice(device.entityId, v),
  ),
)

With Pagination

DataGrid<LogEntry>(
  config: DataGridConfig(
    columns: [...],
    dataMode: DataGridDataMode.paginated(pageSize: 50),
    showFooter: true,
  ),
  source: LogsDataSource(),
)