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,266 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'data_grid_config.dart';
|
||||
import 'data_grid_source.dart';
|
||||
import 'data_grid_state.dart';
|
||||
|
||||
/// Controller for a DataGrid instance.
|
||||
///
|
||||
/// Manages loading, searching, sorting, and selection state.
|
||||
class DataGridController<T> extends StateNotifier<DataGridState<T>> {
|
||||
DataGridController({
|
||||
required this.source,
|
||||
required this.config,
|
||||
required this.idSelector,
|
||||
}) : super(DataGridState<T>(
|
||||
sortColumnIndex: config.defaultSortColumn,
|
||||
sortDescending: config.defaultSortDescending,
|
||||
)) {
|
||||
// Initial load
|
||||
_load();
|
||||
}
|
||||
|
||||
/// Data source for fetching items.
|
||||
final DataGridSource<T> source;
|
||||
|
||||
/// Grid configuration.
|
||||
final DataGridConfig<T> config;
|
||||
|
||||
/// Function to extract unique ID from an item.
|
||||
final Object Function(T item) idSelector;
|
||||
|
||||
/// Debounce timer for search.
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Loads or reloads data from the source.
|
||||
Future<void> _load({bool refresh = false}) async {
|
||||
if (refresh) {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
} else {
|
||||
state = state.copyWith(isLoading: true, isInitialLoad: true, error: null);
|
||||
}
|
||||
|
||||
try {
|
||||
final sortField = state.sortColumnIndex != null
|
||||
? config.columns[state.sortColumnIndex!].effectiveSortField
|
||||
: null;
|
||||
|
||||
final (offset, limit) = _getPaginationParams();
|
||||
|
||||
final result = await source.fetch(
|
||||
searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery,
|
||||
sortField: sortField,
|
||||
sortDescending: state.sortDescending,
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
items: result.items,
|
||||
totalCount: result.totalCount,
|
||||
hasMore: result.hasMore,
|
||||
isLoading: false,
|
||||
isInitialLoad: false,
|
||||
error: null,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
debugPrint('DataGrid load error: $e\n$stack');
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isInitialLoad: false,
|
||||
error: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets pagination parameters based on data mode.
|
||||
(int?, int?) _getPaginationParams() {
|
||||
return switch (config.dataMode) {
|
||||
AllDataMode() => (null, null),
|
||||
PaginatedDataMode(:final pageSize) => (
|
||||
state.currentPage * pageSize,
|
||||
pageSize,
|
||||
),
|
||||
InfiniteDataMode(:final initialLoad) => (0, initialLoad),
|
||||
};
|
||||
}
|
||||
|
||||
/// Refreshes the grid data.
|
||||
Future<void> refresh() => _load(refresh: true);
|
||||
|
||||
/// Sets the search query with debouncing.
|
||||
void search(String query) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (state.searchQuery != query) {
|
||||
state = state.copyWith(searchQuery: query, currentPage: 0);
|
||||
_load(refresh: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clears the search query.
|
||||
void clearSearch() {
|
||||
_searchDebounce?.cancel();
|
||||
if (state.searchQuery.isNotEmpty) {
|
||||
state = state.copyWith(searchQuery: '', currentPage: 0);
|
||||
_load(refresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts by the given column index.
|
||||
void sortBy(int columnIndex) {
|
||||
final column = config.columns[columnIndex];
|
||||
if (!column.sortable) return;
|
||||
|
||||
final newDescending =
|
||||
state.sortColumnIndex == columnIndex ? !state.sortDescending : false;
|
||||
|
||||
state = state.copyWith(
|
||||
sortColumnIndex: columnIndex,
|
||||
sortDescending: newDescending,
|
||||
currentPage: 0,
|
||||
);
|
||||
_load(refresh: true);
|
||||
}
|
||||
|
||||
/// Clears sorting.
|
||||
void clearSort() {
|
||||
state = state.copyWith(
|
||||
sortColumnIndex: null,
|
||||
sortDescending: false,
|
||||
currentPage: 0,
|
||||
);
|
||||
_load(refresh: true);
|
||||
}
|
||||
|
||||
/// Toggles selection of an item.
|
||||
void toggleSelection(T item) {
|
||||
if (!config.rowsSelectable) return;
|
||||
|
||||
final id = idSelector(item);
|
||||
final newSelection = Set<Object>.from(state.selectedIds);
|
||||
|
||||
if (newSelection.contains(id)) {
|
||||
newSelection.remove(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
|
||||
state = state.copyWith(selectedIds: newSelection);
|
||||
}
|
||||
|
||||
/// Selects all visible items.
|
||||
void selectAll() {
|
||||
if (!config.rowsSelectable) return;
|
||||
|
||||
final allIds = state.items.map(idSelector).toSet();
|
||||
state = state.copyWith(selectedIds: allIds);
|
||||
}
|
||||
|
||||
/// Clears all selections.
|
||||
void clearSelection() {
|
||||
state = state.copyWith(selectedIds: {});
|
||||
}
|
||||
|
||||
/// Toggles select all / clear all.
|
||||
void toggleSelectAll() {
|
||||
if (state.allSelected) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if an item is selected.
|
||||
bool isSelected(T item) => state.selectedIds.contains(idSelector(item));
|
||||
|
||||
/// Gets the selected items.
|
||||
List<T> getSelectedItems() {
|
||||
return state.items.where((item) => isSelected(item)).toList();
|
||||
}
|
||||
|
||||
/// Goes to a specific page (for paginated mode).
|
||||
void goToPage(int page) {
|
||||
if (config.dataMode is! PaginatedDataMode) return;
|
||||
|
||||
final mode = config.dataMode as PaginatedDataMode;
|
||||
final maxPage = (state.totalCount / mode.pageSize).ceil() - 1;
|
||||
|
||||
if (page < 0 || page > maxPage) return;
|
||||
|
||||
state = state.copyWith(currentPage: page);
|
||||
_load(refresh: true);
|
||||
}
|
||||
|
||||
/// Goes to the next page.
|
||||
void nextPage() => goToPage(state.currentPage + 1);
|
||||
|
||||
/// Goes to the previous page.
|
||||
void previousPage() => goToPage(state.currentPage - 1);
|
||||
|
||||
/// Loads more items (for infinite scroll mode).
|
||||
Future<void> loadMore() async {
|
||||
if (config.dataMode is! InfiniteDataMode) return;
|
||||
if (state.isLoading || !state.hasMore) return;
|
||||
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final sortField = state.sortColumnIndex != null
|
||||
? config.columns[state.sortColumnIndex!].effectiveSortField
|
||||
: null;
|
||||
|
||||
final result = await source.fetch(
|
||||
searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery,
|
||||
sortField: sortField,
|
||||
sortDescending: state.sortDescending,
|
||||
offset: state.items.length,
|
||||
limit: (config.dataMode as InfiniteDataMode).initialLoad,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...result.items],
|
||||
totalCount: result.totalCount,
|
||||
hasMore: result.hasMore,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a DataGridController provider for a specific grid.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final containersGridProvider = dataGridProvider<Container>(
|
||||
/// source: containersDataSource,
|
||||
/// config: containersGridConfig,
|
||||
/// idSelector: (c) => c.id,
|
||||
/// );
|
||||
/// ```
|
||||
StateNotifierProvider<DataGridController<T>, DataGridState<T>>
|
||||
dataGridProvider<T>({
|
||||
required DataGridSource<T> source,
|
||||
required DataGridConfig<T> config,
|
||||
required Object Function(T) idSelector,
|
||||
}) {
|
||||
return StateNotifierProvider<DataGridController<T>, DataGridState<T>>(
|
||||
(ref) => DataGridController<T>(
|
||||
source: source,
|
||||
config: config,
|
||||
idSelector: idSelector,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user