review changes done

This commit is contained in:
Surendiran 2026-07-27 15:29:10 +05:30
parent 048ecab961
commit ef0f33ecbb
28 changed files with 2409 additions and 1026 deletions

View File

@ -59,13 +59,13 @@ class RouteConstants {
static const String grnEdit = '/grn/:id/edit';
static const String grnDetail = '/grn/:id';
// Assets
static const String assets = '/assets';
static const String assetAdd = '/assets/add';
static const String assetEdit = '/assets/:id/edit';
static const String assetDetail = '/assets/:id';
static const String assetAlerts = '/assets/alerts';
static const String assetMaintenance = '/assets/maintenance';
// Assets (Asset Master)
static const String assets = '/assetsmaster';
static const String assetAdd = '/assetsmaster/add';
static const String assetEdit = '/assetsmaster/:id/edit';
static const String assetDetail = '/assetsmaster/:id';
static const String assetAlerts = '/assetsmaster/alerts';
static const String assetMaintenance = '/assetsmaster/maintenance';
// Master Data
static const String masterData = '/master-data';

View File

@ -13,4 +13,9 @@ class StorageKeys {
static const String appSettings = 'app_settings';
static const String rememberMe = 'remember_me';
static const String rememberedEmail = 'remembered_email';
/// Prefix for per-table column visibility/order prefs (`table_columns_<tableId>`).
static const String tableColumnsPrefix = 'table_columns_';
static String tableColumns(String tableId) => '$tableColumnsPrefix$tableId';
}

View File

@ -259,53 +259,24 @@ class _OverviewTab extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
],
Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// 1) Top Summary
_AssetOverviewSection(
title: 'Summary',
child: _AssetInfoGrid(
items: [
_AssetInfo.widget(
'Status',
AppStatusChip(status: asset.status ?? 'IN_USE'),
),
_AssetInfo(
'Maintenance Due',
asset.maintenance == null
? ''
: (asset.maintenance!.isDue ? 'Yes' : 'No'),
),
_AssetInfo(
'Current Value',
asset.resolvedCurrentValue != null
? CurrencyFormatter.format(
asset.resolvedCurrentValue,
)
: '',
),
_AssetInfo.widget(
'Active',
_ActiveDotIndicator(isActive: asset.isActive),
),
],
child: _AssetSummaryStrip(
status: asset.status ?? 'IN_USE',
maintenanceDue: asset.maintenance?.isDue,
currentValue: asset.resolvedCurrentValue,
isActive: asset.isActive,
daysUntilDueDisplay: daysUntilDueDisplay,
),
),
@ -344,56 +315,43 @@ class _OverviewTab extends ConsumerWidget {
// 3) Maintenance
_AssetOverviewSection(
title: 'Maintenance',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AssetInfoGrid(
items: [
_AssetInfo(
'Maintenance Incharge',
maintenanceInchargeLabel,
),
_AssetInfo(
'Maintenance Frequency',
asset.maintenanceFrequencyInDays != null
? '${asset.maintenanceFrequencyInDays} days'
: '',
),
_AssetInfo(
'Next Due Date',
asset.maintenance?.nextDueDate != null
? dateFormat.format(
asset.maintenance!.nextDueDate!,
)
: '',
),
if (daysUntilDueDisplay == null)
const _AssetInfo('Days Until Due', '')
else
_AssetInfo.widget(
'Days Until Due',
Text(
daysUntilDueDisplay.label,
style: TextStyle(
color: daysUntilDueDisplay.color,
fontWeight: FontWeight.w600,
),
),
),
_AssetInfo(
'Condition',
assetConditionLabel(asset.condition),
),
],
child: _AssetInfoGrid(
items: [
_AssetInfo(
'Maintenance Incharge',
maintenanceInchargeLabel,
),
if (asset.maintenance != null) ...[
const SizedBox(height: 12),
_MaintenanceDueCallout(
isDue: asset.maintenance!.isDue,
daysUntilDue: asset.maintenance!.daysUntilDue,
display: daysUntilDueDisplay,
_AssetInfo(
'Maintenance Frequency',
asset.maintenanceFrequencyInDays != null
? '${asset.maintenanceFrequencyInDays} days'
: '',
),
_AssetInfo(
'Next Due Date',
asset.maintenance?.nextDueDate != null
? dateFormat.format(
asset.maintenance!.nextDueDate!,
)
: '',
),
if (daysUntilDueDisplay == null)
const _AssetInfo('Days Until Due', '')
else
_AssetInfo.widget(
'Days Until Due',
Text(
daysUntilDueDisplay.label,
style: TextStyle(
color: daysUntilDueDisplay.color,
fontWeight: FontWeight.w600,
),
),
),
],
_AssetInfo(
'Condition',
assetConditionLabel(asset.condition),
),
],
),
),
@ -479,6 +437,23 @@ class _OverviewTab extends ConsumerWidget {
label: const Text('Log Maintenance'),
),
],
trailingActions: [
OutlinedButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
],
),
] else ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
),
],
if (asset.remarks?.trim().isNotEmpty == true) ...[
@ -573,82 +548,220 @@ class _AssetOverviewSection extends StatelessWidget {
}
}
class _ActiveDotIndicator extends StatelessWidget {
const _ActiveDotIndicator({required this.isActive});
/// Stylish KPI strip for Overview Summary (theme primary / secondary accents).
class _AssetSummaryStrip extends StatelessWidget {
const _AssetSummaryStrip({
required this.status,
required this.maintenanceDue,
required this.currentValue,
required this.isActive,
required this.daysUntilDueDisplay,
});
final String status;
final bool? maintenanceDue;
final double? currentValue;
final bool isActive;
final MaintenanceDueDisplay? daysUntilDueDisplay;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color =
isActive ? const Color(0xFF16A34A) : theme.colorScheme.onSurfaceVariant;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
final scheme = theme.colorScheme;
final dueColor = maintenanceDue == null
? scheme.onSurfaceVariant
: maintenanceDue!
? scheme.error
: (daysUntilDueDisplay?.color ?? const Color(0xFF16A34A));
final dueLabel = maintenanceDue == null
? ''
: maintenanceDue!
? 'Yes'
: 'No';
final activeColor =
isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary.withValues(alpha: 0.07),
scheme.secondary.withValues(alpha: 0.06),
scheme.surfaceContainerHighest.withValues(alpha: 0.35),
],
),
const SizedBox(width: 6),
Text(
isActive ? 'Active' : 'Inactive',
style: theme.textTheme.bodyLarge?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
border: Border.all(
color: scheme.primary.withValues(alpha: 0.14),
),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth < 560
? 1
: constraints.maxWidth < 820
? 2
: 4;
const gap = 12.0;
final tileWidth =
(constraints.maxWidth - gap * (cols - 1)) / cols;
final tiles = <Widget>[
_SummaryMetricTile(
width: tileWidth,
icon: Icons.flag_outlined,
label: 'Status',
accent: scheme.secondary,
child: AppStatusChip(status: status, compact: true),
),
_SummaryMetricTile(
width: tileWidth,
icon: Icons.build_circle_outlined,
label: 'Maintenance Due',
accent: dueColor,
child: Text(
dueLabel,
style: theme.textTheme.titleMedium?.copyWith(
color: dueColor,
fontWeight: FontWeight.w700,
),
),
),
_SummaryMetricTile(
width: tileWidth,
icon: Icons.payments_outlined,
label: 'Current Value',
accent: scheme.primary,
child: Text(
currentValue != null
? CurrencyFormatter.format(currentValue)
: '',
style: theme.textTheme.titleMedium?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w800,
),
),
),
_SummaryMetricTile(
width: tileWidth,
icon: isActive
? Icons.check_circle_outline
: Icons.pause_circle_outline,
label: 'Active',
accent: activeColor,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: activeColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: activeColor.withValues(alpha: 0.35),
blurRadius: 6,
),
],
),
),
const SizedBox(width: 8),
Text(
isActive ? 'Active' : 'Inactive',
style: theme.textTheme.titleMedium?.copyWith(
color: activeColor,
fontWeight: FontWeight.w700,
),
),
],
),
),
];
return Wrap(
spacing: gap,
runSpacing: gap,
children: tiles,
);
},
),
);
}
}
class _MaintenanceDueCallout extends StatelessWidget {
const _MaintenanceDueCallout({
required this.isDue,
required this.daysUntilDue,
required this.display,
class _SummaryMetricTile extends StatelessWidget {
const _SummaryMetricTile({
required this.width,
required this.icon,
required this.label,
required this.accent,
required this.child,
});
final bool isDue;
final int? daysUntilDue;
final MaintenanceDueDisplay? display;
final double width;
final IconData icon;
final String label;
final Color accent;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = display?.color ?? theme.colorScheme.onSurfaceVariant;
final suffix = () {
if (daysUntilDue == null) return null;
if (daysUntilDue! > 0) {
return 'next service in $daysUntilDue day${daysUntilDue == 1 ? '' : 's'}';
}
if (daysUntilDue == 0) return 'due today';
final overdueBy = daysUntilDue!.abs();
return 'overdue by $overdueBy day${overdueBy == 1 ? '' : 's'}';
}();
final scheme = theme.colorScheme;
final text = suffix == null
? 'Maintenance Due: ${isDue ? 'Yes' : 'No'}'
: 'Maintenance Due: ${isDue ? 'Yes' : 'No'}$suffix';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
return SizedBox(
width: width,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
decoration: BoxDecoration(
color: scheme.surface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: accent.withValues(alpha: 0.18)),
boxShadow: [
BoxShadow(
color: scheme.shadow.withValues(alpha: 0.04),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: accent),
),
const SizedBox(width: 8),
Expanded(
child: Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
],
),
const SizedBox(height: 12),
child,
],
),
),
);
@ -910,24 +1023,26 @@ class _AssetInfoGrid extends StatelessWidget {
const _AssetInfoGrid({required this.items});
final List<_AssetInfo> items;
static const int _columns = 3;
static const int _columns = 4;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cols = maxWidth < 600
final cols = maxWidth < 560
? 1
: maxWidth < 900
: maxWidth < 780
? 2
: _columns;
: maxWidth < 1000
? 3
: _columns;
const spacing = 16.0;
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
return Wrap(
spacing: spacing,
runSpacing: 16,
runSpacing: 18,
children: items
.map(
(item) => SizedBox(
@ -965,14 +1080,22 @@ class _AssetDetailTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
fontWeight: FontWeight.w500,
letterSpacing: 0.45,
),
),
const SizedBox(height: 4),
valueWidget ?? Text(value ?? '', style: theme.textTheme.bodyLarge),
const SizedBox(height: 6),
valueWidget ??
Text(
value ?? '',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
);
}

View File

@ -43,7 +43,7 @@ void openAssetForm(
if (assetId == null) {
context.push(RouteConstants.assetAdd);
} else {
context.push('/assets/$assetId/edit');
context.push('${RouteConstants.assets}/$assetId/edit');
}
}
@ -1559,33 +1559,58 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
)
.where((option) => option.value != 0)
.toList(),
refreshLookups: () {
refreshLookups: () async {
ref.invalidate(itemCategoriesFormProvider);
await ref.read(itemCategoriesFormProvider.future);
},
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() {
_categoryId = v;
_subcategoryId = null;
final selectedCategory =
activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull;
if (selectedCategory != null) {
if (selectedCategory.defaultDepreciationMethod != null &&
selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) {
_depreciationMethod =
selectedCategory.defaultDepreciationMethod!.trim().toUpperCase();
}
if (selectedCategory.defaultUsefulLifeYears != null &&
selectedCategory.defaultUsefulLifeYears! > 0) {
_usefulLifeController.text =
selectedCategory.defaultUsefulLifeYears!.toString();
}
}
onChanged: (v) {
setState(() {
_categoryId = v;
_subcategoryId = null;
});
// Prefer the latest provider list so Quick Add (after refresh) can
// apply default useful life / depreciation method.
_applyCategoryDepreciationDefaults(
v,
fallbackCategories: activeCategories,
);
_triggerPreviewRecalculation();
}),
},
validator: (v) => v == null ? 'Category is required' : null,
);
}
void _applyCategoryDepreciationDefaults(
int? categoryId, {
List<AssetCategoryModel> fallbackCategories = const [],
}) {
if (categoryId == null) return;
final fromProvider =
ref.read(itemCategoriesFormProvider).valueOrNull ?? const [];
final pool = fromProvider.isNotEmpty ? fromProvider : fallbackCategories;
final selectedCategory = pool
.where((c) => int.tryParse(c.id) == categoryId)
.firstOrNull;
if (selectedCategory == null) return;
var changed = false;
if (selectedCategory.defaultDepreciationMethod != null &&
selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) {
_depreciationMethod =
selectedCategory.defaultDepreciationMethod!.trim().toUpperCase();
changed = true;
}
if (selectedCategory.defaultUsefulLifeYears != null &&
selectedCategory.defaultUsefulLifeYears! > 0) {
_usefulLifeController.text =
selectedCategory.defaultUsefulLifeYears!.toString();
changed = true;
}
if (changed && mounted) setState(() {});
}
Widget _vendorDropdown(List<FilterOptionModel> vendors) {
final vendorIds = vendors
.map((vendor) => int.tryParse(vendor.id))

View File

@ -22,7 +22,9 @@ import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/utils/file_download_helper.dart';
@ -91,6 +93,11 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _AssetDataTable.tableId,
columns: _AssetDataTable.columnOptions,
),
const SizedBox(width: 8),
if (canExport)
OutlinedButton.icon(
onPressed: state.isExporting ? null : _exportAssets,
@ -371,7 +378,7 @@ class _AssetsFilterBar extends StatelessWidget {
}
}
class _AssetDataTable extends StatelessWidget {
class _AssetDataTable extends ConsumerWidget {
const _AssetDataTable({
required this.assets,
required this.canEdit,
@ -383,6 +390,29 @@ class _AssetDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'assets_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'asset_code',
label: 'Asset Code',
required: true,
),
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
AppTableColumnOption(id: 'category', label: 'Category'),
AppTableColumnOption(id: 'location', label: 'Location'),
AppTableColumnOption(id: 'current_value', label: 'Current Value'),
AppTableColumnOption(
id: 'depreciated_amount',
label: 'Depreciated Amount',
),
AppTableColumnOption(
id: 'warranty_validity',
label: 'Warranty Validity',
),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<AssetModel> assets;
final bool canEdit;
final bool canDelete;
@ -392,111 +422,141 @@ class _AssetDataTable extends StatelessWidget {
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<AssetModel>> _allColumns() {
return [
AppDataColumn(
id: 'asset_code',
label: 'Asset Code',
sortKey: 'asset_code',
locked: true,
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) {
final code = asset.assetCode;
if (code == null || code.isEmpty) return const Text('');
return AppTableCell.link(code, onTap: () => onView(asset));
},
),
AppDataColumn(
id: 'asset_name',
label: 'Asset Name',
sortKey: 'asset_name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
id: 'category',
label: 'Category',
sortKey: 'category',
flex: 2,
searchText: (asset) => asset.assetCategoryName ?? '',
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? ''),
),
AppDataColumn(
id: 'location',
label: 'Location',
sortKey: 'location',
flex: 1,
searchText: (asset) => asset.locationName ?? '',
cellBuilder: (_, asset) => Text(asset.locationName ?? ''),
),
AppDataColumn(
id: 'current_value',
label: 'Current Value',
sortKey: 'current_value',
flex: 1,
searchText: (asset) =>
CurrencyFormatter.searchable(asset.resolvedCurrentValue),
sortValue: (asset) => asset.resolvedCurrentValue,
cellBuilder: (_, asset) => Text(
asset.resolvedCurrentValue != null
? CurrencyFormatter.format(asset.resolvedCurrentValue)
: '',
),
),
AppDataColumn(
id: 'depreciated_amount',
label: 'Depreciated Amount',
sortKey: 'depreciated_amount',
flex: 1,
searchText: (asset) =>
CurrencyFormatter.searchable(asset.depreciatedAmount),
sortValue: (asset) => asset.depreciatedAmount,
cellBuilder: (_, asset) => Text(
asset.depreciatedAmount != null
? CurrencyFormatter.format(asset.depreciatedAmount)
: '',
),
),
AppDataColumn(
id: 'warranty_validity',
label: 'Warranty Validity',
sortKey: 'warranty_validity',
flex: 1,
searchText: (asset) =>
DateFormatter.searchableDate(asset.warrantyExpiryDate),
sortValue: (asset) => asset.warrantyExpiryDate,
cellBuilder: (_, asset) => Text(
DateFormatter.displayDate(asset.warrantyExpiryDate),
),
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (asset) =>
asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
cellBuilder: (_, asset) => AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit(asset),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete(asset),
),
],
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
return AppDataTable<AssetModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) {
final code = asset.assetCode;
if (code == null || code.isEmpty) return const Text('');
return AppTableCell.link(code, onTap: () => onView(asset));
},
),
AppDataColumn(
label: 'Asset Name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
label: 'Category',
flex: 2,
searchText: (asset) => asset.assetCategoryName ?? '',
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? ''),
),
AppDataColumn(
label: 'Location',
flex: 1,
searchText: (asset) => asset.locationName ?? '',
cellBuilder: (_, asset) => Text(asset.locationName ?? ''),
),
AppDataColumn(
label: 'Current Value',
flex: 1,
searchText: (asset) =>
CurrencyFormatter.searchable(asset.resolvedCurrentValue),
cellBuilder: (_, asset) => Text(
asset.resolvedCurrentValue != null
? CurrencyFormatter.format(asset.resolvedCurrentValue)
: '',
),
),
AppDataColumn(
label: 'Depreciated Amount',
flex: 1,
searchText: (asset) =>
CurrencyFormatter.searchable(asset.depreciatedAmount),
cellBuilder: (_, asset) => Text(
asset.depreciatedAmount != null
? CurrencyFormatter.format(asset.depreciatedAmount)
: '',
),
),
AppDataColumn(
label: 'Warranty Validity',
flex: 1,
searchText: (asset) =>
DateFormatter.searchableDate(asset.warrantyExpiryDate),
cellBuilder: (_, asset) => Text(
DateFormatter.displayDate(asset.warrantyExpiryDate),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (asset) =>
asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
cellBuilder: (_, asset) => AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit(asset),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete(asset),
),
],
),
),
],
columns: columns,
rows: assets,
);
}

