From 5a572be3c629e018ef6e8b3500c9a11ab56ce0b4 Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Tue, 14 Jul 2026 17:24:24 +0530 Subject: [PATCH] table generia search --- .../providers/assets_provider.dart | 4 +- .../screens/asset_list_screen.dart | 9 + .../providers/audit_provider.dart | 4 +- .../screens/audit_logs_screen.dart | 13 + .../presentation/providers/grn_provider.dart | 4 +- .../presentation/screens/grn_list_screen.dart | 6 + .../providers/master_provider.dart | 1 + .../screens/master_list_screen.dart | 3 + .../providers/purchase_orders_provider.dart | 4 +- .../screens/purchase_order_list_screen.dart | 6 + .../presentation/providers/rbac_provider.dart | 4 +- .../screens/users_role_management_screen.dart | 5 + .../depreciation_report_provider.dart | 1 + .../screens/depreciation_report_screen.dart | 13 + .../providers/roles_provider.dart | 4 +- .../screens/permission_matrix_screen.dart | 75 ++- .../screens/role_list_screen.dart | 5 +- .../providers/users_provider.dart | 4 +- .../widgets/user_rich_data_table.dart | 9 + .../providers/vendors_provider.dart | 4 +- .../screens/vendor_list_screen.dart | 7 + lib/shared/widgets/app_data_table.dart | 439 ++++++++++++++---- 22 files changed, 484 insertions(+), 140 deletions(-) diff --git a/lib/modules/assets/presentation/providers/assets_provider.dart b/lib/modules/assets/presentation/providers/assets_provider.dart index f0bc08b..db8908e 100644 --- a/lib/modules/assets/presentation/providers/assets_provider.dart +++ b/lib/modules/assets/presentation/providers/assets_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; import '../../data/repositories/asset_repository_impl.dart'; @@ -94,7 +96,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: search, page: 1)); + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } void setPage(int page) { diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 06345a5..da80b36 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -477,6 +477,7 @@ class _AssetDataTable extends StatelessWidget { AppDataColumn( label: 'Asset Code', flex: 1, + searchText: (asset) => asset.assetCode ?? '', cellBuilder: (_, asset) { final code = asset.assetCode; if (code == null || code.isEmpty) return const Text('—'); @@ -486,21 +487,26 @@ class _AssetDataTable extends StatelessWidget { 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: 'Plant', flex: 1, + searchText: (asset) => asset.plantName ?? '', cellBuilder: (_, asset) => Text(asset.plantName ?? '—'), ), AppDataColumn( label: 'Warranty', flex: 1, + searchText: (asset) => + DateFormatter.displayDate(asset.warrantyExpiryDate), cellBuilder: (_, asset) => Text( DateFormatter.displayDate(asset.warrantyExpiryDate), ), @@ -508,6 +514,8 @@ class _AssetDataTable extends StatelessWidget { AppDataColumn( label: 'Status', flex: 1, + searchText: (asset) => + asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', cellBuilder: (_, asset) => AppStatusChip( status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', compact: true, @@ -518,6 +526,7 @@ class _AssetDataTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, + enableSearch: false, cellBuilder: (_, asset) => AppTableActions( children: [ AppTableActionIcon( diff --git a/lib/modules/audit/presentation/providers/audit_provider.dart b/lib/modules/audit/presentation/providers/audit_provider.dart index a8553d5..db6e389 100644 --- a/lib/modules/audit/presentation/providers/audit_provider.dart +++ b/lib/modules/audit/presentation/providers/audit_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/audit_log_model.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../data/repositories/audit_repository_impl.dart'; @@ -143,7 +145,7 @@ class AuditLogsListNotifier extends AsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: search, page: 1)); + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } void setPage(int page) { diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 7e69475..d6bef42 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -418,6 +418,7 @@ class _AuditDataTable extends StatelessWidget { AppDataColumn( label: 'When', flex: 2, + searchText: (row) => DateFormatter.displayDateTime(row.performedAt), cellBuilder: (_, row) => AppTableCell.text( DateFormatter.displayDateTime(row.performedAt), ), @@ -425,6 +426,7 @@ class _AuditDataTable extends StatelessWidget { AppDataColumn( label: 'Action', flex: 1, + searchText: (row) => row.action, cellBuilder: (_, row) => AppTableCell.child( AppStatusChip( status: row.action, @@ -436,26 +438,36 @@ class _AuditDataTable extends StatelessWidget { AppDataColumn( label: 'Table', flex: 2, + searchText: (row) => row.tableName, cellBuilder: (_, row) => AppTableCell.text(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 = []; + if (row.hasOldValue) parts.add('old'); + if (row.hasNewValue) parts.add('new'); + return parts.isEmpty ? '' : parts.join(' / '); + }, cellBuilder: (_, row) { final parts = []; if (row.hasOldValue) parts.add('old'); @@ -469,6 +481,7 @@ class _AuditDataTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, + enableSearch: false, cellBuilder: (_, row) => AppTableActions( children: [ AppTableActionIcon( diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index ec5bd29..82bcc27 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/grn_model.dart'; import '../../data/repositories/grn_repository_impl.dart'; @@ -93,7 +95,7 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: search, page: 1)); + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } void setStatusFilter(String? status) { diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index a179546..bb01be2 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -239,26 +239,31 @@ class _GrnDataTable extends StatelessWidget { AppDataColumn( label: 'GRN Number', flex: 2, + searchText: (grn) => grn.grnNumber ?? '', cellBuilder: (_, grn) => Text(grn.grnNumber ?? '—'), ), AppDataColumn( label: 'Date', flex: 1, + searchText: (grn) => DateFormatter.displayDate(grn.grnDate), cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)), ), AppDataColumn( label: 'PO Number', flex: 2, + searchText: (grn) => grn.poNumber ?? '', cellBuilder: (_, grn) => Text(grn.poNumber ?? '—'), ), AppDataColumn( label: 'Vendor', flex: 2, + searchText: (grn) => grn.vendorName ?? '', cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'), ), AppDataColumn( label: 'Status', flex: 1, + searchText: (grn) => grn.status, cellBuilder: (_, grn) => GrnStatusChip( status: grn.status, compact: true, @@ -268,6 +273,7 @@ class _GrnDataTable extends StatelessWidget { AppDataColumn( label: 'Actions', flex: 1, + enableSearch: false, cellBuilder: (_, grn) => AppTableActions( children: [ AppTableActionIcon( diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index 7817376..394c114 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../assets/data/repositories/asset_repository_impl.dart'; import '../../data/repositories/master_repository_impl.dart'; diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index 1fc3283..8736cb2 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -277,12 +277,14 @@ class _MasterListTable extends StatelessWidget { (field) => AppDataColumn>( label: field.label, flex: _columnFlex(field), + searchText: (row) => masterCellValue(row, field), cellBuilder: (_, row) => Text(masterCellValue(row, field)), ), ), AppDataColumn( label: 'Status', flex: 1, + searchText: (row) => masterStatusValue(row), cellBuilder: (_, row) => AppStatusChip( status: masterStatusValue(row), compact: true, @@ -293,6 +295,7 @@ class _MasterListTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, + enableSearch: false, cellBuilder: (_, row) { final id = row['id']?.toString(); return AppTableActions( diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index f0d53bb..e369095 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/purchase_order_model.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; @@ -95,7 +97,7 @@ class PurchaseOrdersListNotifier void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: search, page: 1)); + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } void setStatusFilter(String? status) { diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 9c90e52..f1fd23b 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -332,27 +332,32 @@ class _PoDataTable extends StatelessWidget { AppDataColumn( label: 'PO Number', flex: 2, + searchText: (order) => order.poNo ?? '', cellBuilder: (_, order) => Text(order.poNo ?? '—'), ), AppDataColumn( label: 'Date', flex: 1, + searchText: (order) => DateFormatter.displayDate(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.format(order.totalAmount), cellBuilder: (_, order) => Text(CurrencyFormatter.format(order.totalAmount)), ), AppDataColumn( label: 'Status', flex: 1, + searchText: (order) => order.status, cellBuilder: (_, order) => PoStatusChip( status: order.status, compact: true, @@ -363,6 +368,7 @@ class _PoDataTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, + enableSearch: false, cellBuilder: (_, order) => AppTableActions( children: [ AppTableActionIcon( diff --git a/lib/modules/rbac/presentation/providers/rbac_provider.dart b/lib/modules/rbac/presentation/providers/rbac_provider.dart index e2a7003..853dc39 100644 --- a/lib/modules/rbac/presentation/providers/rbac_provider.dart +++ b/lib/modules/rbac/presentation/providers/rbac_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../domain/entities/rbac_entities.dart'; enum RbacTab { users, roles, permissions } @@ -114,7 +116,7 @@ class RbacNotifier extends StateNotifier { void setTab(RbacTab tab) => state = state.copyWith(selectedTab: tab, currentPage: 0); void setSearch(String query) => - state = state.copyWith(searchQuery: query, currentPage: 0); + state = state.copyWith(searchQuery: TableSearch.normalize(query), currentPage: 0); void setRoleFilter(String filter) => state = state.copyWith(roleFilter: filter, currentPage: 0); diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index 45bda0c..d63ac24 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -1425,6 +1425,7 @@ class _PermissionMatrixTable extends ConsumerWidget { AppDataColumn( label: 'Module', flex: 3, + searchText: (module) => '${module.name} ${module.code}', cellBuilder: (context, module) { final index = matrix.modules.indexOf(module); final appearance = @@ -1463,6 +1464,10 @@ class _PermissionMatrixTable extends ConsumerWidget { label: permissionActionLabel(action), flex: 1, alignment: Alignment.center, + searchText: (module) { + final checked = module.granted[action] ?? false; + return checked ? 'yes granted true' : 'no denied false'; + }, cellBuilder: (context, module) { final checked = module.granted[action] ?? false; return Checkbox( diff --git a/lib/modules/reports/presentation/providers/depreciation_report_provider.dart b/lib/modules/reports/presentation/providers/depreciation_report_provider.dart index 6940846..4b36879 100644 --- a/lib/modules/reports/presentation/providers/depreciation_report_provider.dart +++ b/lib/modules/reports/presentation/providers/depreciation_report_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../data/repositories/reports_repository_impl.dart'; import '../../domain/entities/depreciation_report.dart'; diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index 60b63ec..00df0a8 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -627,26 +627,31 @@ class _ReportTable extends StatelessWidget { AppDataColumn( label: 'Asset Code', flex: 2, + searchText: (row) => row.assetCode ?? '', cellBuilder: (_, row) => AppTableCell.text(row.assetCode), ), 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: 'Plant', flex: 2, + searchText: (row) => row.plantName ?? '', cellBuilder: (_, row) => AppTableCell.text(row.plantName), ), AppDataColumn( label: 'Purchase Date', flex: 2, + searchText: (row) => DateFormatter.displayDate(row.purchaseDate), cellBuilder: (_, row) => AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)), ), @@ -654,6 +659,7 @@ class _ReportTable extends StatelessWidget { label: 'Purchase Cost', flex: 2, alignment: Alignment.centerRight, + searchText: (row) => CurrencyFormatter.format(row.purchaseCost), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.purchaseCost), textAlign: TextAlign.right, @@ -662,12 +668,14 @@ class _ReportTable extends StatelessWidget { AppDataColumn( label: 'Method', flex: 2, + searchText: (row) => row.depreciationMethod ?? '', cellBuilder: (_, row) => AppTableCell.text(row.depreciationMethod), ), AppDataColumn( label: 'Rate %', flex: 1, alignment: Alignment.centerRight, + searchText: (row) => row.depreciationRate?.toStringAsFixed(2) ?? '', cellBuilder: (_, row) => AppTableCell.text( row.depreciationRate?.toStringAsFixed(2), textAlign: TextAlign.right, @@ -677,6 +685,8 @@ class _ReportTable extends StatelessWidget { label: 'Annual', flex: 2, alignment: Alignment.centerRight, + searchText: (row) => + CurrencyFormatter.format(row.annualDepreciation), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.annualDepreciation), textAlign: TextAlign.right, @@ -686,6 +696,8 @@ class _ReportTable extends StatelessWidget { label: 'Accumulated', flex: 2, alignment: Alignment.centerRight, + searchText: (row) => + CurrencyFormatter.format(row.accumulatedDepreciation), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.accumulatedDepreciation), textAlign: TextAlign.right, @@ -695,6 +707,7 @@ class _ReportTable extends StatelessWidget { label: 'Book Value', flex: 2, alignment: Alignment.centerRight, + searchText: (row) => CurrencyFormatter.format(row.bookValue), cellBuilder: (_, row) => AppTableCell.text( CurrencyFormatter.format(row.bookValue), textAlign: TextAlign.right, diff --git a/lib/modules/roles/presentation/providers/roles_provider.dart b/lib/modules/roles/presentation/providers/roles_provider.dart index a21bce5..46e7518 100644 --- a/lib/modules/roles/presentation/providers/roles_provider.dart +++ b/lib/modules/roles/presentation/providers/roles_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../data/repositories/role_repository_impl.dart'; @@ -125,7 +127,7 @@ class RolesListNotifier extends AsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - state = AsyncData(current.copyWith(search: search, page: 1)); + state = AsyncData(current.copyWith(search: TableSearch.normalize(search), page: 1)); } void setPage(int page) { diff --git a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart index 2264464..7c0d3f3 100644 --- a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart +++ b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart @@ -84,53 +84,36 @@ class _MatrixGrid extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return AppCard( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columns: [ - const DataColumn(label: Text('Module')), - ...matrix.actionColumns.map( - (action) => DataColumn(label: Text(permissionActionLabel(action))), - ), - ], - rows: matrix.modules - .map( - (row) => DataRow( - cells: [ - DataCell(AppTableCell.text(row.name)), - ...matrix.actionColumns.map( - (action) => DataCell( - _actionToggle( - roleId, - row, - action, - row.granted[action] ?? false, - ref, - ), - ), - ), - ], - ), - ) - .toList(), + return AppDataTable( + wrapInCard: true, + columns: [ + AppDataColumn( + label: 'Module', + flex: 3, + searchText: (row) => row.name, + cellBuilder: (_, row) => AppTableCell.text(row.name), ), - ), - ); - } - - Widget _actionToggle( - String roleId, - PermissionMatrixModuleRow row, - String action, - bool value, - WidgetRef ref, - ) { - return Checkbox( - value: value, - onChanged: (checked) => ref - .read(permissionMatrixProvider(roleId).notifier) - .toggleAction(row.moduleId, action, checked ?? false), + ...matrix.actionColumns.map( + (action) => AppDataColumn( + label: permissionActionLabel(action), + flex: 1, + alignment: Alignment.center, + searchText: (row) { + final checked = row.granted[action] ?? false; + return checked ? 'yes granted true' : 'no denied false'; + }, + cellBuilder: (_, row) => Checkbox( + value: row.granted[action] ?? false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (checked) => ref + .read(permissionMatrixProvider(roleId).notifier) + .toggleAction(row.moduleId, action, checked ?? false), + ), + ), + ), + ], + rows: matrix.modules, ); } } diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 1c6286c..28d0aa1 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -120,21 +120,24 @@ class _RoleDataTable extends StatelessWidget { return AppDataTable( wrapInCard: false, columns: [ - AppDataColumn(label: 'Role Name', flex: 2, cellBuilder: (_, r) => Text(r.name)), + 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( diff --git a/lib/modules/users/presentation/providers/users_provider.dart b/lib/modules/users/presentation/providers/users_provider.dart index ce4fee1..69841d2 100644 --- a/lib/modules/users/presentation/providers/users_provider.dart +++ b/lib/modules/users/presentation/providers/users_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../data/repositories/user_repository_impl.dart'; @@ -147,7 +149,7 @@ class UsersListNotifier extends AsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: search, page: 1)); + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } void setPage(int page) { diff --git a/lib/modules/users/presentation/widgets/user_rich_data_table.dart b/lib/modules/users/presentation/widgets/user_rich_data_table.dart index aba61ec..3d9cc4c 100644 --- a/lib/modules/users/presentation/widgets/user_rich_data_table.dart +++ b/lib/modules/users/presentation/widgets/user_rich_data_table.dart @@ -43,6 +43,8 @@ class UserRichDataTable extends StatelessWidget { label: 'User', sortKey: 'full_name', flex: 3, + searchText: (user) => + '${user.fullName} ${user.email}'.trim(), cellBuilder: (_, user) => UserTableUserCell(user: user), ), AppDataColumn( @@ -50,22 +52,27 @@ class UserRichDataTable extends StatelessWidget { 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(' '), cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), ), AppDataColumn( label: 'Department', flex: 2, + searchText: (user) => user.departmentLabel, cellBuilder: (_, user) => Text(user.departmentLabel), ), AppDataColumn( label: 'Last Login', flex: 2, + searchText: (user) => + DateFormatter.formatUserLastLogin(user.lastLoginAt), cellBuilder: (_, user) => Text( DateFormatter.formatUserLastLogin(user.lastLoginAt), style: theme.textTheme.bodyMedium?.copyWith( @@ -76,6 +83,7 @@ class UserRichDataTable extends StatelessWidget { AppDataColumn( label: 'Status', flex: 1, + searchText: (user) => user.status, cellBuilder: (_, user) => AppStatusChip( status: user.status, compact: true, @@ -86,6 +94,7 @@ class UserRichDataTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, + enableSearch: false, cellBuilder: (context, user) => actionsBuilder(context, user), ), ], diff --git a/lib/modules/vendors/presentation/providers/vendors_provider.dart b/lib/modules/vendors/presentation/providers/vendors_provider.dart index ac9569f..317c9f6 100644 --- a/lib/modules/vendors/presentation/providers/vendors_provider.dart +++ b/lib/modules/vendors/presentation/providers/vendors_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/utils/table_search.dart'; + import '../../../../shared/models/vendor_model.dart'; import '../../data/repositories/vendor_repository_impl.dart'; @@ -93,7 +95,7 @@ class VendorsListNotifier extends AutoDisposeAsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: search, page: 1)); + applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); } void setStatusFilter(String? status) { diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index d394541..dd6d9b0 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -272,26 +272,32 @@ class _VendorDataTable extends StatelessWidget { AppDataColumn( label: 'Code', flex: 1, + searchText: (vendor) => vendor.vendorCode ?? '', cellBuilder: (_, vendor) => Text(vendor.vendorCode ?? '—'), ), 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, @@ -302,6 +308,7 @@ class _VendorDataTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, + enableSearch: false, cellBuilder: (_, vendor) => AppTableActions( children: [ AppTableActionIcon( diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index 9f977bd..ec970b5 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -2,9 +2,15 @@ import 'package:flutter/material.dart'; import 'app_card.dart'; -/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets. +/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets. const double kAppTableRowHeight = 52; +/// Fixed column-filter row height (first row under the header). +const double kAppTableFilterRowHeight = 52; + +/// Horizontal gap between column search fields. +const double kAppTableFilterGap = 8; + class AppDataColumn { const AppDataColumn({ required this.label, @@ -13,6 +19,8 @@ class AppDataColumn { this.flex = 1, this.alignment = Alignment.centerLeft, this.padding = EdgeInsets.zero, + this.searchText, + this.enableSearch, }); final String label; @@ -21,6 +29,15 @@ class AppDataColumn { final int flex; final Alignment alignment; final EdgeInsets padding; + + /// Text used for this column's filter. When null and [enableSearch] is not + /// forced on, the column has no search input (e.g. Actions). + final String Function(T row)? searchText; + + /// When null, search is enabled only if [searchText] is provided. + final bool? enableSearch; + + bool get isSearchable => enableSearch ?? searchText != null; } /// Helpers for table cell content — single-line text with ellipsis and tooltip. @@ -49,7 +66,7 @@ class AppTableCell { static Widget child(Widget widget) => widget; } -class AppDataTable extends StatelessWidget { +class AppDataTable extends StatefulWidget { const AppDataTable({ super.key, required this.columns, @@ -58,6 +75,7 @@ class AppDataTable extends StatelessWidget { this.sortAscending = true, this.onSort, this.emptyMessage = 'No records found', + this.noMatchMessage = 'No matching records', this.wrapInCard = true, this.shrinkWrap = false, }); @@ -68,70 +86,152 @@ class AppDataTable extends StatelessWidget { final bool sortAscending; final void Function(String column, bool ascending)? onSort; final String emptyMessage; + final String noMatchMessage; final bool wrapInCard; /// Set true when the table is placed inside another scrollable. final bool shrinkWrap; + @override + State> createState() => _AppDataTableState(); +} + +class _AppDataTableState extends State> { + /// Column index → search query (raw, including spaces until applied). + final Map _queries = {}; + final Map _controllers = {}; + + @override + void dispose() { + for (final c in _controllers.values) { + c.dispose(); + } + super.dispose(); + } + + TextEditingController _controllerFor(int index) { + return _controllers.putIfAbsent(index, TextEditingController.new); + } + + List get _filteredRows { + final active = {}; + for (final entry in _queries.entries) { + final q = entry.value.trim().toLowerCase(); + if (q.isEmpty) continue; + final col = widget.columns[entry.key]; + if (!col.isSearchable || col.searchText == null) continue; + active[entry.key] = q; + } + if (active.isEmpty) return widget.rows; + + return widget.rows.where((row) { + for (final entry in active.entries) { + final col = widget.columns[entry.key]; + final value = (col.searchText!(row)).trim().toLowerCase(); + if (!value.contains(entry.value)) return false; + } + return true; + }).toList(); + } + + bool get _hasActiveColumnFilters => + _queries.values.any((q) => q.trim().isNotEmpty); + @override Widget build(BuildContext context) { - if (rows.isEmpty) { + if (widget.rows.isEmpty) { final empty = Center( child: Padding( padding: const EdgeInsets.all(32), child: Text( - emptyMessage, + widget.emptyMessage, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ); - if (!wrapInCard) return empty; + if (!widget.wrapInCard) return empty; return AppCard(clipBehavior: Clip.antiAlias, child: empty); } + final filtered = _filteredRows; + final showFilterRow = widget.columns.any((c) => c.isSearchable); + final header = _TableHeaderRow( - columns: columns, - sortColumn: sortColumn, - sortAscending: sortAscending, - onSort: onSort, + columns: widget.columns, + sortColumn: widget.sortColumn, + sortAscending: widget.sortAscending, + onSort: widget.onSort, ); - final body = ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: shrinkWrap, - clipBehavior: Clip.none, - physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, - itemCount: rows.length, - itemBuilder: (context, index) => _TableDataRow( - columns: columns, - row: rows[index], - ), - ); + final filterRow = showFilterRow + ? _TableFilterRow( + columns: widget.columns, + controllerFor: _controllerFor, + queryFor: (i) => _queries[i] ?? '', + onQueryChanged: (index, value) { + setState(() => _queries[index] = value); + }, + ) + : null; - // Header stays fixed; body scrolls in the remaining space and is clipped so - // rows cannot paint over footers (e.g. pagination) outside the table. - final table = shrinkWrap + final Widget body; + if (filtered.isEmpty) { + body = Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + _hasActiveColumnFilters + ? widget.noMatchMessage + : widget.emptyMessage, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } else { + body = ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: widget.shrinkWrap, + clipBehavior: Clip.none, + physics: widget.shrinkWrap + ? const NeverScrollableScrollPhysics() + : null, + itemCount: filtered.length, + itemBuilder: (context, index) => _TableDataRow( + columns: widget.columns, + row: filtered[index], + ), + ); + } + + final pinned = [ + header, + if (filterRow != null) filterRow, + ]; + + final table = widget.shrinkWrap ? Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - header, + ...pinned, body, ], ) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - header, + ...pinned, Expanded( child: ClipRect(child: body), ), ], ); - if (!wrapInCard) return table; + if (!widget.wrapInCard) return table; return AppCard( clipBehavior: Clip.antiAlias, @@ -157,71 +257,240 @@ class _TableHeaderRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return SizedBox( - height: kAppTableRowHeight, - width: double.infinity, - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - border: Border( - bottom: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.12), + return Material( + color: theme.colorScheme.surfaceContainerHighest, + elevation: 1, + shadowColor: theme.colorScheme.shadow.withValues(alpha: 0.08), + child: SizedBox( + height: kAppTableRowHeight, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.12), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: columns.map((col) { + final isSorted = + col.sortKey != null && col.sortKey == sortColumn; + final label = Text( + col.label.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + + Widget header = label; + if (col.sortKey != null && onSort != null) { + header = InkWell( + onTap: () => onSort!( + col.sortKey!, + isSorted ? !sortAscending : true, + ), + child: Row( + 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, + ), + ], + ], + ), + ); + } + + return Expanded( + flex: col.flex, + child: Padding( + padding: col.padding, + child: Align( + alignment: col.alignment, + child: header, + ), + ), + ); + }).toList(), ), ), ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: columns.map((col) { - final isSorted = col.sortKey != null && col.sortKey == sortColumn; - final label = Text( - col.label.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: theme.colorScheme.onSurfaceVariant, - ), - ); + ), + ); + } +} - Widget header = label; - if (col.sortKey != null && onSort != null) { - header = InkWell( - onTap: () => onSort!( - col.sortKey!, - isSorted ? !sortAscending : true, - ), - child: Row( - 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, - ), - ], - ], - ), - ); - } +/// Fixed first row under the header — one search field per searchable column. +class _TableFilterRow extends StatelessWidget { + const _TableFilterRow({ + required this.columns, + required this.controllerFor, + required this.queryFor, + required this.onQueryChanged, + }); - return Expanded( - flex: col.flex, - child: Padding( - padding: col.padding, - child: Align( - alignment: col.alignment, - child: header, + final List> columns; + final TextEditingController Function(int index) controllerFor; + final String Function(int index) queryFor; + final void Function(int index, String value) onQueryChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Material( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + child: SizedBox( + height: kAppTableFilterRowHeight, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.12), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + for (var i = 0; i < columns.length; i++) ...[ + if (i > 0) const SizedBox(width: kAppTableFilterGap), + Expanded( + flex: columns[i].flex, + child: Padding( + padding: columns[i].padding, + child: columns[i].isSearchable + ? _ColumnSearchField( + controller: controllerFor(i), + query: queryFor(i), + onChanged: (v) => onQueryChanged(i, v), + ) + : const SizedBox.shrink(), + ), ), - ), - ); - }).toList(), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _ColumnSearchField extends StatelessWidget { + const _ColumnSearchField({ + required this.controller, + required this.query, + required this.onChanged, + }); + + final TextEditingController controller; + final String query; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final outline = theme.colorScheme.outline.withValues(alpha: 0.28); + final hasQuery = query.trim().isNotEmpty; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.shadow.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: TextField( + controller: controller, + onChanged: onChanged, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + cursorHeight: 14, + decoration: InputDecoration( + isDense: true, + hintText: 'Search…', + hintStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.55), + fontWeight: FontWeight.w400, + ), + prefixIcon: Icon( + Icons.search_rounded, + size: 16, + color: hasQuery + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 34, + minHeight: 34, + ), + suffixIcon: hasQuery + ? IconButton( + tooltip: 'Clear', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + icon: Icon( + Icons.close_rounded, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + onPressed: () { + controller.clear(); + onChanged(''); + }, + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + filled: true, + fillColor: theme.colorScheme.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 1.5, + ), ), ), ),