Files
tatlock-ui/lib/shared/components/data_grid/data_grid.dart
T
Jeroen SchweitzerandClaude Opus 4.5 bdced8739c
Build and Push / build (release) Successful in 3m6s
chore: release v0.3.3
Features:
- Health check endpoint for Portainer monitoring
- Local search filtering in DataGrid
- Container status badges reflect health (green/orange/blue)

Improvements:
- Standardized 56px header heights across panels
- Container grid parses Docker API format correctly
- Search bar styling improvements
- Status badges have consistent width

Fixes:
- Quick links persistence (link type, form refresh)
- Iframe switching closes existing content first
- ContainerState type conflict resolved

Branding:
- Updated favicon and icons with Tatlock bucket logo

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:12:18 +01:00

226 lines
6.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'data_grid_config.dart';
import 'data_grid_provider.dart';
import 'data_grid_state.dart';
import 'widgets/data_grid_bulk_actions.dart';
import 'widgets/data_grid_empty_state.dart';
import 'widgets/data_grid_footer.dart';
import 'widgets/data_grid_header.dart';
import 'widgets/data_grid_row.dart';
import 'widgets/data_grid_search_bar.dart';
/// A configurable data grid widget for displaying tabular data.
///
/// Features:
/// - Sortable columns
/// - Row selection (single and bulk)
/// - Per-row and bulk actions
/// - Search/filtering
/// - Pagination or infinite scroll
/// - Custom cell rendering
/// - Empty, loading, and error states
///
/// Usage:
/// ```dart
/// DataGrid<Container>(
/// provider: containersGridProvider,
/// config: containersGridConfig,
/// idSelector: (c) => c.id,
/// )
/// ```
class DataGrid<T> extends ConsumerWidget {
const DataGrid({
super.key,
required this.provider,
required this.config,
required this.idSelector,
this.toolbarActions,
});
/// The Riverpod provider for this grid's state.
final StateNotifierProvider<DataGridController<T>, DataGridState<T>> provider;
/// Grid configuration.
final DataGridConfig<T> config;
/// Function to extract unique ID from an item.
final Object Function(T item) idSelector;
/// Additional actions to show in the toolbar (next to search).
final List<Widget>? toolbarActions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(provider);
final controller = ref.read(provider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Toolbar: search + bulk actions + custom actions
if (config.enableSearch ||
(state.selectedCount > 0 && config.bulkActions.isNotEmpty) ||
toolbarActions != null)
_buildToolbar(context, state, controller),
// Header
if (config.showHeader)
DataGridHeader<T>(
config: config,
sortColumnIndex: state.sortColumnIndex,
sortDescending: state.sortDescending,
onSort: controller.sortBy,
showCheckbox: config.rowsSelectable,
allSelected: state.allSelected,
someSelected: state.someSelected,
onSelectAll: controller.toggleSelectAll,
),
// Content area
Expanded(
child: _buildContent(context, state, controller),
),
// Footer
if (config.showFooter)
DataGridFooter(
totalCount: state.totalCount,
displayedCount: state.items.length,
dataMode: config.dataMode,
currentPage: state.currentPage,
onPageChange: controller.goToPage,
isLoading: state.isLoading,
),
],
);
}
Widget _buildToolbar(
BuildContext context,
DataGridState<T> state,
DataGridController<T> controller,
) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (config.enableSearch)
DataGridSearchBar(
onSearch: controller.search,
onClear: controller.clearSearch,
hintText: config.searchHint,
initialValue: state.searchQuery,
),
if (state.selectedCount > 0 && config.bulkActions.isNotEmpty) ...[
const SizedBox(width: 16),
Expanded(
child: DataGridBulkActions<T>(
selectedCount: state.selectedCount,
bulkActions: config.bulkActions,
onClearSelection: controller.clearSelection,
getSelectedItems: controller.getSelectedItems,
),
),
] else ...[
const Spacer(),
],
if (toolbarActions != null) ...toolbarActions!,
],
),
);
}
Widget _buildContent(
BuildContext context,
DataGridState<T> state,
DataGridController<T> controller,
) {
// Initial loading state
if (state.isInitialLoad && state.isLoading) {
return config.loadingBuilder?.call(context) ??
const DataGridLoadingState();
}
// Error state
if (state.hasError && state.items.isEmpty) {
return config.errorBuilder?.call(context, state.error!, controller.refresh) ??
DataGridErrorState(
error: state.error!,
onRetry: controller.refresh,
);
}
// Empty state
if (state.isEmpty) {
return config.emptyStateBuilder?.call(context) ??
DataGridEmptyState(
title: state.searchQuery.isNotEmpty
? 'No results found'
: 'No items found',
subtitle: state.searchQuery.isNotEmpty
? 'Try a different search term'
: null,
);
}
// Data rows
return _buildListView(state, controller);
}
Widget _buildListView(
DataGridState<T> state,
DataGridController<T> controller,
) {
final isInfiniteScroll = config.dataMode is InfiniteDataMode;
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (isInfiniteScroll &&
notification is ScrollEndNotification &&
notification.metrics.extentAfter < 200) {
controller.loadMore();
}
return false;
},
child: ListView.builder(
itemCount: state.items.length + (state.isLoading && !state.isInitialLoad ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at bottom for infinite scroll
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = state.items[index];
final isSelected = controller.isSelected(item);
return DataGridRow<T>(
item: item,
index: index,
config: config,
isSelected: isSelected,
onSelect: () => controller.toggleSelection(item),
onTap: config.onRowTap != null ? () => config.onRowTap!(item) : null,
backgroundColor: config.rowColor?.call(context, item, index),
);
},
),
);
}
}