View File

@ -8,12 +8,14 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
@ -95,6 +97,11 @@ class _MyMaintenanceBody extends ConsumerWidget {
title: 'My Maintenance',
subtitle: 'Assets assigned to you for checklist-based maintenance',
actions: [
AppTableColumnSelectorButton(
tableId: _MaintenanceTable.tableId,
columns: _MaintenanceTable.columnOptions,
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assets),
icon: const Icon(Icons.inventory_2_outlined),
@ -174,83 +181,116 @@ class _MyMaintenanceBody extends ConsumerWidget {
}
}
class _MaintenanceTable extends StatelessWidget {
class _MaintenanceTable extends ConsumerWidget {
const _MaintenanceTable({
required this.assets,
required this.onSubmit,
required this.onView,
});
static const tableId = 'my_maintenance';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'asset_code',
label: 'Asset Code',
required: true,
),
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
AppTableColumnOption(id: 'next_due', label: 'Next Due'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<AssetModel> assets;
final void Function(AssetModel asset) onSubmit;
final void Function(AssetModel asset) onView;
@override
Widget build(BuildContext context) {
List<AppDataColumn<AssetModel>> _allColumns() {
final dateFormat = DateFormat('dd MMM yyyy');
return [
AppDataColumn(
id: 'asset_code',
label: 'Asset Code',
sortKey: 'asset_code',
locked: true,
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) => AppTableCell.link(
asset.assetCode,
onTap: () => onView(asset),
),
),
AppDataColumn(
id: 'asset_name',
label: 'Asset Name',
sortKey: 'asset_name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
id: 'next_due',
label: 'Next Due',
sortKey: 'next_due',
flex: 1,
searchText: (asset) => DateFormatter.displayDate(
asset.maintenance?.nextDueDate,
),
sortValue: (asset) => asset.maintenance?.nextDueDate,
cellBuilder: (_, asset) {
final nextDue = asset.maintenance?.nextDueDate;
return Text(
nextDue != null ? dateFormat.format(nextDue) : '',
);
},
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (asset) =>
asset.maintenance?.isDue == true ? 'due' : 'ok',
cellBuilder: (_, asset) => _DueBadge(
isDue: asset.maintenance?.isDue == true,
daysUntilDue: asset.maintenance?.daysUntilDue,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'Submit checklist',
icon: Icons.checklist_outlined,
onPressed: () => onSubmit(asset),
),
AppTableActionIcon(
tooltip: 'View asset',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
],
),
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
return AppDataTable<AssetModel>(
wrapInCard: false,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) => AppTableCell.link(
asset.assetCode,
onTap: () => context.push('${RouteConstants.assets}/${asset.id}'),
),
),
AppDataColumn(
label: 'Asset Name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
label: 'Next Due',
flex: 1,
searchText: (asset) => DateFormatter.displayDate(
asset.maintenance?.nextDueDate,
),
cellBuilder: (_, asset) {
final nextDue = asset.maintenance?.nextDueDate;
return Text(
nextDue != null ? dateFormat.format(nextDue) : '',
);
},
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (asset) =>
asset.maintenance?.isDue == true ? 'due' : 'ok',
cellBuilder: (_, asset) => _DueBadge(
isDue: asset.maintenance?.isDue == true,
daysUntilDue: asset.maintenance?.daysUntilDue,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'Submit checklist',
icon: Icons.checklist_outlined,
onPressed: () => onSubmit(asset),
),
AppTableActionIcon(
tooltip: 'View asset',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
],
),
),
],
columns: columns,
rows: assets,
);
}

