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>
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
# 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](https://github.com/postmeridiem/fframe/tree/main/fframe/lib/screens/listgrid_screen)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```dart
|
||||
enum DataGridColumnAlignment {
|
||||
start,
|
||||
center,
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
### DataGridAction
|
||||
|
||||
Per-row action definition.
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
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.
|
||||
|
||||
```dart
|
||||
@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
|
||||
|
||||
```dart
|
||||
@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
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```dart
|
||||
DataGridColumn<Device>(
|
||||
header: 'State',
|
||||
valueBuilder: (d) => d.state,
|
||||
cellBuilder: (context, device) => Switch(
|
||||
value: device.state == 'on',
|
||||
onChanged: (v) => toggleDevice(device.entityId, v),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### With Pagination
|
||||
|
||||
```dart
|
||||
DataGrid<LogEntry>(
|
||||
config: DataGridConfig(
|
||||
columns: [...],
|
||||
dataMode: DataGridDataMode.paginated(pageSize: 50),
|
||||
showFooter: true,
|
||||
),
|
||||
source: LogsDataSource(),
|
||||
)
|
||||
```
|
||||
Reference in New Issue
Block a user