table generia search

This commit is contained in:
Surendiran 2026-07-14 17:24:24 +05:30
parent c20b12c261
commit 5a572be3c6
22 changed files with 484 additions and 140 deletions

View File

@ -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<AssetsListState> {
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) {

View File

@ -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(

View File

@ -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<AuditLogsListState> {
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) {

View File

@ -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 = <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');
@ -469,6 +481,7 @@ class _AuditDataTable extends StatelessWidget {
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, row) => AppTableActions(
children: [
AppTableActionIcon(

View File

@ -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<GrnListState> {
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) {

View File

@ -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(

View File

@ -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';

View File

@ -277,12 +277,14 @@ class _MasterListTable extends StatelessWidget {
(field) => AppDataColumn<Map<String, dynamic>>(
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(

View File

@ -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) {

View File

@ -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(

View File

@ -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<RbacState> {
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);

View File

@ -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(

View File

@ -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';

View File

@ -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,

View File

@ -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<RolesListState> {
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) {

View File

@ -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<PermissionMatrixModuleRow>(
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,
);
}
}

View File

@ -120,21 +120,24 @@ class _RoleDataTable extends StatelessWidget {
return AppDataTable<RoleCardModel>(
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(

View File

@ -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<UsersListState> {
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) {

View File

@ -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),
),
],

View File

@ -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<VendorsListState> {
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) {

View File

@ -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(

View File

@ -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<T> {
const AppDataColumn({
required this.label,
@ -13,6 +19,8 @@ class AppDataColumn<T> {
this.flex = 1,
this.alignment = Alignment.centerLeft,
this.padding = EdgeInsets.zero,
this.searchText,
this.enableSearch,
});
final String label;
@ -21,6 +29,15 @@ class AppDataColumn<T> {
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<T> extends StatelessWidget {
class AppDataTable<T> extends StatefulWidget {
const AppDataTable({
super.key,
required this.columns,
@ -58,6 +75,7 @@ class AppDataTable<T> 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<T> 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<AppDataTable<T>> createState() => _AppDataTableState<T>();
}
class _AppDataTableState<T> extends State<AppDataTable<T>> {
/// Column index search query (raw, including spaces until applied).
final Map<int, String> _queries = {};
final Map<int, TextEditingController> _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<T> get _filteredRows {
final active = <int, String>{};
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<T>(
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<T>(
columns: columns,
row: rows[index],
),
);
final filterRow = showFilterRow
? _TableFilterRow<T>(
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<T>(
columns: widget.columns,
row: filtered[index],
),
);
}
final pinned = <Widget>[
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<T> 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<T> 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<AppDataColumn<T>> 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<String> 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,
),
),
),
),