View File

@ -273,14 +273,18 @@ class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget {
required this.assetId,
this.limit = 5,
this.leadingActions,
this.trailingActions,
});
final String assetId;
final int limit;
/// Optional actions shown on the same row (e.g. Log Maintenance).
/// Optional actions shown before Recent Logs (e.g. Log Maintenance).
final List<Widget>? leadingActions;
/// Optional actions shown after Recent Logs (e.g. Transfer History).
final List<Widget>? trailingActions;
@override
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
_AssetRecentMaintenanceLogsSectionState();
@ -352,6 +356,7 @@ class _AssetRecentMaintenanceLogsSectionState
),
label: const Text('Recent Logs'),
),
...?widget.trailingActions,
],
),
),

View File

@ -23,7 +23,9 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/audit_provider.dart';
@ -143,6 +145,11 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _AuditDataTable.tableId,
columns: _AuditDataTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -370,7 +377,7 @@ class _FiltersBar extends StatelessWidget {
}
}
class _AuditDataTable extends StatelessWidget {
class _AuditDataTable extends ConsumerWidget {
const _AuditDataTable({
required this.items,
required this.onView,
@ -378,99 +385,135 @@ class _AuditDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'audit_logs';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(id: 'when', label: 'When', required: true),
AppTableColumnOption(id: 'action', label: 'Action'),
AppTableColumnOption(id: 'table', label: 'Table'),
AppTableColumnOption(id: 'record', label: 'Record'),
AppTableColumnOption(id: 'performed_by', label: 'Performed By'),
AppTableColumnOption(id: 'request_id', label: 'Request ID'),
AppTableColumnOption(id: 'changes', label: 'Changes'),
];
final List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
static String _changesText(AuditLogEntryModel row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return parts.isEmpty ? '' : parts.join(' / ');
}
List<AppDataColumn<AuditLogEntryModel>> _allColumns() {
return [
AppDataColumn(
id: 'when',
label: 'When',
sortKey: 'when',
locked: true,
flex: 2,
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
sortValue: (row) => row.performedAt,
cellBuilder: (_, row) => AppTableCell.text(
DateFormatter.displayDateTime(row.performedAt),
),
),
AppDataColumn(
id: 'action',
label: 'Action',
sortKey: 'action',
flex: 1,
searchText: (row) => row.action,
cellBuilder: (_, row) => AppTableCell.child(
AppStatusChip(
status: row.action,
compact: true,
forTable: true,
),
),
),
AppDataColumn(
id: 'table',
label: 'Table',
sortKey: 'table',
flex: 2,
searchText: (row) => humanizeLabel(row.tableName),
cellBuilder: (_, row) =>
AppTableCell.text(humanizeLabel(row.tableName)),
),
AppDataColumn(
id: 'record',
label: 'Record',
sortKey: 'record',
flex: 1,
searchText: (row) => row.recordId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
),
AppDataColumn(
id: 'performed_by',
label: 'Performed By',
sortKey: 'performed_by',
flex: 2,
searchText: (row) => row.performerLabel,
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
),
AppDataColumn(
id: 'request_id',
label: 'Request ID',
sortKey: 'request_id',
flex: 2,
searchText: (row) => row.requestId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
),
AppDataColumn(
id: 'changes',
label: 'Changes',
sortKey: 'changes',
flex: 1,
searchText: _changesText,
cellBuilder: (_, row) {
final text = _changesText(row);
return AppTableCell.text(text.isEmpty ? '' : text);
},
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, row) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(row),
),
],
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
return AppDataTable<AuditLogEntryModel>(
wrapInCard: false,
rows: items,
emptyMessage: 'No audit logs found',
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'When',
flex: 2,
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
cellBuilder: (_, row) => AppTableCell.text(
DateFormatter.displayDateTime(row.performedAt),
),
),
AppDataColumn(
label: 'Action',
flex: 1,
searchText: (row) => row.action,
cellBuilder: (_, row) => AppTableCell.child(
AppStatusChip(
status: row.action,
compact: true,
forTable: true,
),
),
),
AppDataColumn(
label: 'Table',
flex: 2,
searchText: (row) => humanizeLabel(row.tableName),
cellBuilder: (_, row) =>
AppTableCell.text(humanizeLabel(row.tableName)),
),
AppDataColumn(
label: 'Record',
flex: 1,
searchText: (row) => row.recordId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
),
AppDataColumn(
label: 'Performed By',
flex: 2,
searchText: (row) => row.performerLabel,
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
),
AppDataColumn(
label: 'Request ID',
flex: 2,
searchText: (row) => row.requestId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
),
AppDataColumn(
label: 'Changes',
flex: 1,
searchText: (row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return parts.isEmpty ? '' : parts.join(' / ');
},
cellBuilder: (_, row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return AppTableCell.text(
parts.isEmpty ? '' : parts.join(' / '),
);
},
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, row) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(row),
),
],
),
),
],
columns: columns,
);
}
}

View File

@ -77,7 +77,7 @@ final _entries = [
),
// Assets
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
_GalleryEntry(title: 'Asset Detail', route: '${RouteConstants.assets}/demo-asset', group: 'Assets'),
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
// Master data
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),

View File

