feat: add Control Room with containers and stacks management
- Add Control Room page with stacks sidebar and containers list - Implement container actions (start/stop/restart) with snackbars - Add container logs viewer dialog - Add search/filter for both stacks and containers lists - Add external links for Portainer and Netdata (url_launcher) - Add VS Code launch configuration for Flutter web debugging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
dd6bcbdbda
commit
3b89ed8c18
@@ -0,0 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'data_grid_config.dart';
|
||||
import 'data_grid_provider.dart';
|
||||
import 'data_grid_source.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,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user