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,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data_grid_action.dart';
|
||||
|
||||
/// Actions menu for a DataGrid row.
|
||||
class DataGridActionsMenu<T> extends StatelessWidget {
|
||||
const DataGridActionsMenu({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
final T item;
|
||||
final List<DataGridAction<T>> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visibleActions = actions.where((a) => a.shouldShow(item)).toList();
|
||||
|
||||
if (visibleActions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return PopupMenuButton<DataGridAction<T>>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Actions',
|
||||
onSelected: (action) => _handleAction(context, action),
|
||||
itemBuilder: (context) => visibleActions.map((action) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return PopupMenuItem<DataGridAction<T>>(
|
||||
value: action,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
action.icon,
|
||||
size: 20,
|
||||
color: action.destructive ? colorScheme.error : null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
action.label,
|
||||
style: TextStyle(
|
||||
color: action.destructive ? colorScheme.error : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAction(
|
||||
BuildContext context,
|
||||
DataGridAction<T> action,
|
||||
) async {
|
||||
if (action.requiresConfirmation) {
|
||||
final confirmed = await _showConfirmationDialog(context, action);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
await action.onTap(item);
|
||||
}
|
||||
|
||||
Future<bool> _showConfirmationDialog(
|
||||
BuildContext context,
|
||||
DataGridAction<T> action,
|
||||
) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(action.label),
|
||||
content: Text(
|
||||
action.confirmationMessage ?? 'Are you sure you want to proceed?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: action.destructive
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
)
|
||||
: null,
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data_grid_action.dart';
|
||||
|
||||
/// Bulk actions bar shown when items are selected.
|
||||
class DataGridBulkActions<T> extends StatelessWidget {
|
||||
const DataGridBulkActions({
|
||||
super.key,
|
||||
required this.selectedCount,
|
||||
required this.bulkActions,
|
||||
required this.onClearSelection,
|
||||
required this.getSelectedItems,
|
||||
});
|
||||
|
||||
final int selectedCount;
|
||||
final List<DataGridBulkAction<T>> bulkActions;
|
||||
final VoidCallback onClearSelection;
|
||||
final List<T> Function() getSelectedItems;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
...bulkActions.where((a) => a.isAvailable(selectedCount)).map(
|
||||
(action) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _BulkActionButton(
|
||||
action: action,
|
||||
onPressed: () => _handleAction(context, action),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClearSelection,
|
||||
tooltip: 'Clear selection',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAction(
|
||||
BuildContext context,
|
||||
DataGridBulkAction<T> action,
|
||||
) async {
|
||||
if (action.requiresConfirmation) {
|
||||
final confirmed = await _showConfirmationDialog(context, action);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
final items = getSelectedItems();
|
||||
await action.onTap(items);
|
||||
}
|
||||
|
||||
Future<bool> _showConfirmationDialog(
|
||||
BuildContext context,
|
||||
DataGridBulkAction<T> action,
|
||||
) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(action.label),
|
||||
content: Text(
|
||||
action.confirmationMessage ??
|
||||
'Are you sure you want to ${action.label.toLowerCase()} $selectedCount items?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: action.destructive
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
)
|
||||
: null,
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
}
|
||||
|
||||
class _BulkActionButton<T> extends StatelessWidget {
|
||||
const _BulkActionButton({
|
||||
required this.action,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final DataGridBulkAction<T> action;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (action.destructive) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(action.icon, size: 18),
|
||||
label: Text(action.label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
side: BorderSide(color: colorScheme.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(action.icon, size: 18),
|
||||
label: Text(action.label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onPrimaryContainer,
|
||||
side: BorderSide(color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Default empty state for DataGrid.
|
||||
class DataGridEmptyState extends StatelessWidget {
|
||||
const DataGridEmptyState({
|
||||
super.key,
|
||||
this.icon = Icons.inbox_outlined,
|
||||
this.title = 'No items found',
|
||||
this.subtitle,
|
||||
this.action,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? action;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null && onAction != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.tonal(
|
||||
onPressed: onAction,
|
||||
child: Text(action!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading state for DataGrid.
|
||||
class DataGridLoadingState extends StatelessWidget {
|
||||
const DataGridLoadingState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Error state for DataGrid.
|
||||
class DataGridErrorState extends StatelessWidget {
|
||||
const DataGridErrorState({
|
||||
super.key,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load data',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data_grid_config.dart';
|
||||
|
||||
/// Footer for DataGrid with item count and pagination controls.
|
||||
class DataGridFooter extends StatelessWidget {
|
||||
const DataGridFooter({
|
||||
super.key,
|
||||
required this.totalCount,
|
||||
required this.displayedCount,
|
||||
required this.dataMode,
|
||||
this.currentPage = 0,
|
||||
this.onPageChange,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
final int totalCount;
|
||||
final int displayedCount;
|
||||
final DataGridDataMode dataMode;
|
||||
final int currentPage;
|
||||
final void Function(int page)? onPageChange;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_getCountText(),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (dataMode is PaginatedDataMode) _buildPaginationControls(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getCountText() {
|
||||
return switch (dataMode) {
|
||||
AllDataMode() => '$totalCount items',
|
||||
PaginatedDataMode(:final pageSize) => _getPaginatedCountText(pageSize),
|
||||
InfiniteDataMode() => '$displayedCount of $totalCount items',
|
||||
};
|
||||
}
|
||||
|
||||
String _getPaginatedCountText(int pageSize) {
|
||||
final start = currentPage * pageSize + 1;
|
||||
final end = (start + displayedCount - 1).clamp(start, totalCount);
|
||||
return '$start-$end of $totalCount items';
|
||||
}
|
||||
|
||||
Widget _buildPaginationControls(BuildContext context) {
|
||||
final mode = dataMode as PaginatedDataMode;
|
||||
final totalPages = (totalCount / mode.pageSize).ceil();
|
||||
final canGoPrevious = currentPage > 0;
|
||||
final canGoNext = currentPage < totalPages - 1;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.first_page),
|
||||
onPressed: canGoPrevious ? () => onPageChange?.call(0) : null,
|
||||
tooltip: 'First page',
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed:
|
||||
canGoPrevious ? () => onPageChange?.call(currentPage - 1) : null,
|
||||
tooltip: 'Previous page',
|
||||
iconSize: 20,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Page ${currentPage + 1} of $totalPages',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed:
|
||||
canGoNext ? () => onPageChange?.call(currentPage + 1) : null,
|
||||
tooltip: 'Next page',
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.last_page),
|
||||
onPressed:
|
||||
canGoNext ? () => onPageChange?.call(totalPages - 1) : null,
|
||||
tooltip: 'Last page',
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data_grid_column.dart';
|
||||
import '../data_grid_config.dart';
|
||||
|
||||
/// Header row for DataGrid.
|
||||
class DataGridHeader<T> extends StatelessWidget {
|
||||
const DataGridHeader({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.sortColumnIndex,
|
||||
required this.sortDescending,
|
||||
required this.onSort,
|
||||
this.showCheckbox = false,
|
||||
this.allSelected = false,
|
||||
this.someSelected = false,
|
||||
this.onSelectAll,
|
||||
});
|
||||
|
||||
final DataGridConfig<T> config;
|
||||
final int? sortColumnIndex;
|
||||
final bool sortDescending;
|
||||
final void Function(int columnIndex) onSort;
|
||||
final bool showCheckbox;
|
||||
final bool allSelected;
|
||||
final bool someSelected;
|
||||
final VoidCallback? onSelectAll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final columns = config.visibleColumns;
|
||||
|
||||
return Container(
|
||||
height: config.headerHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (showCheckbox)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Center(
|
||||
child: Checkbox(
|
||||
value: allSelected ? true : (someSelected ? null : false),
|
||||
tristate: true,
|
||||
onChanged: (_) => onSelectAll?.call(),
|
||||
),
|
||||
),
|
||||
),
|
||||
...columns.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final column = entry.value;
|
||||
final isSorted = sortColumnIndex == index;
|
||||
|
||||
return _buildHeaderCell(
|
||||
context,
|
||||
column,
|
||||
index,
|
||||
isSorted,
|
||||
isSorted && sortDescending,
|
||||
);
|
||||
}),
|
||||
if (config.actions.isNotEmpty)
|
||||
const SizedBox(width: 56), // Space for actions column
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderCell(
|
||||
BuildContext context,
|
||||
DataGridColumn<T> column,
|
||||
int index,
|
||||
bool isSorted,
|
||||
bool isDescending,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
Widget content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
column.header,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: column.textAlign,
|
||||
),
|
||||
),
|
||||
if (column.sortable) ...[
|
||||
const SizedBox(width: 4),
|
||||
AnimatedRotation(
|
||||
turns: isDescending ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
isSorted ? Icons.arrow_upward : Icons.unfold_more,
|
||||
size: 16,
|
||||
color: isSorted
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (column.sortable) {
|
||||
content = InkWell(
|
||||
onTap: () => onSort(index),
|
||||
child: Padding(
|
||||
padding: config.cellPadding,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content = Padding(
|
||||
padding: config.cellPadding,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return _wrapWithWidth(column.width, content);
|
||||
}
|
||||
|
||||
Widget _wrapWithWidth(DataGridColumnWidth width, Widget child) {
|
||||
return switch (width) {
|
||||
GridFixedWidth(:final width) => SizedBox(width: width, child: child),
|
||||
GridFlexWidth(:final flex) => Expanded(flex: flex, child: child),
|
||||
GridFractionWidth(:final fraction) => FractionallySizedBox(
|
||||
widthFactor: fraction,
|
||||
child: child,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data_grid_column.dart';
|
||||
import '../data_grid_config.dart';
|
||||
import 'data_grid_actions_menu.dart';
|
||||
|
||||
/// A single data row in the DataGrid.
|
||||
class DataGridRow<T> extends StatelessWidget {
|
||||
const DataGridRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.index,
|
||||
required this.config,
|
||||
this.isSelected = false,
|
||||
this.onSelect,
|
||||
this.onTap,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
final T item;
|
||||
final int index;
|
||||
final DataGridConfig<T> config;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onSelect;
|
||||
final VoidCallback? onTap;
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final columns = config.visibleColumns;
|
||||
|
||||
// Determine row background color
|
||||
Color? bgColor = backgroundColor;
|
||||
if (bgColor == null && config.alternatingRowColors) {
|
||||
bgColor = index.isOdd
|
||||
? colorScheme.surfaceContainerLowest
|
||||
: colorScheme.surface;
|
||||
}
|
||||
if (isSelected) {
|
||||
bgColor = colorScheme.primaryContainer.withValues(alpha: 0.3);
|
||||
}
|
||||
|
||||
final rowContent = Container(
|
||||
height: config.rowHeight,
|
||||
constraints: config.rowHeight == null
|
||||
? const BoxConstraints(minHeight: 48)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (config.rowsSelectable)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Center(
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => onSelect?.call(),
|
||||
),
|
||||
),
|
||||
),
|
||||
...columns.map((column) => _buildCell(context, column)),
|
||||
if (config.actions.isNotEmpty)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: DataGridActionsMenu<T>(
|
||||
item: item,
|
||||
actions: config.actions,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: rowContent,
|
||||
);
|
||||
}
|
||||
|
||||
return rowContent;
|
||||
}
|
||||
|
||||
Widget _buildCell(BuildContext context, DataGridColumn<T> column) {
|
||||
Widget content;
|
||||
|
||||
if (column.cellBuilder != null) {
|
||||
content = column.cellBuilder!(context, item);
|
||||
} else {
|
||||
content = Text(
|
||||
column.valueBuilder(item),
|
||||
textAlign: column.textAlign,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with controls if provided
|
||||
if (column.cellControlsBuilder != null) {
|
||||
content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(child: content),
|
||||
const SizedBox(width: 8),
|
||||
column.cellControlsBuilder!(context, item),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with tooltip if provided
|
||||
if (column.tooltip != null) {
|
||||
content = Tooltip(
|
||||
message: column.tooltip!(item),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
final cell = Padding(
|
||||
padding: config.cellPadding,
|
||||
child: Align(
|
||||
alignment: switch (column.alignment) {
|
||||
DataGridColumnAlignment.start => Alignment.centerLeft,
|
||||
DataGridColumnAlignment.center => Alignment.center,
|
||||
DataGridColumnAlignment.end => Alignment.centerRight,
|
||||
},
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
|
||||
return _wrapWithWidth(column.width, cell);
|
||||
}
|
||||
|
||||
Widget _wrapWithWidth(DataGridColumnWidth width, Widget child) {
|
||||
return switch (width) {
|
||||
GridFixedWidth(:final width) => SizedBox(width: width, child: child),
|
||||
GridFlexWidth(:final flex) => Expanded(flex: flex, child: child),
|
||||
GridFractionWidth(:final fraction) => FractionallySizedBox(
|
||||
widthFactor: fraction,
|
||||
child: child,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Search bar for DataGrid.
|
||||
class DataGridSearchBar extends StatefulWidget {
|
||||
const DataGridSearchBar({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
required this.onClear,
|
||||
this.hintText = 'Search...',
|
||||
this.initialValue = '',
|
||||
});
|
||||
|
||||
final void Function(String query) onSearch;
|
||||
final VoidCallback onClear;
|
||||
final String hintText;
|
||||
final String initialValue;
|
||||
|
||||
@override
|
||||
State<DataGridSearchBar> createState() => _DataGridSearchBarState();
|
||||
}
|
||||
|
||||
class _DataGridSearchBarState extends State<DataGridSearchBar> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: 300,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
widget.onClear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {}); // Update clear button visibility
|
||||
widget.onSearch(value);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user