@ -21,9 +21,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
@ -82,6 +84,11 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _GrnDataTable.tableId,
columns: _GrnDataTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -289,7 +296,7 @@ class _FiltersBar extends StatelessWidget {
}
}
class _GrnDataTable extends StatelessWidget {
class _GrnDataTable extends ConsumerWidget {
const _GrnDataTable({
required this.grns,
required this.onView,
@ -298,90 +305,129 @@ class _GrnDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'grn_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'grn_number',
label: 'GRN Number',
required: true,
),
AppTableColumnOption(id: 'date', label: 'Date'),
AppTableColumnOption(id: 'po_number', label: 'PO Number'),
AppTableColumnOption(id: 'vendor', label: 'Vendor'),
AppTableColumnOption(id: 'location', label: 'Location'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<GrnModel> grns;
final ValueChanged<GrnModel> onView;
final ValueChanged<GrnModel>? onEdit;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<GrnModel>> _allColumns(BuildContext context) {
return [
AppDataColumn(
id: 'grn_number',
label: 'GRN Number',
sortKey: 'grn_number',
locked: true,
flex: 2,
searchText: (grn) => grn.grnNumber ?? '',
cellBuilder: (_, grn) => AppTableCell.link(
grn.grnNumber,
onTap: () => onView(grn),
),
),
AppDataColumn(
id: 'date',
label: 'Date',
sortKey: 'date',
flex: 1,
searchText: (grn) => DateFormatter.searchableDate(grn.grnDate),
sortValue: (grn) => grn.grnDate,
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
),
AppDataColumn(
id: 'po_number',
label: 'PO Number',
sortKey: 'po_number',
flex: 2,
searchText: (grn) => grn.poNumber ?? '',
cellBuilder: (_, grn) => AppTableCell.link(
grn.poNumber,
onTap: grn.poId == null
? null
: () => context.push(
'${RouteConstants.purchaseOrders}/${grn.poId}',
),
),
),
AppDataColumn(
id: 'vendor',
label: 'Vendor',
sortKey: 'vendor',
flex: 2,
searchText: (grn) => grn.vendorName ?? '',
cellBuilder: (_, grn) => Text(grn.vendorName ?? ''),
),
AppDataColumn(
id: 'location',
label: 'Location',
sortKey: 'location',
flex: 2,
searchText: (grn) => grn.locationName ?? '',
cellBuilder: (_, grn) => Text(grn.locationName ?? ''),
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (grn) => grn.status,
cellBuilder: (_, grn) => GrnStatusChip(
status: grn.status,
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, grn) => AppTableActions(
children: [
AppTableActionIcon(
icon: Icons.visibility_outlined,
tooltip: 'View',
onPressed: () => onView(grn),
),
if (onEdit != null && grn.canEdit)
AppTableActionIcon(
icon: Icons.edit_outlined,
tooltip: 'Edit',
onPressed: () => onEdit!(grn),
),
],
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(context), prefs);
return AppDataTable<GrnModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'GRN Number',
flex: 2,
searchText: (grn) => grn.grnNumber ?? '',
cellBuilder: (_, grn) => AppTableCell.link(
grn.grnNumber,
onTap: () => onView(grn),
),
),
AppDataColumn(
label: 'Date',
flex: 1,
searchText: (grn) => DateFormatter.searchableDate(grn.grnDate),
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
),
AppDataColumn(
label: 'PO Number',
flex: 2,
searchText: (grn) => grn.poNumber ?? '',
cellBuilder: (_, grn) => AppTableCell.link(
grn.poNumber,
onTap: grn.poId == null
? null
: () => context.push(
'${RouteConstants.purchaseOrders}/${grn.poId}',
),
),
),
AppDataColumn(
label: 'Vendor',
flex: 2,
searchText: (grn) => grn.vendorName ?? '',
cellBuilder: (_, grn) => Text(grn.vendorName ?? ''),
),
AppDataColumn(
label: 'Location',
flex: 2,
searchText: (grn) => grn.locationName ?? '',
cellBuilder: (_, grn) => Text(grn.locationName ?? ''),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (grn) => grn.status,
cellBuilder: (_, grn) => GrnStatusChip(
status: grn.status,
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
enableSearch: false,
cellBuilder: (_, grn) => AppTableActions(
children: [
AppTableActionIcon(
icon: Icons.visibility_outlined,
tooltip: 'View',
onPressed: () => onView(grn),
),
if (onEdit != null && grn.canEdit)
AppTableActionIcon(
icon: Icons.edit_outlined,
tooltip: 'Edit',
onPressed: () => onEdit!(grn),
),
],
),
),
],
columns: columns,
rows: grns,
);
}

View File

@ -7,6 +7,7 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_loading_view.dart';
@ -15,6 +16,7 @@ import '../../../../shared/widgets/app_search_export_bar.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
@ -204,6 +206,11 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _MasterListTable.tableIdFor(def),
columns: _MasterListTable.columnOptionsFor(def),
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -285,7 +292,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
}
}
class _MasterListTable extends StatelessWidget {
class _MasterListTable extends ConsumerWidget {
const _MasterListTable({
required this.definition,
required this.items,
@ -308,75 +315,111 @@ class _MasterListTable extends StatelessWidget {
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
static String tableIdFor(MasterDefinition definition) =>
'master_${definition.id}';
static String _fieldLabel(MasterFieldDef field) =>
field.key == 'is_asset_item' ? 'Type' : field.label;
static List<AppTableColumnOption> columnOptionsFor(
MasterDefinition definition,
) {
final fields = definition.listFields;
return [
for (var i = 0; i < fields.length; i++)
AppTableColumnOption(
id: fields[i].key,
label: _fieldLabel(fields[i]),
required: i == 0,
),
const AppTableColumnOption(id: 'status', label: 'Status'),
];
}
List<AppDataColumn<Map<String, dynamic>>> _allColumns(ThemeData theme) {
final fields = definition.listFields;
return [
for (var i = 0; i < fields.length; i++)
AppDataColumn<Map<String, dynamic>>(
id: fields[i].key,
label: _fieldLabel(fields[i]),
sortKey: fields[i].key,
locked: i == 0,
flex: _columnFlex(fields[i]),
searchText: (row) => masterCellValue(row, fields[i]),
cellBuilder: (_, row) {
final field = fields[i];
if (field.key == 'is_asset_item') {
final isAsset = masterIsAssetItem(row['is_asset_item']);
return TableStatusBadge(
label: isAsset ? 'Asset' : 'Stock',
color: isAsset
? const Color(0xFF2563EB)
: const Color(0xFF64748B),
compact: true,
);
}
return Text(masterCellValue(row, field));
},
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (row) => masterStatusValue(row),
cellBuilder: (_, row) => AppStatusChip(
status: masterStatusValue(row),
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, row) {
final id = row['id']?.toString();
return AppTableActions(
children: [
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
enabled: id != null,
onPressed: () => onEdit(id!),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
color: theme.colorScheme.error,
enabled: !isDeleting,
onPressed: () => onDelete(row),
),
],
);
},
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableIdFor(definition)));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<Map<String, dynamic>>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
emptyMessage: 'No ${definition.title.toLowerCase()} found',
columns: [
...definition.listFields.map(
(field) => AppDataColumn<Map<String, dynamic>>(
label: field.key == 'is_asset_item' ? 'Type' : field.label,
flex: _columnFlex(field),
searchText: (row) => masterCellValue(row, field),
cellBuilder: (_, row) {
if (field.key == 'is_asset_item') {
final isAsset = masterIsAssetItem(row['is_asset_item']);
return TableStatusBadge(
label: isAsset ? 'Asset' : 'Stock',
color: isAsset
? const Color(0xFF2563EB)
: const Color(0xFF64748B),
compact: true,
);
}
return Text(masterCellValue(row, field));
},
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (row) => masterStatusValue(row),
cellBuilder: (_, row) => AppStatusChip(
status: masterStatusValue(row),
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, row) {
final id = row['id']?.toString();
return AppTableActions(
children: [
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
enabled: id != null,
onPressed: () => onEdit(id!),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
color: theme.colorScheme.error,
enabled: !isDeleting,
onPressed: () => onDelete(row),
),
],
);
},
),
],
columns: columns,
rows: items,
);
}

View File

@ -201,12 +201,10 @@ class _MasterQuickAddDropdownState<T>
}
if (!mounted) return;
showAppToastFromSnackBar(
showAppToast(
context,
SnackBar(
content:
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
),
'${_titleCase(masterQuickAddNoun(widget.masterId))} added',
type: AppToastType.success,
);
}

View File

@ -23,9 +23,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../providers/purchase_orders_provider.dart';
@ -133,6 +135,11 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _PoDataTable.tableId,
columns: _PoDataTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -482,7 +489,7 @@ class _FiltersBar extends StatelessWidget {
}
}
class _PoDataTable extends StatelessWidget {
class _PoDataTable extends ConsumerWidget {
const _PoDataTable({
required this.orders,
required this.onView,
@ -494,6 +501,20 @@ class _PoDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'purchase_orders_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'po_number',
label: 'PO Number',
required: true,
),
AppTableColumnOption(id: 'date', label: 'Date'),
AppTableColumnOption(id: 'vendor', label: 'Vendor'),
AppTableColumnOption(id: 'total', label: 'Total'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<PurchaseOrderModel> orders;
final ValueChanged<PurchaseOrderModel> onView;
final ValueChanged<PurchaseOrderModel>? onEdit;
@ -503,106 +524,129 @@ class _PoDataTable extends StatelessWidget {
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<PurchaseOrderModel>> _allColumns(ThemeData theme) {
return [
AppDataColumn(
id: 'po_number',
label: 'PO Number',
sortKey: 'po_number',
locked: true,
flex: 2,
searchText: (order) => order.poNo ?? '',
cellBuilder: (_, order) => AppTableCell.link(
order.poNo,
onTap: () => onView(order),
),
),
AppDataColumn(
id: 'date',
label: 'Date',
sortKey: 'date',
flex: 1,
searchText: (order) => DateFormatter.searchableDate(order.poDate),
sortValue: (order) => order.poDate,
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
),
AppDataColumn(
id: 'vendor',
label: 'Vendor',
sortKey: 'vendor',
flex: 2,
searchText: (order) => order.vendorName ?? '',
cellBuilder: (_, order) => Text(order.vendorName ?? ''),
),
AppDataColumn(
id: 'total',
label: 'Total',
sortKey: 'total',
flex: 1,
searchText: (order) => CurrencyFormatter.searchable(order.totalAmount),
sortValue: (order) => order.totalAmount,
cellBuilder: (_, order) => SizedBox(
width: double.infinity,
child: AppTableCell.text(
CurrencyFormatter.format(order.totalAmount),
textAlign: TextAlign.right,
),
),
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (order) => order.status,
cellBuilder: (_, order) {
final chip = PoStatusChip(
status: order.status,
compact: true,
forTable: true,
);
final reason = order.rejectReasonForDisplay;
if (reason == null) return chip;
return Tooltip(
message: 'Reject reason: $reason',
child: chip,
);
},
),
AppDataColumn(
id: 'actions',
label: 'Actions',
width: 88,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, order) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(order),
),
if (onNotify != null && order.canNotifyApprovers)
AppTableActionIcon(
tooltip: 'Notify approvers',
icon: Icons.notifications_outlined,
onPressed: () => onNotify!(order),
),
if (onApprove != null && order.canApprove)
AppTableActionIcon(
tooltip: 'Approve',
icon: Icons.check_circle_outline,
color: theme.colorScheme.primary,
onPressed: () => onApprove!(order),
),
if (onEdit != null && order.canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(order),
),
if (onDelete != null && order.canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(order),
),
],
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<PurchaseOrderModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'PO Number',
flex: 2,
searchText: (order) => order.poNo ?? '',
cellBuilder: (_, order) => AppTableCell.link(
order.poNo,
onTap: () => onView(order),
),
),
AppDataColumn(
label: 'Date',
flex: 1,
searchText: (order) => DateFormatter.searchableDate(order.poDate),
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
),
AppDataColumn(
label: 'Vendor',
flex: 2,
searchText: (order) => order.vendorName ?? '',
cellBuilder: (_, order) => Text(order.vendorName ?? ''),
),
AppDataColumn(
label: 'Total',
flex: 1,
searchText: (order) => CurrencyFormatter.searchable(order.totalAmount),
cellBuilder: (_, order) => SizedBox(
width: double.infinity,
child: AppTableCell.text(
CurrencyFormatter.format(order.totalAmount),
textAlign: TextAlign.right,
),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (order) => order.status,
cellBuilder: (_, order) {
final chip = PoStatusChip(
status: order.status,
compact: true,
forTable: true,
);
final reason = order.rejectReasonForDisplay;
if (reason == null) return chip;
return Tooltip(
message: 'Reject reason: $reason',
child: chip,
);
},
),
AppDataColumn(
label: 'Actions',
width: 88,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, order) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(order),
),
if (onNotify != null && order.canNotifyApprovers)
AppTableActionIcon(
tooltip: 'Notify approvers',
icon: Icons.notifications_outlined,
onPressed: () => onNotify!(order),
),
if (onApprove != null && order.canApprove)
AppTableActionIcon(
tooltip: 'Approve',
icon: Icons.check_circle_outline,
color: theme.colorScheme.primary,
onPressed: () => onApprove!(order),
),
if (onEdit != null && order.canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(order),
),
if (onDelete != null && order.canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(order),
),
],
),
),
],
columns: columns,
rows: orders,
);
}

View File

@ -1457,9 +1457,12 @@ class _PermissionMatrixTable extends ConsumerWidget {
wrapInCard: false,
columns: [
AppDataColumn(
id: 'module',
label: 'Module',
sortKey: 'module',
flex: 3,
enableSearch: false,
searchText: (module) => module.name,
cellBuilder: (context, module) {
final index = matrix.modules.indexOf(module);
final appearance =

View File

@ -10,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_date_range_popup.dart';
@ -21,6 +22,7 @@ import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
@ -157,6 +159,11 @@ class _DepreciationReportScreenState
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _ReportTable.tableId,
columns: _ReportTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -586,104 +593,153 @@ class _FiltersBarState extends State<_FiltersBar> {
}
}
class _ReportTable extends StatelessWidget {
class _ReportTable extends ConsumerWidget {
const _ReportTable({
required this.items,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
static const tableId = 'depreciation_report';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'asset_code',
label: 'Asset Code',
required: true,
),
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
AppTableColumnOption(id: 'category', label: 'Category'),
AppTableColumnOption(id: 'location', label: 'Location'),
AppTableColumnOption(id: 'purchase_date', label: 'Purchase Date'),
AppTableColumnOption(id: 'purchase_cost', label: 'Purchase Cost'),
AppTableColumnOption(id: 'annual', label: 'Annual'),
AppTableColumnOption(id: 'accumulated', label: 'Accumulated'),
AppTableColumnOption(id: 'book_value', label: 'Book Value'),
];
final List<DepreciationReportRow> items;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<DepreciationReportRow>> _allColumns(BuildContext context) {
return [
AppDataColumn(
id: 'asset_code',
label: 'Asset Code',
sortKey: 'asset_code',
locked: true,
flex: 2,
searchText: (row) => row.assetCode ?? '',
cellBuilder: (_, row) => AppTableCell.link(
row.assetCode,
onTap: row.id.trim().isEmpty
? null
: () => context.push('${RouteConstants.assets}/${row.id}'),
),
),
AppDataColumn(
id: 'asset_name',
label: 'Asset Name',
sortKey: 'asset_name',
flex: 3,
searchText: (row) => row.assetName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.assetName),
),
AppDataColumn(
id: 'category',
label: 'Category',
sortKey: 'category',
flex: 2,
searchText: (row) => row.categoryName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.categoryName),
),
AppDataColumn(
id: 'location',
label: 'Location',
sortKey: 'location',
flex: 2,
searchText: (row) => row.locationName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.locationName),
),
AppDataColumn(
id: 'purchase_date',
label: 'Purchase Date',
sortKey: 'purchase_date',
flex: 2,
searchText: (row) => DateFormatter.searchableDate(row.purchaseDate),
sortValue: (row) => row.purchaseDate,
cellBuilder: (_, row) =>
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
),
AppDataColumn(
id: 'purchase_cost',
label: 'Purchase Cost',
sortKey: 'purchase_cost',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost),
sortValue: (row) => row.purchaseCost,
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.purchaseCost),
textAlign: TextAlign.right,
),
),
AppDataColumn(
id: 'annual',
label: 'Annual',
sortKey: 'annual',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.searchable(row.annualDepreciation),
sortValue: (row) => row.annualDepreciation,
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.annualDepreciation),
textAlign: TextAlign.right,
),
),
AppDataColumn(
id: 'accumulated',
label: 'Accumulated',
sortKey: 'accumulated',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.searchable(row.accumulatedDepreciation),
sortValue: (row) => row.accumulatedDepreciation,
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.accumulatedDepreciation),
textAlign: TextAlign.right,
),
),
AppDataColumn(
id: 'book_value',
label: 'Book Value',
sortKey: 'book_value',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.searchable(row.bookValue),
sortValue: (row) => row.bookValue,
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.bookValue),
textAlign: TextAlign.right,
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(context), prefs);
return AppDataTable<DepreciationReportRow>(
wrapInCard: false,
rows: items,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 2,
searchText: (row) => row.assetCode ?? '',
cellBuilder: (_, row) => AppTableCell.link(
row.assetCode,
onTap: row.id.trim().isEmpty
? null
: () => context.push('${RouteConstants.assets}/${row.id}'),
),
),
AppDataColumn(
label: 'Asset Name',
flex: 3,
searchText: (row) => row.assetName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.assetName),
),
AppDataColumn(
label: 'Category',
flex: 2,
searchText: (row) => row.categoryName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.categoryName),
),
AppDataColumn(
label: 'Location',
flex: 2,
searchText: (row) => row.locationName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.locationName),
),
AppDataColumn(
label: 'Purchase Date',
flex: 2,
searchText: (row) => DateFormatter.searchableDate(row.purchaseDate),
cellBuilder: (_, row) =>
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
),
AppDataColumn(
label: 'Purchase Cost',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.purchaseCost),
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Annual',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.searchable(row.annualDepreciation),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.annualDepreciation),
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Accumulated',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.searchable(row.accumulatedDepreciation),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.accumulatedDepreciation),
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Book Value',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.searchable(row.bookValue),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.bookValue),
textAlign: TextAlign.right,
),
),
],
columns: columns,
);
}
}

