- Add PageUrlState utility for URL ↔ state serialization - Add column `id` field for unique column identification in URLs - Update idSelector to return String for URL compatibility - All DataGrid pages now support URL params: search, sort, order, id - Browser URL updates via replaceState (no GoRouter rebuilds) - Add FilterPanelSemantics for filter panel semantic IDs - Add TESTING.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
333 lines
9.1 KiB
Dart
333 lines
9.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/legacy.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,
|
|
String? initialSearch,
|
|
int? initialSortColumnIndex,
|
|
bool? initialSortDescending,
|
|
}) : super(DataGridState<T>(
|
|
searchQuery: initialSearch ?? '',
|
|
sortColumnIndex:
|
|
initialSortColumnIndex ?? config.defaultSortColumn,
|
|
sortDescending:
|
|
initialSortDescending ?? 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 (used for selection and URL deep-linking).
|
|
///
|
|
/// Returns a unique string identifier for each row. Can be:
|
|
/// - A simple ID field: `(item) => item.id.toString()`
|
|
/// - A combination of fields: `(item) => '${item.name}_${item.type}'`
|
|
/// - Any unique identifier suitable for URLs
|
|
final String Function(T item) idSelector;
|
|
|
|
/// Debounce timer for search.
|
|
Timer? _searchDebounce;
|
|
|
|
/// All items before local filtering (for local search).
|
|
List<T> _allItems = [];
|
|
|
|
@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(
|
|
sortField: sortField,
|
|
sortDescending: state.sortDescending,
|
|
offset: offset,
|
|
limit: limit,
|
|
);
|
|
|
|
// Store all items for local filtering
|
|
_allItems = result.items;
|
|
|
|
// Apply local filter if search query exists
|
|
final filteredItems = _applyLocalFilter(_allItems);
|
|
|
|
state = state.copyWith(
|
|
items: filteredItems,
|
|
totalCount: filteredItems.length,
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Applies local filtering based on search query.
|
|
List<T> _applyLocalFilter(List<T> items) {
|
|
if (state.searchQuery.isEmpty) return items;
|
|
|
|
final query = state.searchQuery.toLowerCase();
|
|
|
|
return items.where((item) {
|
|
// Check all searchable columns
|
|
for (final column in config.columns) {
|
|
if (column.searchable) {
|
|
final value = column.valueBuilder(item);
|
|
if (value.toLowerCase().contains(query)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}).toList();
|
|
}
|
|
|
|
/// 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 (filters locally).
|
|
void search(String query) {
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(const Duration(milliseconds: 150), () {
|
|
if (state.searchQuery != query) {
|
|
state = state.copyWith(searchQuery: query, currentPage: 0);
|
|
_applyFilterAndUpdateState();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Clears the search query.
|
|
void clearSearch() {
|
|
_searchDebounce?.cancel();
|
|
if (state.searchQuery.isNotEmpty) {
|
|
state = state.copyWith(searchQuery: '', currentPage: 0);
|
|
_applyFilterAndUpdateState();
|
|
}
|
|
}
|
|
|
|
/// Applies local filter and updates state with filtered items.
|
|
void _applyFilterAndUpdateState() {
|
|
final filteredItems = _applyLocalFilter(_allItems);
|
|
state = state.copyWith(
|
|
items: filteredItems,
|
|
totalCount: filteredItems.length,
|
|
);
|
|
}
|
|
|
|
/// 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<String>.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,
|
|
/// );
|
|
/// ```
|
|
///
|
|
/// For URL deep-linking, pass initial state from URL params:
|
|
/// ```dart
|
|
/// final gridProvider = dataGridProvider<Container>(
|
|
/// source: source,
|
|
/// config: config,
|
|
/// idSelector: (c) => c.id,
|
|
/// initialSearch: urlState.search,
|
|
/// initialSortColumnIndex: _columnIndexForId(urlState.sortColumn),
|
|
/// initialSortDescending: urlState.sortDescending,
|
|
/// );
|
|
/// ```
|
|
StateNotifierProvider<DataGridController<T>, DataGridState<T>>
|
|
dataGridProvider<T>({
|
|
required DataGridSource<T> source,
|
|
required DataGridConfig<T> config,
|
|
required String Function(T) idSelector,
|
|
String? initialSearch,
|
|
int? initialSortColumnIndex,
|
|
bool? initialSortDescending,
|
|
}) {
|
|
return StateNotifierProvider<DataGridController<T>, DataGridState<T>>(
|
|
(ref) => DataGridController<T>(
|
|
source: source,
|
|
config: config,
|
|
idSelector: idSelector,
|
|
initialSearch: initialSearch,
|
|
initialSortColumnIndex: initialSortColumnIndex,
|
|
initialSortDescending: initialSortDescending,
|
|
),
|
|
);
|
|
}
|