Build and Push / build (release) Successful in 3m6s
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>
226 lines
6.7 KiB
Dart
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),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|