View File

@ -89,9 +89,12 @@ class _MatrixGrid extends ConsumerWidget {
wrapInCard: true,
columns: [
AppDataColumn(
id: 'module',
label: 'Module',
sortKey: 'module',
flex: 3,
enableSearch: false,
searchText: (row) => row.name,
cellBuilder: (_, row) => AppTableCell.text(row.name),
),
...matrix.actionColumns.map(

View File

@ -6,12 +6,14 @@ import 'package:go_router/go_router.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/error_view.dart';
@ -63,6 +65,11 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _RoleDataTable.tableId,
columns: _RoleDataTable.columnOptions,
),
],
),
Expanded(
@ -128,7 +135,7 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
}
}
class _RoleDataTable extends StatelessWidget {
class _RoleDataTable extends ConsumerWidget {
const _RoleDataTable({
required this.roles,
required this.onOpen,
@ -136,47 +143,82 @@ class _RoleDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'roles_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'role_name',
label: 'Role Name',
required: true,
),
AppTableColumnOption(id: 'description', label: 'Description'),
AppTableColumnOption(id: 'users_count', label: 'Users Count'),
];
final List<RoleCardModel> roles;
final void Function(RoleCardModel role) onOpen;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<RoleCardModel>> _allColumns() {
return [
AppDataColumn(
id: 'role_name',
label: 'Role Name',
sortKey: 'role_name',
locked: true,
flex: 2,
searchText: (r) => r.name,
cellBuilder: (_, r) => Text(r.name),
),
AppDataColumn(
id: 'description',
label: 'Description',
sortKey: 'description',
flex: 3,
searchText: (r) => r.description ?? '',
cellBuilder: (_, r) => Text(r.description ?? ''),
),
AppDataColumn(
id: 'users_count',
label: 'Users Count',
sortKey: 'users_count',
flex: 1,
searchText: (r) => '${r.userCount}',
sortValue: (r) => r.userCount,
cellBuilder: (_, r) => Text('${r.userCount}'),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, r) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View Matrix',
icon: Icons.grid_view_outlined,
onPressed: () => onOpen(r),
),
],
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
return AppDataTable<RoleCardModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
AppDataColumn(
label: 'Description',
flex: 3,
searchText: (r) => r.description ?? '',
cellBuilder: (_, r) => Text(r.description ?? ''),
),
AppDataColumn(
label: 'Users Count',
flex: 1,
searchText: (r) => '${r.userCount}',
cellBuilder: (_, r) => Text('${r.userCount}'),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, r) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View Matrix',
icon: Icons.grid_view_outlined,
onPressed: () => onOpen(r),
),
],
),
),
],
columns: columns,
rows: roles,
);
}

View File

