- 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>
14 KiB
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(),
)