@ -21,6 +21,7 @@ import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/kpi_card.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/page_header.dart';
import '../widgets/user_rich_data_table.dart';
import '../providers/users_provider.dart';
@ -69,6 +70,11 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: UserRichDataTable.tableId,
columns: UserRichDataTable.columnOptions,
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => context.push(RouteConstants.userAdd),
icon: const Icon(Icons.person_add),

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
typedef UserTableActionsBuilder = Widget Function(
@ -11,7 +14,7 @@ typedef UserTableActionsBuilder = Widget Function(
ManagedUserModel user,
);
class UserRichDataTable extends StatelessWidget {
class UserRichDataTable extends ConsumerWidget {
const UserRichDataTable({
super.key,
required this.users,
@ -25,6 +28,8 @@ class UserRichDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'users_list';
final List<ManagedUserModel> users;
final UserTableActionsBuilder actionsBuilder;
final String? sortColumn;
@ -35,9 +40,92 @@ class UserRichDataTable extends StatelessWidget {
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(id: 'user', label: 'User', required: true),
AppTableColumnOption(id: 'employee_code', label: 'Employee Code'),
AppTableColumnOption(id: 'role', label: 'Role'),
AppTableColumnOption(id: 'department', label: 'Department'),
AppTableColumnOption(id: 'last_login', label: 'Last Login'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
List<AppDataColumn<ManagedUserModel>> _allColumns(ThemeData theme) {
return [
AppDataColumn(
id: 'user',
label: 'User',
sortKey: 'full_name',
locked: true,
flex: 3,
searchText: (user) => '${user.fullName} ${user.email}'.trim(),
cellBuilder: (_, user) => UserTableUserCell(user: user),
),
AppDataColumn(
id: 'employee_code',
label: 'Employee Code',
sortKey: 'employee_code',
flex: 1,
alignment: Alignment.centerRight,
searchText: (user) => user.employeeCode,
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
),
AppDataColumn(
id: 'role',
label: 'Role',
flex: 2,
padding: const EdgeInsets.only(left: 8),
searchText: (user) =>
'${user.roleNames.join(' ')} ${user.roleLabel}'.trim(),
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
),
AppDataColumn(
id: 'department',
label: 'Department',
flex: 2,
searchText: (user) => user.departmentName ?? '',
cellBuilder: (_, user) => Text(user.departmentLabel),
),
AppDataColumn(
id: 'last_login',
label: 'Last Login',
flex: 2,
searchText: (user) => DateFormatter.searchableDate(user.lastLoginAt),
cellBuilder: (_, user) => Text(
DateFormatter.formatUserLastLogin(user.lastLoginAt),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
AppDataColumn(
id: 'status',
label: 'Status',
flex: 1,
searchText: (user) => user.status,
cellBuilder: (_, user) => AppStatusChip(
status: user.status,
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (context, user) => actionsBuilder(context, user),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<ManagedUserModel>(
wrapInCard: wrapInCard,
@ -47,67 +135,7 @@ class UserRichDataTable extends StatelessWidget {
onServerSearchChanged: onServerSearchChanged,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'User',
sortKey: 'full_name',
flex: 3,
searchText: (user) =>
'${user.fullName} ${user.email}'.trim(),
cellBuilder: (_, user) => UserTableUserCell(user: user),
),
AppDataColumn(
label: 'Employee Code',
sortKey: 'employee_code',
flex: 1,
alignment: Alignment.centerRight,
searchText: (user) => user.employeeCode,
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
),
AppDataColumn(
label: 'Role',
flex: 2,
padding: const EdgeInsets.only(left: 8),
searchText: (user) =>
'${user.roleNames.join(' ')} ${user.roleLabel}'.trim(),
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
),
AppDataColumn(
label: 'Department',
flex: 2,
searchText: (user) => user.departmentName ?? '',
cellBuilder: (_, user) => Text(user.departmentLabel),
),
AppDataColumn(
label: 'Last Login',
flex: 2,
searchText: (user) =>
DateFormatter.searchableDate(user.lastLoginAt),
cellBuilder: (_, user) => Text(
DateFormatter.formatUserLastLogin(user.lastLoginAt),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (user) => user.status,
cellBuilder: (_, user) => AppStatusChip(
status: user.status,
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (context, user) => actionsBuilder(context, user),
),
],
columns: columns,
rows: users,
);
}

View File

@ -21,10 +21,12 @@ import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../providers/vendors_provider.dart';
import '../widgets/vendor_form_panel.dart';
@ -86,6 +88,11 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _VendorDataTable.tableId,
columns: _VendorDataTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -288,7 +295,7 @@ class _FiltersBar extends StatelessWidget {
}
}
class _VendorDataTable extends StatelessWidget {
class _VendorDataTable extends ConsumerWidget {
const _VendorDataTable({
required this.vendors,
required this.onView,
@ -298,6 +305,20 @@ class _VendorDataTable extends StatelessWidget {
this.onColumnSearchCleared,
});
static const tableId = 'vendors_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'code',
label: 'Code',
required: true,
),
AppTableColumnOption(id: 'name', label: 'Name'),
AppTableColumnOption(id: 'type', label: 'Type'),
AppTableColumnOption(id: 'gstin', label: 'GSTIN'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<VendorModel> vendors;
final ValueChanged<VendorModel> onView;
final ValueChanged<VendorModel>? onEdit;
@ -305,79 +326,100 @@ class _VendorDataTable extends StatelessWidget {
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<VendorModel>> _allColumns() {
return [
AppDataColumn(
id: 'code',
label: 'Code',
sortKey: 'vendor_code',
locked: true,
flex: 1,
searchText: (vendor) => vendor.vendorCode ?? '',
cellBuilder: (_, vendor) => AppTableCell.link(
vendor.vendorCode,
onTap: () => onView(vendor),
),
),
AppDataColumn(
id: 'name',
label: 'Name',
sortKey: 'vendor_name',
flex: 2,
searchText: (vendor) => vendor.vendorName,
cellBuilder: (_, vendor) => Text(vendor.vendorName),
),
AppDataColumn(
id: 'type',
label: 'Type',
sortKey: 'vendor_type',
flex: 2,
searchText: (vendor) => vendorTypeLabel(vendor.vendorType),
cellBuilder: (_, vendor) => Text(vendorTypeLabel(vendor.vendorType)),
),
AppDataColumn(
id: 'gstin',
label: 'GSTIN',
sortKey: 'gstin',
flex: 2,
searchText: (vendor) => vendor.gstin ?? '',
cellBuilder: (_, vendor) => Text(vendor.gstin ?? ''),
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (vendor) =>
vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
cellBuilder: (_, vendor) => AppStatusChip(
status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, vendor) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(vendor),
),
if (onEdit != null)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(vendor),
),
if (onDelete != null)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(vendor),
),
],
),
),
];
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
return AppDataTable<VendorModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'Code',
flex: 1,
searchText: (vendor) => vendor.vendorCode ?? '',
cellBuilder: (_, vendor) => AppTableCell.link(
vendor.vendorCode,
onTap: () => onView(vendor),
),
),
AppDataColumn(
label: 'Name',
flex: 2,
searchText: (vendor) => vendor.vendorName,
cellBuilder: (_, vendor) => Text(vendor.vendorName),
),
AppDataColumn(
label: 'Type',
flex: 2,
searchText: (vendor) => vendorTypeLabel(vendor.vendorType),
cellBuilder: (_, vendor) => Text(vendorTypeLabel(vendor.vendorType)),
),
AppDataColumn(
label: 'GSTIN',
flex: 2,
searchText: (vendor) => vendor.gstin ?? '',
cellBuilder: (_, vendor) => Text(vendor.gstin ?? ''),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (vendor) =>
vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
cellBuilder: (_, vendor) => AppStatusChip(
status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, vendor) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(vendor),
),
if (onEdit != null)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(vendor),
),
if (onDelete != null)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(vendor),
),
],
),
),
],
columns: columns,
rows: vendors,
);
}

View File

@ -0,0 +1,70 @@
import 'dart:convert';
/// Persisted visibility + order for one data table.
class TableColumnPrefs {
const TableColumnPrefs({
this.order = const [],
this.hidden = const {},
this.locked = const {},
});
/// Column ids in display order. Missing ids append in definition order.
final List<String> order;
/// Column ids that are hidden (must not include required/locked columns).
final Set<String> hidden;
/// User-locked column ids (cannot be hidden until unlocked).
final Set<String> locked;
static const empty = TableColumnPrefs();
bool isHidden(String id) => hidden.contains(id);
bool isLocked(String id, {required bool required}) =>
required || locked.contains(id);
TableColumnPrefs copyWith({
List<String>? order,
Set<String>? hidden,
Set<String>? locked,
}) {
return TableColumnPrefs(
order: order ?? this.order,
hidden: hidden ?? this.hidden,
locked: locked ?? this.locked,
);
}
Map<String, dynamic> toJson() => {
'order': order,
'hidden': hidden.toList(),
'locked': locked.toList(),
};
factory TableColumnPrefs.fromJson(Map<String, dynamic> json) {
return TableColumnPrefs(
order: (json['order'] as List?)?.map((e) => e.toString()).toList() ??
const [],
hidden: {
for (final e in (json['hidden'] as List?) ?? const []) e.toString(),
},
locked: {
for (final e in (json['locked'] as List?) ?? const []) e.toString(),
},
);
}
String encode() => jsonEncode(toJson());
static TableColumnPrefs decode(String? raw) {
if (raw == null || raw.trim().isEmpty) return empty;
try {
final map = jsonDecode(raw);
if (map is! Map) return empty;
return TableColumnPrefs.fromJson(Map<String, dynamic>.from(map));
} catch (_) {
return empty;
}
}
}

View File

@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/constants/storage_keys.dart';
import '../../core/theme/theme_provider.dart';
import '../models/table_column_prefs.dart';
final tableColumnPrefsProvider = StateNotifierProvider.family<
TableColumnPrefsNotifier, TableColumnPrefs, String>(
(ref, tableId) => TableColumnPrefsNotifier(
ref.watch(sharedPreferencesProvider),
tableId,
),
);
class TableColumnPrefsNotifier extends StateNotifier<TableColumnPrefs> {
TableColumnPrefsNotifier(this._prefs, this.tableId)
: super(TableColumnPrefs.empty) {
_load();
}
final SharedPreferences _prefs;
final String tableId;
String get _key => StorageKeys.tableColumns(tableId);
void _load() {
state = TableColumnPrefs.decode(_prefs.getString(_key));
}
Future<void> _persist(TableColumnPrefs next) async {
state = next;
await _prefs.setString(_key, next.encode());
}
Future<void> setOrder(List<String> order) =>
_persist(state.copyWith(order: List<String>.from(order)));
Future<void> setHidden(Set<String> hidden) =>
_persist(state.copyWith(hidden: Set<String>.from(hidden)));
Future<void> setLocked(Set<String> locked) =>
_persist(state.copyWith(locked: Set<String>.from(locked)));
Future<void> replace(TableColumnPrefs prefs) => _persist(prefs);
Future<void> reset() async {
state = TableColumnPrefs.empty;
await _prefs.remove(_key);
}
}

View File

@ -89,6 +89,12 @@ final routerProvider = Provider<GoRouter>((ref) {
return RouteConstants.dashboard;
}
// Legacy /assets /assetsmaster
final fullPath = state.uri.path;
if (fullPath == '/assets' || fullPath.startsWith('/assets/')) {
return fullPath.replaceFirst('/assets', RouteConstants.assets);
}
return null;
},
routes: [

View File

@ -106,10 +106,10 @@ const List<MenuItem> appMenuItems = [
module: 'reports',
),
MenuItem(
label: 'Support',
icon: Icons.support_agent_outlined,
label: 'Manage',
icon: Icons.admin_panel_settings_outlined,
route: RouteConstants.vendors,
module: 'support',
module: 'administrator',
children: [
MenuItem(
label: 'Vendors',
@ -119,7 +119,7 @@ const List<MenuItem> appMenuItems = [
),
MenuItem(
label: 'Users & Roles',
icon: Icons.admin_panel_settings_outlined,
icon: Icons.manage_accounts_outlined,
route: RouteConstants.usersRoleManagement,
module: 'users',
),
@ -145,18 +145,28 @@ const List<MenuItem> appMenuItems = [
),
];
/// True when [item] is the Support group (top-nav dropdown / sidebar section).
bool isSupportMenuItem(MenuItem item) =>
item.module == 'support' || item.label == 'Support';
/// True when [item] is the Manage group (sidebar / top-nav).
bool isAdministratorMenuItem(MenuItem item) =>
item.module == 'administrator' ||
item.module == 'support' ||
item.label == 'Manage' ||
item.label == 'Administrator' ||
item.label == 'Support';
/// Child routes that belong under Support (sidebar section flattening).
const Set<String> supportMenuRoutes = {
/// Child routes that belong under Manage.
const Set<String> administratorMenuRoutes = {
RouteConstants.vendors,
RouteConstants.usersRoleManagement,
RouteConstants.masterData,
RouteConstants.auditLogs,
RouteConstants.settings,
};
@Deprecated('Use isAdministratorMenuItem')
bool isSupportMenuItem(MenuItem item) => isAdministratorMenuItem(item);
@Deprecated('Use administratorMenuRoutes')
const Set<String> supportMenuRoutes = administratorMenuRoutes;
List<MenuItem> getVisibleMenuItems({
required List<String> permissions,
required UserRole role,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/utils/table_search.dart';
import '../models/table_column_prefs.dart';
import 'app_card.dart';
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
@ -16,18 +17,30 @@ class AppDataColumn<T> {
const AppDataColumn({
required this.label,
required this.cellBuilder,
this.id,
this.sortKey,
this.sortValue,
this.flex = 1,
this.width,
this.alignment = Alignment.centerLeft,
this.padding = EdgeInsets.zero,
this.searchText,
this.enableSearch,
this.locked = false,
this.includeInColumnSelector = true,
});
/// Stable id for column prefs (visibility / order). Defaults to [sortKey] or [label].
final String? id;
final String label;
final Widget Function(BuildContext context, T row) cellBuilder;
final String? sortKey;
/// Optional comparable used for client-side sorting when [AppDataTable.onSort]
/// is not provided. Falls back to [searchText] string comparison.
final Comparable? Function(T row)? sortValue;
final int flex;
/// When set, column uses a fixed width instead of [flex].
@ -43,7 +56,46 @@ class AppDataColumn<T> {
/// When null, search is enabled only if [searchText] is provided.
final bool? enableSearch;
/// When true, column cannot be hidden and shows a locked padlock in the selector.
final bool locked;
/// When false, column is always shown and omitted from the column selector
/// (typical for Actions).
final bool includeInColumnSelector;
bool get isSearchable => enableSearch ?? searchText != null;
String get columnId => id ?? sortKey ?? label;
}
/// Applies [prefs] order + visibility to [columns].
List<AppDataColumn<T>> resolveAppDataColumns<T>(
List<AppDataColumn<T>> columns,
TableColumnPrefs prefs,
) {
if (columns.isEmpty) return columns;
final byId = <String, AppDataColumn<T>>{
for (final c in columns) c.columnId: c,
};
final ordered = <AppDataColumn<T>>[];
final seen = <String>{};
for (final id in prefs.order) {
final col = byId[id];
if (col == null || seen.contains(id)) continue;
ordered.add(col);
seen.add(id);
}
for (final col in columns) {
if (seen.add(col.columnId)) ordered.add(col);
}
return ordered.where((col) {
if (!col.includeInColumnSelector) return true;
if (col.locked || prefs.locked.contains(col.columnId)) return true;
return !prefs.hidden.contains(col.columnId);
}).toList();
}
/// Shared column sizing: fixed [AppDataColumn.width] or flexible [AppDataColumn.flex].
@ -188,9 +240,32 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
bool _fullDatasetActive = false;
bool _ensureInFlight = false;
/// Client-side sort when [AppDataTable.onSort] is null.
String? _clientSortColumn;
bool _clientSortAscending = true;
bool get _usesFullDatasetMode =>
widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null;
bool get _usesClientSort => widget.onSort == null;
String? get _activeSortColumn =>
_usesClientSort ? _clientSortColumn : widget.sortColumn;
bool get _activeSortAscending =>
_usesClientSort ? _clientSortAscending : widget.sortAscending;
void _handleSort(String column, bool ascending) {
if (widget.onSort != null) {
widget.onSort!(column, ascending);
return;
}
setState(() {
_clientSortColumn = column;
_clientSortAscending = ascending;
});
}
@override
void dispose() {
_ensureDatasetDebouncer.dispose();
@ -253,6 +328,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
for (final entry in _queries.entries) {
final q = entry.value.trim().toLowerCase();
if (q.isEmpty) continue;
if (entry.key < 0 || entry.key >= widget.columns.length) continue;
final col = widget.columns[entry.key];
if (!col.isSearchable || col.searchText == null) continue;
active[entry.key] = q;
@ -269,10 +345,47 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
}).toList();
}
List<T> get _displayRows {
final rows = List<T>.from(_filteredRows);
final sortKey = _activeSortColumn;
if (sortKey == null) return rows;
AppDataColumn<T>? col;
for (final c in widget.columns) {
if (c.sortKey == sortKey) {
col = c;
break;
}
}
if (col == null) return rows;
Comparable? valueOf(T row) {
if (col!.sortValue != null) return col.sortValue!(row);
if (col.searchText != null) return col.searchText!(row).toLowerCase();
return null;
}
rows.sort((a, b) {
final av = valueOf(a);
final bv = valueOf(b);
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
try {
final cmp = av.compareTo(bv);
return _activeSortAscending ? cmp : -cmp;
} catch (_) {
final cmp = av.toString().compareTo(bv.toString());
return _activeSortAscending ? cmp : -cmp;
}
});
return rows;
}
@override
Widget build(BuildContext context) {
final showFilterRow = widget.columns.any((c) => c.isSearchable);
final filtered = _filteredRows;
final filtered = _displayRows;
// Keep header + column filters visible even when the API returns no rows,
// so users can refine or clear a server-side search.
@ -294,9 +407,9 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
final header = _TableHeaderRow<T>(
columns: widget.columns,
sortColumn: widget.sortColumn,
sortAscending: widget.sortAscending,
onSort: widget.onSort,
sortColumn: _activeSortColumn,
sortAscending: _activeSortAscending,
onSort: _handleSort,
);
final filterRow = showFilterRow
@ -453,6 +566,16 @@ class _TableHeaderRow<T> extends StatelessWidget {
if (col.sortKey == null || onSort == null) return label;
final IconData sortIcon;
final Color sortIconColor;
if (isSorted) {
sortIcon = sortAscending ? Icons.arrow_upward : Icons.arrow_downward;
sortIconColor = theme.colorScheme.primary;
} else {
sortIcon = Icons.unfold_more;
sortIconColor = theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.55);
}
return InkWell(
onTap: () => onSort(
col.sortKey!,
@ -462,14 +585,8 @@ class _TableHeaderRow<T> extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: label),
if (isSorted) ...[
const SizedBox(width: 4),
Icon(
sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: 4),
Icon(sortIcon, size: 14, color: sortIconColor),
],
),
);

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -58,32 +59,20 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
final Set<String> _manuallyCollapsedMenus = <String>{};
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
.where((item) => !menu.isSupportMenuItem(item))
.where((item) => !menu.isAdministratorMenuItem(item))
.toList();
/// Sidebar shows Support children flat under the SUPPORT section label.
/// Top nav keeps the Support parent as a dropdown (via [menuItems] as-is).
List<menu.MenuItem> get _supportMenuItems {
final items = <menu.MenuItem>[];
/// Collapsible Administrator group pinned above the user profile.
menu.MenuItem? get _administratorMenuItem {
for (final item in widget.menuItems) {
if (menu.isSupportMenuItem(item)) {
if (item.children.isNotEmpty) {
items.addAll(item.children);
} else {
items.add(item);
}
continue;
}
if (menu.supportMenuRoutes.contains(item.route) &&
item.children.isEmpty) {
items.add(item);
if (menu.isAdministratorMenuItem(item) && item.children.isNotEmpty) {
return item;
}
}
return items;
return null;
}
bool get _hasSupportSection =>
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
bool get _hasAdministratorMenu => _administratorMenuItem != null;
bool _routeMatches(String route) {
final current = widget.currentRoute;
@ -113,9 +102,13 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
return _expandedMenus.contains(item.route) || _isGroupActive(item);
}
Iterable<menu.MenuItem> get _allGroupItems =>
[..._primaryMenuItems, ..._supportMenuItems]
.where((item) => item.children.isNotEmpty);
Iterable<menu.MenuItem> get _allGroupItems sync* {
for (final item in _primaryMenuItems) {
if (item.children.isNotEmpty) yield item;
}
final admin = _administratorMenuItem;
if (admin != null) yield admin;
}
/// Accordion: keep only [keepRoute] expanded; collapse every other group.
void _expandOnly(String keepRoute) {
@ -211,27 +204,37 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
children: [
if (isNarrow) const SizedBox(height: 4),
..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow),
if (_hasSupportSection) ...[
const SizedBox(height: 16),
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
if (AppConstants.showNotificationsMenu)
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
..._buildNavItems(
_supportMenuItems,
isNarrow: isNarrow,
if (AppConstants.showNotificationsMenu)
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
],
],
),
),
),
if (_hasAdministratorMenu) ...[
Padding(
padding: EdgeInsets.fromLTRB(
isNarrow ? 8 : 12,
4,
isNarrow ? 8 : 12,
4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildNavItems(
[_administratorMenuItem!],
isNarrow: isNarrow,
expandUpward: true,
),
),
),
],
_buildUserProfile(context, isNarrow: isNarrow),
],
);
@ -245,6 +248,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
List<Widget> _buildNavItems(
List<menu.MenuItem> items, {
required bool isNarrow,
bool expandUpward = false,
}) {
return items.map((item) {
if (isNarrow && item.children.isNotEmpty) {
@ -260,33 +264,46 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
if (item.children.isNotEmpty && !isNarrow) {
final expanded = _isGroupExpanded(item);
final active = _isGroupActive(item);
final childrenPanel = AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
alignment:
expandUpward ? Alignment.bottomCenter : Alignment.topCenter,
child: expanded
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in item.children)
_SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
],
)
: const SizedBox(width: double.infinity),
);
final header = _SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
),
if (expanded)
...item.children.map(
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
),
],
children: expandUpward
? [childrenPanel, header]
: [header, childrenPanel],
);
}
@ -554,6 +571,32 @@ class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
final anchorSize = renderBox.size;
const panelWidth = 220.0;
const panelHeaderHeight = 42.0;
const panelDividerHeight = 1.0;
final panelHeight = panelHeaderHeight +
panelDividerHeight +
(widget.item.children.length * _sidebarItemHeight);
final media = MediaQuery.of(context);
final screenSize = media.size;
final viewPadding = media.viewPadding;
const edgePadding = 8.0;
final minTop = viewPadding.top + edgePadding;
final maxBottom = screenSize.height - viewPadding.bottom - edgePadding;
final belowSpace = maxBottom - anchorTopLeft.dy;
final aboveSpace = anchorTopLeft.dy + anchorSize.height - minTop;
final showAbove = belowSpace < panelHeight && aboveSpace > belowSpace;
var top = showAbove
? (anchorTopLeft.dy + anchorSize.height - panelHeight)
: anchorTopLeft.dy;
top = top.clamp(minTop, math.max(minTop, maxBottom - panelHeight));
final maxLeft = math.max(
edgePadding,
screenSize.width - panelWidth - anchorSize.width - edgePadding,
);
final left = anchorTopLeft.dx.clamp(edgePadding, maxLeft);
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
@ -561,8 +604,8 @@ class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
final primary = theme.colorScheme.primary;
return Positioned(
left: anchorTopLeft.dx,
top: anchorTopLeft.dy,
left: left,
top: top,
child: TapRegion(
onTapOutside: (_) => _removeOverlay(),
child: MouseRegion(
@ -807,27 +850,6 @@ class _LogoDivider extends StatelessWidget {
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 12, bottom: 8),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
);
}
}
class _SidebarNavItem extends StatelessWidget {
const _SidebarNavItem({
required this.icon,
@ -940,12 +962,15 @@ class _SidebarNavItem extends StatelessWidget {
),
),
if (showChevron)
Icon(
chevronExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
AnimatedRotation(
turns: chevronExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
child: Icon(
Icons.keyboard_arrow_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),

View File

@ -0,0 +1,491 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_typography.dart';
import '../models/table_column_prefs.dart';
import '../providers/table_column_prefs_provider.dart';
import 'app_data_table.dart';
/// Column definition used by the selector (label + flags, no cell builders).
class AppTableColumnOption {
const AppTableColumnOption({
required this.id,
required this.label,
this.required = false,
});
final String id;
final String label;
/// Permanently locked cannot hide or unlock.
final bool required;
}
AppTableColumnOption appTableColumnOptionFrom<T>(AppDataColumn<T> column) {
return AppTableColumnOption(
id: column.columnId,
label: column.label,
required: column.locked,
);
}
/// Toolbar button that opens the column visibility / reorder menu.
class AppTableColumnSelectorButton extends ConsumerStatefulWidget {
const AppTableColumnSelectorButton({
super.key,
required this.tableId,
required this.columns,
this.tooltip = 'Columns',
});
final String tableId;
final List<AppTableColumnOption> columns;
final String tooltip;
@override
ConsumerState<AppTableColumnSelectorButton> createState() =>
_AppTableColumnSelectorButtonState();
}
class _AppTableColumnSelectorButtonState
extends ConsumerState<AppTableColumnSelectorButton> {
static const double _menuWidth = 340;
final OverlayPortalController _portalController = OverlayPortalController();
bool _isOpen = false;
void _toggle() {
if (_portalController.isShowing) {
_close();
} else {
_portalController.show();
setState(() => _isOpen = true);
}
}
void _close() {
if (!_portalController.isShowing && !_isOpen) return;
if (_portalController.isShowing) {
_portalController.hide();
}
if (_isOpen) {
setState(() => _isOpen = false);
}
}
Widget _buildOverlay(BuildContext context, OverlayChildLayoutInfo info) {
final scheme = Theme.of(context).colorScheme;
final anchorRect = MatrixUtils.transformRect(
info.childPaintTransform,
Offset.zero & info.childSize,
);
final maxLeft = (info.overlaySize.width - _menuWidth - 8)
.clamp(8.0, double.infinity);
final left = (anchorRect.right - _menuWidth).clamp(8.0, maxLeft);
final top = (anchorRect.bottom + 6).clamp(
8.0,
math.max(8.0, info.overlaySize.height - 120),
);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _close,
),
),
Positioned(
left: left.toDouble(),
top: top.toDouble(),
width: _menuWidth,
child: _ColumnSelectorOpenAnimation(
child: Material(
elevation: 8,
shadowColor: scheme.shadow.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
color: scheme.surface,
clipBehavior: Clip.antiAlias,
child: _ColumnSelectorPanel(
tableId: widget.tableId,
columns: widget.columns,
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(widget.tableId));
final selectedCount = widget.columns.where((c) {
if (c.required || prefs.locked.contains(c.id)) return true;
return !prefs.hidden.contains(c.id);
}).length;
final activeColor = theme.colorScheme.secondary;
return OverlayPortal.overlayChildLayoutBuilder(
controller: _portalController,
overlayChildBuilder: _buildOverlay,
child: Tooltip(
message: widget.tooltip,
child: OutlinedButton.icon(
onPressed: _toggle,
style: OutlinedButton.styleFrom(
backgroundColor:
_isOpen ? activeColor.withValues(alpha: 0.08) : null,
foregroundColor:
_isOpen ? activeColor : theme.colorScheme.onSurface,
side: BorderSide(
color: _isOpen
? activeColor.withValues(alpha: 0.45)
: theme.colorScheme.outline.withValues(alpha: 0.35),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
icon: Icon(
Icons.view_column_outlined,
size: 18,
color: _isOpen ? activeColor : null,
),
label: Text('Columns ($selectedCount)'),
),
),
);
}
}
/// Fade + slight slide/scale when the columns menu opens.
class _ColumnSelectorOpenAnimation extends StatelessWidget {
const _ColumnSelectorOpenAnimation({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
builder: (context, t, child) {
return Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(0, (1 - t) * -6),
child: Transform.scale(
alignment: Alignment.topRight,
scale: 0.96 + (0.04 * t),
child: child,
),
),
);
},
child: child,
);
}
}
class _ColumnSelectorPanel extends ConsumerStatefulWidget {
const _ColumnSelectorPanel({
required this.tableId,
required this.columns,
});
final String tableId;
final List<AppTableColumnOption> columns;
@override
ConsumerState<_ColumnSelectorPanel> createState() =>
_ColumnSelectorPanelState();
}
class _ColumnSelectorPanelState extends ConsumerState<_ColumnSelectorPanel> {
late List<String> _order;
late Set<String> _hidden;
late Set<String> _locked;
@override
void initState() {
super.initState();
_syncFromPrefs(ref.read(tableColumnPrefsProvider(widget.tableId)));
}
void _syncFromPrefs(TableColumnPrefs prefs) {
final byId = {for (final c in widget.columns) c.id: c};
final ordered = <String>[];
final seen = <String>{};
for (final id in prefs.order) {
if (byId.containsKey(id) && seen.add(id)) ordered.add(id);
}
for (final c in widget.columns) {
if (seen.add(c.id)) ordered.add(c.id);
}
_order = ordered;
_hidden = Set<String>.from(prefs.hidden)
..removeWhere((id) => byId[id]?.required == true);
_locked = {
for (final id in prefs.locked)
if (byId.containsKey(id) && byId[id]?.required != true) id,
};
}
AppTableColumnOption _option(String id) =>
widget.columns.firstWhere((c) => c.id == id);
bool _isLocked(String id) {
final col = _option(id);
return col.required || _locked.contains(id);
}
bool _isVisible(String id) {
if (_isLocked(id)) return true;
return !_hidden.contains(id);
}
int get _selectedCount =>
_order.where(_isVisible).length;
Future<void> _persist() async {
await ref.read(tableColumnPrefsProvider(widget.tableId).notifier).replace(
TableColumnPrefs(
order: List<String>.from(_order),
hidden: Set<String>.from(_hidden),
locked: Set<String>.from(_locked),
),
);
}
Future<void> _toggleVisible(String id, bool visible) async {
if (_isLocked(id) && !visible) return;
setState(() {
if (visible) {
_hidden.remove(id);
} else {
_hidden.add(id);
}
});
await _persist();
}
Future<void> _toggleLocked(String id) async {
final col = _option(id);
if (col.required) return;
setState(() {
if (_locked.contains(id)) {
_locked.remove(id);
} else {
_locked.add(id);
_hidden.remove(id);
}
});
await _persist();
}
Future<void> _reorder(int oldIndex, int newIndex) async {
setState(() {
final id = _order.removeAt(oldIndex);
_order.insert(newIndex, id);
});
await _persist();
}
Future<void> _selectAll() async {
setState(() => _hidden.clear());
await _persist();
}
Future<void> _hideAll() async {
setState(() {
_hidden = {
for (final id in _order)
if (!_isLocked(id)) id,
};
});
await _persist();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surface,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 360),
child: ReorderableListView.builder(
shrinkWrap: true,
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
itemCount: _order.length,
onReorderItem: _reorder,
itemBuilder: (context, index) {
final id = _order[index];
final col = _option(id);
final locked = _isLocked(id);
final visible = _isVisible(id);
return Material(
key: ValueKey(id),
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.drag_handle,
size: 20,
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
),
),
SizedBox(
height: 22,
width: 36,
child: FittedBox(
fit: BoxFit.contain,
child: Switch(
value: visible,
onChanged: locked && visible
? null
: (v) => _toggleVisible(id, v),
activeThumbColor: scheme.onPrimary,
activeTrackColor: scheme.primary,
inactiveThumbColor: scheme.surface,
inactiveTrackColor: scheme.onSurfaceVariant
.withValues(alpha: 0.28),
trackOutlineColor:
WidgetStateProperty.resolveWith(
(states) {
if (states.contains(WidgetState.selected)) {
return Colors.transparent;
}
return scheme.outline.withValues(alpha: 0.4);
},
),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
col.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.body3(
weight: AppTypography.medium,
color: scheme.onSurface,
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed:
col.required ? null : () => _toggleLocked(id),
icon: Icon(
locked
? Icons.lock_outline
: Icons.lock_open_outlined,
size: 18,
color: locked
? scheme.onSurface
: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
),
],
),
),
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
child: Row(
children: [
InkWell(
onTap: _selectAll,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
vertical: 2,
),
child: Text(
'Select All',
style: AppTypography.label2(
weight: AppTypography.semiBold,
color: scheme.primary,
),
),
),
),
Text(
' / ',
style: AppTypography.label2(
weight: AppTypography.semiBold,
color: scheme.primary,
),
),
InkWell(
onTap: _hideAll,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
vertical: 2,
),
child: Text(
'Hide All',
style: AppTypography.label2(
weight: AppTypography.semiBold,
color: scheme.primary,
),
),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$_selectedCount selected',
style: AppTypography.caption1(
weight: AppTypography.semiBold,
color: scheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}

View File

@ -136,6 +136,7 @@ AppToastType inferToastType(
lower.contains('saved') ||
lower.contains('updated') ||
lower.contains('created') ||
lower.contains('added') ||
lower.contains('deleted') ||
lower.contains('uploaded') ||
lower.contains('downloaded') ||