diff --git a/lib/modules/assets/presentation/providers/assets_provider.dart b/lib/modules/assets/presentation/providers/assets_provider.dart index 4f68bfe..5d59086 100644 --- a/lib/modules/assets/presentation/providers/assets_provider.dart +++ b/lib/modules/assets/presentation/providers/assets_provider.dart @@ -93,25 +93,14 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { } } - items = TableSearch.filter( - items, - search, - (asset) => [ - asset.assetCode, - asset.assetName, - asset.assetCategoryName, - asset.locationName, - asset.status, - asset.condition, - ], - ); + // Keep API page results + enriched matches; pagination stays server-driven. } return AssetsListState( assets: items, query: query, - total: search.isEmpty ? page.total : items.length, - totalPages: search.isEmpty ? page.totalPages : 1, + total: page.total, + totalPages: page.totalPages, ); } diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 6aeb0c5..7825bd9 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -158,33 +158,17 @@ class _AssetListScreenState extends ConsumerState { onStatusChanged: notifier.setStatusFilter, ), ), - if (state.assets.isEmpty) - Expanded( - child: Center( - child: Text( - 'No assets found', - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ) - else - Expanded( - child: _AssetDataTable( - assets: state.assets, - canEdit: canEdit, - canDelete: canDelete, - onView: _viewAsset, - onEdit: _editAsset, - onDelete: _deleteAsset, - ), + Expanded( + child: _AssetDataTable( + assets: state.assets, + canEdit: canEdit, + canDelete: canDelete, + onView: _viewAsset, + onEdit: _editAsset, + onDelete: _deleteAsset, + onServerSearch: notifier.setSearch, ), + ), const Divider(height: 1), Padding( padding: const EdgeInsets.all(16), @@ -390,6 +374,7 @@ class _AssetDataTable extends StatelessWidget { required this.onView, required this.onEdit, required this.onDelete, + this.onServerSearch, }); final List assets; @@ -398,11 +383,13 @@ class _AssetDataTable extends StatelessWidget { final void Function(AssetModel asset) onView; final void Function(AssetModel asset) onEdit; final Future Function(AssetModel asset) onDelete; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn( label: 'Asset Code', diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index a4aa3ae..e25fb93 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -637,10 +637,13 @@ class _AssetFormPanelState extends ConsumerState { ), ), const SizedBox(height: 12), - lookupsAsync.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('Failed to load locations'), - data: (lookups) => _locationDropdown(lookups.locations), + SidePanelFormRow( + left: lookupsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('Failed to load locations'), + data: (lookups) => _locationDropdown(lookups.locations), + ), + right: const SizedBox.shrink(), ), ], ), @@ -744,12 +747,15 @@ class _AssetFormPanelState extends ConsumerState { ), ), const SizedBox(height: 12), - _optionalLookupDropdown( - label: 'Assigned To', - value: _assignedToUserId, - options: lookups.users, - onChanged: (v) => setState(() => _assignedToUserId = v), - emptyHint: 'Unassigned', + SidePanelFormRow( + left: _optionalLookupDropdown( + label: 'Assigned To', + value: _assignedToUserId, + options: lookups.users, + onChanged: (v) => setState(() => _assignedToUserId = v), + emptyHint: 'Unassigned', + ), + right: const SizedBox.shrink(), ), ], ), diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index b27b246..4de6f16 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -206,30 +206,40 @@ class _AuditLogsScreenState extends ConsumerState { ), ], ) - : state.items.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox( - height: 260, - child: AppEmptyState( - title: 'No audit logs found', - description: - 'Try adjusting filters or expanding the date range.', - icon: Icons.history_outlined, - ), - ), - ], - ) - : context.isMobile - ? _AuditCardList( - items: state.items, - onView: _viewLog, + : context.isMobile + ? (state.items.isEmpty + ? ListView( + physics: + const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 260, + child: AppEmptyState( + title: 'No audit logs found', + description: + 'Try adjusting filters or expanding the date range.', + icon: Icons.history_outlined, + ), + ), + ], ) - : _AuditDataTable( + : _AuditCardList( items: state.items, onView: _viewLog, - ), + )) + : _AuditDataTable( + items: state.items, + onView: _viewLog, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, + ), + ); + notifier.setSearch(value); + }, + ), ), ), ), @@ -369,10 +379,12 @@ class _AuditDataTable extends StatelessWidget { const _AuditDataTable({ required this.items, required this.onView, + this.onServerSearch, }); final List items; final void Function(AuditLogEntryModel log) onView; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { @@ -380,6 +392,7 @@ class _AuditDataTable extends StatelessWidget { wrapInCard: false, rows: items, emptyMessage: 'No audit logs found', + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn( label: 'When', diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index 40e2524..9c164ce 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -134,25 +134,13 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier { } catch (_) { // Lookups enrichment is best-effort. } - - items = TableSearch.filter( - items, - search, - (grn) => [ - grn.grnNumber, - grn.poNumber, - grn.vendorName, - grn.locationName, - grn.status, - ], - ); } return GrnListState( grns: items, query: query, - total: search.isEmpty ? page.total : items.length, - totalPages: search.isEmpty ? page.totalPages : 1, + total: page.total, + totalPages: page.totalPages, ); } diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index 1750040..f14d680 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -626,7 +626,7 @@ class _GrnFormScreenState extends ConsumerState { final theme = Theme.of(context); final title = widget.isEditing ? 'Edit ${existing?.grnNumber ?? 'GRN'}' - : 'Create Goods Received Note'; + : 'Create Purchase Receipt'; final subtitle = widget.isEditing ? null : 'Select an approved purchase order, enter receipt details, then confirm quantities.'; diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index 3440936..4a68f6e 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -73,7 +73,7 @@ class _GrnListScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ PageHeader( - title: 'Goods Received Notes', + title: 'Purchase Receipt', subtitle: 'Record and track purchase order receipts', actions: [ AppSearchFilterButton( @@ -133,32 +133,41 @@ class _GrnListScreenState extends ConsumerState { ), child: RefreshIndicator( onRefresh: () => ref.read(grnListProvider.notifier).refresh(), - child: state.grns.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox( - height: 240, - child: AppEmptyState( - title: 'No GRNs found', - description: - 'Try adjusting filters or create a new goods received note.', - icon: Icons.inventory_2_outlined, - ), - ), - ], - ) - : context.isMobile - ? _GrnCardList( - grns: state.grns, - onView: _viewGrn, - onEdit: canEdit ? _editGrn : null, + child: context.isMobile + ? (state.grns.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 240, + child: AppEmptyState( + title: 'No GRNs found', + description: + 'Try adjusting filters or create a new goods received note.', + icon: Icons.inventory_2_outlined, + ), + ), + ], ) - : _GrnDataTable( + : _GrnCardList( grns: state.grns, onView: _viewGrn, onEdit: canEdit ? _editGrn : null, - ), + )) + : _GrnDataTable( + grns: state.grns, + onView: _viewGrn, + onEdit: canEdit ? _editGrn : null, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, + ), + ); + ref.read(grnListProvider.notifier).setSearch(value); + }, + ), ), ), ), @@ -288,16 +297,19 @@ class _GrnDataTable extends StatelessWidget { required this.grns, required this.onView, this.onEdit, + this.onServerSearch, }); final List grns; final ValueChanged onView; final ValueChanged? onEdit; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn( label: 'GRN Number', diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index 003639f..fe8c30e 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -152,14 +152,7 @@ class MasterListNotifier extends FamilyAsyncNotifier { } } - items = TableSearch.filter( - items, - nextSearch, - (row) => [ - ..._definition.listFields.map((field) => masterCellValue(row, field)), - masterStatusValue(row), - ], - ); + // Keep API page results + enriched matches; pagination stays server-driven. } final data = result.data!; @@ -170,10 +163,8 @@ class MasterListNotifier extends FamilyAsyncNotifier { // Keep the requested page size so the /page dropdown stays valid // even if API meta omits or mismatches `limit`. limit: nextLimit, - total: nextSearch.isEmpty ? data.total : items.length, - totalPages: nextSearch.isEmpty - ? resolveTotalPages(total: data.total, limit: nextLimit) - : resolveTotalPages(total: items.length, limit: nextLimit), + total: data.total, + totalPages: resolveTotalPages(total: data.total, limit: nextLimit), ); } 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 e88d7ba..be3e67e 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -9,7 +9,6 @@ import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_confirmation_dialog.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_export_bar.dart'; @@ -257,30 +256,24 @@ class _MasterListScreenState extends ConsumerState { ), child: RefreshIndicator( onRefresh: notifier.refresh, - child: state.items.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: 240, - child: AppEmptyState( - title: 'No ${def.title.toLowerCase()} found', - description: - 'Add your first ${def.title.toLowerCase()} record to get started.', - icon: def.icon, - ), - ), - ], - ) - : _MasterListTable( - definition: def, - items: state.items, - isDeleting: state.isDeleting, - canEdit: canEdit, - canDelete: canDelete, - onEdit: (id) => _openFormPanel(recordId: id), - onDelete: _deleteRecord, + child: _MasterListTable( + definition: def, + items: state.items, + isDeleting: state.isDeleting, + canEdit: canEdit, + canDelete: canDelete, + onEdit: (id) => _openFormPanel(recordId: id), + onDelete: _deleteRecord, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, ), + ); + notifier.setSearch(value); + }, + ), ), ), ), @@ -301,6 +294,7 @@ class _MasterListTable extends StatelessWidget { required this.canDelete, required this.onEdit, required this.onDelete, + this.onServerSearch, }); final MasterDefinition definition; @@ -310,6 +304,7 @@ class _MasterListTable extends StatelessWidget { final bool canDelete; final ValueChanged onEdit; final ValueChanged> onDelete; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { @@ -317,6 +312,8 @@ class _MasterListTable extends StatelessWidget { return AppDataTable>( wrapInCard: false, + onServerSearchChanged: onServerSearch, + emptyMessage: 'No ${definition.title.toLowerCase()} found', columns: [ ...definition.listFields.map( (field) => AppDataColumn>( diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart index 3e5a00c..fbeb317 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -412,11 +412,13 @@ class _MasterFormPanelState extends ConsumerState { final fullWidth = left.multiline || left.key == 'is_asset_item'; if (fullWidth) { widgets.add( - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: SizedBox( - width: double.infinity, - child: _buildField(context, field: left, formState: formState), + QuickAddBlockable( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + child: _buildField(context, field: left, formState: formState), + ), ), ), ); @@ -438,11 +440,13 @@ class _MasterFormPanelState extends ConsumerState { i += 2; } else { widgets.add( - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: SizedBox( - width: double.infinity, - child: _buildField(context, field: left, formState: formState), + QuickAddBlockable( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + child: _buildField(context, field: left, formState: formState), + ), ), ), ); @@ -452,11 +456,13 @@ class _MasterFormPanelState extends ConsumerState { if (activeField != null) { widgets.add( - SidePanelSection( - title: 'STATUS', - children: [ - _buildField(context, field: activeField, formState: formState), - ], + QuickAddBlockable( + child: SidePanelSection( + title: 'STATUS', + children: [ + _buildField(context, field: activeField, formState: formState), + ], + ), ), ); } diff --git a/lib/modules/master_data/presentation/widgets/master_quick_add.dart b/lib/modules/master_data/presentation/widgets/master_quick_add.dart index 09675f0..684a704 100644 --- a/lib/modules/master_data/presentation/widgets/master_quick_add.dart +++ b/lib/modules/master_data/presentation/widgets/master_quick_add.dart @@ -148,8 +148,14 @@ class _MasterQuickAddDropdownState } void _collapse() { - if (!_expanded) return; - _host?.dismiss(this); + final host = _host ?? QuickAddInlineScope.maybeOf(context); + // Prefer owner dismiss; fall back to dismissAll so Cancel/Close always works + // even if the host state was remounted while the form was open. + if (host != null) { + host.dismiss(this); + if (host.isOpen) host.dismissAll(); + } + if (!_expanded && _sessionId == null) return; setState(() { _expanded = false; _sessionId = null; @@ -161,24 +167,32 @@ class _MasterQuickAddDropdownState Future _onSaved(String createdId) async { await _applyCreated(createdId); - _collapse(); + if (mounted) _collapse(); } Future _applyCreated(String createdId) async { + final parsed = (createdId.isNotEmpty && createdId != 'created') + ? widget.parseCreatedId(createdId) + : null; + + // Select first so parent form keeps the new id even if this State is + // disposed during the options reload rebuild. + if (parsed != null) { + widget.onChanged(parsed); + } + try { final refresh = widget.refreshLookups; if (refresh != null) await refresh(); } catch (_) { - // Still select the created row even if lookup refresh fails. + // Still keep the created selection even if lookup refresh fails. } + if (!mounted) return; - // Prefer a real id from create; fall back only when API omits it. - if (createdId.isNotEmpty && createdId != 'created') { - final parsed = widget.parseCreatedId(createdId); - if (parsed != null) { - widget.onChanged(parsed); - } + // Re-apply after reload in case options refresh cleared dependent values. + if (parsed != null) { + widget.onChanged(parsed); } if (!mounted) return; @@ -207,7 +221,7 @@ class _MasterQuickAddDropdownState final host = _host ?? QuickAddInlineScope.maybeOf(context); if (host == null) return; _host = host; - host.present(owner: this, form: _buildCreateForm()); + host.present(owner: this, formBuilder: _buildCreateForm); } @override @@ -246,7 +260,14 @@ class _MasterQuickAddDropdownState return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - dropdown, + IgnorePointer( + ignoring: true, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: 0.45, + child: dropdown, + ), + ), AnimatedSize( duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic, 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 56ce47c..fab384b 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -84,12 +84,11 @@ class PurchaseOrdersListNotifier items: page.items, load: repository.getPurchaseOrders, ); - final search = TableSearch.normalize(query.search); return PurchaseOrdersListState( orders: orders, query: query, - total: search.isEmpty ? page.total : orders.length, - totalPages: search.isEmpty ? page.totalPages : 1, + total: page.total, + totalPages: page.totalPages, ); } @@ -202,12 +201,11 @@ class PendingApprovalPurchaseOrdersListNotifier items: page.items, load: repository.getPendingApprovalPurchaseOrders, ); - final search = TableSearch.normalize(query.search); return PurchaseOrdersListState( orders: orders, query: query, - total: search.isEmpty ? page.total : orders.length, - totalPages: search.isEmpty ? page.totalPages : 1, + total: page.total, + totalPages: page.totalPages, ); } @@ -479,15 +477,5 @@ Future> _enrichPurchaseOrderSearch({ // Lookups/vendor enrichment is best-effort. } - return TableSearch.filter( - merged, - search, - (order) => [ - order.poNo, - order.vendorName, - order.status, - order.billingName, - order.shippingName, - ], - ); + return merged; } diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index 88ec117..f91b64e 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -396,7 +396,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState orders; @@ -434,12 +455,14 @@ class _PoDataTable extends StatelessWidget { final ValueChanged? onEdit; final ValueChanged? onDelete; final ValueChanged? onApprove; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { final theme = Theme.of(context); return AppDataTable( wrapInCard: false, + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn( label: 'PO Number', diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index a98c90e..a581ef3 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -85,7 +85,7 @@ class PoOrderTotals { /// Sub Total = sum of line amounts /// Taxable = Sub Total + Freight + Other − Discount - /// Tax = sum of line GST amounts + /// Tax = Taxable × effective GST rate (from line GST ÷ Sub Total) /// Grand Total = Taxable + Tax factory PoOrderTotals.compute({ required Iterable lines, @@ -94,17 +94,20 @@ class PoOrderTotals { required double discountAmount, }) { var subTotal = 0.0; - var tax = 0.0; + var lineTax = 0.0; for (final line in lines) { subTotal += line.lineAmount; - tax += line.gstAmount; + lineTax += line.gstAmount; } final freightSafe = freight < 0 ? 0.0 : freight; final otherSafe = otherCharges < 0 ? 0.0 : otherCharges; final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount; final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount; final taxable = taxableRaw < 0 ? 0.0 : taxableRaw; - final maxDiscount = subTotal + tax + freightSafe + otherSafe; + // Apply the blended line GST rate to Taxable Amount (not Sub Total), + // so freight / other / discount are included in the tax base. + final tax = subTotal > 0 ? lineTax * (taxable / subTotal) : 0.0; + final maxDiscount = subTotal + lineTax + freightSafe + otherSafe; final grandTotal = taxable + tax; return PoOrderTotals( subTotal: subTotal, @@ -629,7 +632,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { borderRadius: BorderRadius.circular(10), ), child: QuickAddInlineHost( - child: LayoutBuilder( + child: QuickAddBlockable( + child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; @@ -675,6 +679,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { ); }, ), + ), ), ); } 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 110ba1b..95a3090 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -833,33 +833,28 @@ class _UsersTabState extends ConsumerState<_UsersTab> { }, ), ), - if (usersState.users.isEmpty) - Expanded( - child: Center( - child: Text( - 'No users found', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ) - else - Expanded( - child: UserRichDataTable( - wrapInCard: false, - users: usersState.users, - actionsBuilder: (_, user) => UserTableActions( - user: user, - canEdit: canEditUser, - canResetPassword: canEditUser, - canDeactivate: canDeleteUser, - onEdit: () => _editUser(user), - onResetPassword: () => _resetPassword(user), - onDeactivate: () => _deactivateUser(user), - ), + Expanded( + child: UserRichDataTable( + wrapInCard: false, + users: usersState.users, + onServerSearchChanged: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + ref.read(usersListProvider.notifier).setSearch(value); + }, + actionsBuilder: (_, user) => UserTableActions( + user: user, + canEdit: canEditUser, + canResetPassword: canEditUser, + canDeactivate: canDeleteUser, + onEdit: () => _editUser(user), + onResetPassword: () => _resetPassword(user), + onDeactivate: () => _deactivateUser(user), ), ), + ), const Divider(height: 1), AppPagination( currentPage: page, diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index 947d835..52aeb59 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -215,24 +215,35 @@ class _DepreciationReportScreenState ), child: RefreshIndicator( onRefresh: notifier.refresh, - child: state.items.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox( - height: 260, - child: AppEmptyState( - title: 'No depreciation data', - description: - 'Try adjusting filters or the as-of date.', - icon: Icons.trending_down_outlined, + child: context.isMobile + ? (state.items.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 260, + child: AppEmptyState( + title: 'No depreciation data', + description: + 'Try adjusting filters or the as-of date.', + icon: Icons.trending_down_outlined, + ), + ), + ], + ) + : _MobileList(items: state.items)) + : _ReportTable( + items: state.items, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, ), - ), - ], - ) - : context.isMobile - ? _MobileList(items: state.items) - : _ReportTable(items: state.items), + ); + notifier.setSearch(value); + }, + ), ), ), ), @@ -581,15 +592,20 @@ class _FiltersBarState extends State<_FiltersBar> { } class _ReportTable extends StatelessWidget { - const _ReportTable({required this.items}); + const _ReportTable({ + required this.items, + this.onServerSearch, + }); final List items; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, rows: items, + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn( label: 'Asset Code', diff --git a/lib/modules/roles/presentation/providers/roles_provider.dart b/lib/modules/roles/presentation/providers/roles_provider.dart index 89351d7..3f75e16 100644 --- a/lib/modules/roles/presentation/providers/roles_provider.dart +++ b/lib/modules/roles/presentation/providers/roles_provider.dart @@ -21,15 +21,8 @@ class RolesListState { final int limit; List get filteredRoles { - if (search.isEmpty) return roles; - final q = search.toLowerCase(); - return roles - .where( - (role) => - role.name.toLowerCase().contains(q) || - (role.description?.toLowerCase().contains(q) ?? false), - ) - .toList(); + // Roles are already filtered by the API when [search] is set. + return roles; } int get total => filteredRoles.length; @@ -124,10 +117,17 @@ class RolesListNotifier extends AsyncNotifier { return true; } - void setSearch(String search) { + Future setSearch(String search) async { final current = state.valueOrNull; if (current == null) return; - state = AsyncData(current.copyWith(search: TableSearch.normalize(search), page: 1)); + final normalized = TableSearch.normalize(search); + state = const AsyncLoading(); + try { + final loaded = await _load(search: normalized); + state = AsyncData(loaded.copyWith(page: 1, limit: current.limit)); + } catch (e, st) { + state = AsyncError(e, st); + } } void setPage(int page) { diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index f89f1fd..16ead21 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -88,24 +88,36 @@ class _RoleListScreenState extends ConsumerState { ), child: RefreshIndicator( onRefresh: notifier.refresh, - child: roles.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox( - height: 240, - child: AppEmptyState( - title: 'No roles found', - description: - 'Roles from the API will appear here.', - icon: Icons.security_outlined, + child: context.isMobile + ? (roles.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 240, + child: AppEmptyState( + title: 'No roles found', + description: + 'Roles from the API will appear here.', + icon: Icons.security_outlined, + ), + ), + ], + ) + : _RoleCardList(roles: roles, onOpen: _openRole)) + : _RoleDataTable( + roles: roles, + onOpen: _openRole, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, ), - ), - ], - ) - : context.isMobile - ? _RoleCardList(roles: roles, onOpen: _openRole) - : _RoleDataTable(roles: roles, onOpen: _openRole), + ); + notifier.setSearch(value); + }, + ), ), ), ), @@ -122,15 +134,21 @@ class _RoleListScreenState extends ConsumerState { } class _RoleDataTable extends StatelessWidget { - const _RoleDataTable({required this.roles, required this.onOpen}); + const _RoleDataTable({ + required this.roles, + required this.onOpen, + this.onServerSearch, + }); final List roles; final void Function(RoleCardModel role) onOpen; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)), AppDataColumn( diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 4342ced..e679194 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -109,41 +109,52 @@ class _UserListScreenState extends ConsumerState { ), child: RefreshIndicator( onRefresh: () => ref.read(usersListProvider.notifier).refresh(), - child: state.users.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox( - height: 240, - child: AppEmptyState( - title: 'No users found', - description: - 'Try adjusting filters or add a new user.', - icon: Icons.people_outline, - ), - ), - ], - ) - : context.isMobile - ? _UserCardList( - users: state.users, - onView: _viewUser, - onEdit: _editUser, - onToggleStatus: _toggleStatus, - onDeactivate: _deactivateUser, + child: context.isMobile + ? (state.users.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 240, + child: AppEmptyState( + title: 'No users found', + description: + 'Try adjusting filters or add a new user.', + icon: Icons.people_outline, + ), + ), + ], ) - : _UserDataTable( + : _UserCardList( users: state.users, - sortBy: state.query.sortBy, - sortOrder: state.query.sortOrder, - onSort: (column, ascending) => ref - .read(usersListProvider.notifier) - .setSort(column, ascending ? 'asc' : 'desc'), onView: _viewUser, onEdit: _editUser, onToggleStatus: _toggleStatus, onDeactivate: _deactivateUser, - ), + )) + : _UserDataTable( + users: state.users, + sortBy: state.query.sortBy, + sortOrder: state.query.sortOrder, + onSort: (column, ascending) => ref + .read(usersListProvider.notifier) + .setSort(column, ascending ? 'asc' : 'desc'), + onView: _viewUser, + onEdit: _editUser, + onToggleStatus: _toggleStatus, + onDeactivate: _deactivateUser, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, + ), + ); + ref + .read(usersListProvider.notifier) + .setSearch(value); + }, + ), ), ), ), @@ -301,6 +312,7 @@ class _UserDataTable extends StatelessWidget { required this.onEdit, required this.onToggleStatus, required this.onDeactivate, + this.onServerSearch, }); final List users; @@ -311,6 +323,7 @@ class _UserDataTable extends StatelessWidget { final void Function(ManagedUserModel user) onEdit; final Future Function(ManagedUserModel user) onToggleStatus; final Future Function(ManagedUserModel user) onDeactivate; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { @@ -320,6 +333,7 @@ class _UserDataTable extends StatelessWidget { sortAscending: sortOrder == 'asc', onSort: onSort, wrapInCard: false, + onServerSearchChanged: onServerSearch, actionsBuilder: (_, user) => _UserActions( user: user, onView: onView, 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 3d9cc4c..b56170b 100644 --- a/lib/modules/users/presentation/widgets/user_rich_data_table.dart +++ b/lib/modules/users/presentation/widgets/user_rich_data_table.dart @@ -20,6 +20,7 @@ class UserRichDataTable extends StatelessWidget { this.sortAscending = true, this.onSort, this.wrapInCard = false, + this.onServerSearchChanged, }); final List users; @@ -28,6 +29,7 @@ class UserRichDataTable extends StatelessWidget { final bool sortAscending; final void Function(String column, bool ascending)? onSort; final bool wrapInCard; + final ValueChanged? onServerSearchChanged; @override Widget build(BuildContext context) { @@ -38,6 +40,7 @@ class UserRichDataTable extends StatelessWidget { sortColumn: sortColumn, sortAscending: sortAscending, onSort: onSort, + onServerSearchChanged: onServerSearchChanged, columns: [ AppDataColumn( label: 'User', diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index e66dde4..3581de5 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -137,34 +137,45 @@ class _VendorListScreenState extends ConsumerState { ), child: RefreshIndicator( onRefresh: () => ref.read(vendorsListProvider.notifier).refresh(), - child: state.vendors.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox( - height: 240, - child: AppEmptyState( - title: 'No vendors found', - description: - 'Try adjusting filters or add a new vendor.', - icon: Icons.store_outlined, - ), - ), - ], - ) - : context.isMobile - ? _VendorCardList( - vendors: state.vendors, - onView: _viewVendor, - onEdit: canEdit ? _editVendor : null, - onDelete: canDelete ? _deleteVendor : null, + child: context.isMobile + ? (state.vendors.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 240, + child: AppEmptyState( + title: 'No vendors found', + description: + 'Try adjusting filters or add a new vendor.', + icon: Icons.store_outlined, + ), + ), + ], ) - : _VendorDataTable( + : _VendorCardList( vendors: state.vendors, onView: _viewVendor, onEdit: canEdit ? _editVendor : null, onDelete: canDelete ? _deleteVendor : null, - ), + )) + : _VendorDataTable( + vendors: state.vendors, + onView: _viewVendor, + onEdit: canEdit ? _editVendor : null, + onDelete: canDelete ? _deleteVendor : null, + onServerSearch: (value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed( + offset: value.length, + ), + ); + ref + .read(vendorsListProvider.notifier) + .setSearch(value); + }, + ), ), ), ), @@ -288,17 +299,20 @@ class _VendorDataTable extends StatelessWidget { required this.onView, this.onEdit, this.onDelete, + this.onServerSearch, }); final List vendors; final ValueChanged onView; final ValueChanged? onEdit; final ValueChanged? onDelete; + final ValueChanged? onServerSearch; @override Widget build(BuildContext context) { return AppDataTable( wrapInCard: false, + onServerSearchChanged: onServerSearch, columns: [ AppDataColumn( label: 'Code', diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index 6440ada..82b371d 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -46,6 +46,27 @@ const List appMenuItems = [ // route: RouteConstants.branches, // module: 'branches', // ), + MenuItem( + label: 'Purchase Orders', + icon: Icons.receipt_long_outlined, + route: RouteConstants.purchaseOrders, + module: 'purchase_orders', + children: [ + MenuItem( + label: 'All Orders', + icon: Icons.receipt_long_outlined, + route: RouteConstants.purchaseOrders, + module: 'purchase_orders', + ), + MenuItem( + label: 'Pending Approval', + icon: Icons.pending_actions_outlined, + route: RouteConstants.purchaseOrdersPendingApproval, + module: 'purchase_orders', + requiredAction: PermissionAction.approve, + ), + ], + ), MenuItem( label: 'Assets', icon: Icons.inventory_2_outlined, @@ -73,34 +94,7 @@ const List appMenuItems = [ ], ), MenuItem( - label: 'Vendors', - icon: Icons.store_outlined, - route: RouteConstants.vendors, - module: 'vendors', - ), - MenuItem( - label: 'Purchase Orders', - icon: Icons.receipt_long_outlined, - route: RouteConstants.purchaseOrders, - module: 'purchase_orders', - children: [ - MenuItem( - label: 'All Orders', - icon: Icons.receipt_long_outlined, - route: RouteConstants.purchaseOrders, - module: 'purchase_orders', - ), - MenuItem( - label: 'Pending Approval', - icon: Icons.pending_actions_outlined, - route: RouteConstants.purchaseOrdersPendingApproval, - module: 'purchase_orders', - requiredAction: PermissionAction.approve, - ), - ], - ), - MenuItem( - label: 'GRN', + label: 'Purchase Receipt', icon: Icons.inventory_2_outlined, route: RouteConstants.grn, module: 'grn', @@ -112,33 +106,52 @@ const List appMenuItems = [ module: 'reports', ), MenuItem( - label: 'Users & Roles', - icon: Icons.admin_panel_settings_outlined, - route: RouteConstants.usersRoleManagement, - module: 'users', - ), - MenuItem( - label: 'Master Data', - icon: Icons.dataset_outlined, - route: RouteConstants.masterData, - module: 'master_data', - ), - MenuItem( - label: 'Audit Logs', - icon: Icons.history_outlined, - route: RouteConstants.auditLogs, - module: 'audit_logs', - ), - MenuItem( - label: 'Settings', - icon: Icons.settings_outlined, - route: RouteConstants.settings, - module: 'settings', + label: 'Support', + icon: Icons.support_agent_outlined, + route: RouteConstants.vendors, + module: 'support', + children: [ + MenuItem( + label: 'Vendors', + icon: Icons.store_outlined, + route: RouteConstants.vendors, + module: 'vendors', + ), + MenuItem( + label: 'Users & Roles', + icon: Icons.admin_panel_settings_outlined, + route: RouteConstants.usersRoleManagement, + module: 'users', + ), + MenuItem( + label: 'Master Data', + icon: Icons.dataset_outlined, + route: RouteConstants.masterData, + module: 'master_data', + ), + MenuItem( + label: 'Audit Logs', + icon: Icons.history_outlined, + route: RouteConstants.auditLogs, + module: 'audit_logs', + ), + MenuItem( + label: 'Settings', + icon: Icons.settings_outlined, + route: RouteConstants.settings, + module: 'settings', + ), + ], ), ]; -/// Routes rendered under the Support section (after primary ops menus). +/// True when [item] is the Support group (top-nav dropdown / sidebar section). +bool isSupportMenuItem(MenuItem item) => + item.module == 'support' || item.label == 'Support'; + +/// Child routes that belong under Support (sidebar section flattening). const Set supportMenuRoutes = { + RouteConstants.vendors, RouteConstants.usersRoleManagement, RouteConstants.masterData, RouteConstants.auditLogs, diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index 5b53230..75691fa 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/utils/table_search.dart'; import 'app_card.dart'; /// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets. @@ -78,6 +79,7 @@ class AppDataTable extends StatefulWidget { this.noMatchMessage = 'No matching records', this.wrapInCard = true, this.shrinkWrap = false, + this.onServerSearchChanged, }); final List> columns; @@ -92,6 +94,13 @@ class AppDataTable extends StatefulWidget { /// Set true when the table is placed inside another scrollable. final bool shrinkWrap; + /// When set, column filters are sent to the parent for API search instead of + /// filtering only the currently loaded [rows] (current page). + /// + /// The callback receives a normalized query (trimmed). Empty string means + /// clear search and reload the full paginated list. + final ValueChanged? onServerSearchChanged; + @override State> createState() => _AppDataTableState(); } @@ -100,9 +109,15 @@ class _AppDataTableState extends State> { /// Column index → search query (raw, including spaces until applied). final Map _queries = {}; final Map _controllers = {}; + final SearchDebouncer _serverSearchDebouncer = SearchDebouncer(); + int? _lastEditedColumnIndex; + String _lastEmittedServerSearch = ''; + + bool get _serverSideSearch => widget.onServerSearchChanged != null; @override void dispose() { + _serverSearchDebouncer.dispose(); for (final c in _controllers.values) { c.dispose(); } @@ -113,7 +128,38 @@ class _AppDataTableState extends State> { return _controllers.putIfAbsent(index, TextEditingController.new); } + String _composeServerSearch() { + if (_lastEditedColumnIndex != null) { + final latest = (_queries[_lastEditedColumnIndex!] ?? '').trim(); + if (latest.isNotEmpty) return latest; + } + for (final entry in _queries.entries) { + final q = entry.value.trim(); + if (q.isNotEmpty) return q; + } + return ''; + } + + void _onColumnQueryChanged(int index, String value) { + setState(() { + _queries[index] = value; + _lastEditedColumnIndex = index; + }); + + if (!_serverSideSearch) return; + + _serverSearchDebouncer.run(value, (_) { + final composed = _composeServerSearch(); + if (composed == _lastEmittedServerSearch) return; + _lastEmittedServerSearch = composed; + widget.onServerSearchChanged!(composed); + }); + } + List get _filteredRows { + // Server-side mode: parent already fetched matching rows from the API. + if (_serverSideSearch) return widget.rows; + final active = {}; for (final entry in _queries.entries) { final q = entry.value.trim().toLowerCase(); @@ -139,7 +185,12 @@ class _AppDataTableState extends State> { @override Widget build(BuildContext context) { - if (widget.rows.isEmpty) { + final showFilterRow = widget.columns.any((c) => c.isSearchable); + final filtered = _filteredRows; + + // Keep header + column filters visible even when the API returns no rows, + // so users can refine or clear a server-side search. + if (widget.rows.isEmpty && !showFilterRow) { final empty = Center( child: Padding( padding: const EdgeInsets.all(32), @@ -155,9 +206,6 @@ class _AppDataTableState extends State> { return AppCard(clipBehavior: Clip.antiAlias, child: empty); } - final filtered = _filteredRows; - final showFilterRow = widget.columns.any((c) => c.isSearchable); - final header = _TableHeaderRow( columns: widget.columns, sortColumn: widget.sortColumn, @@ -170,9 +218,7 @@ class _AppDataTableState extends State> { columns: widget.columns, controllerFor: _controllerFor, queryFor: (i) => _queries[i] ?? '', - onQueryChanged: (index, value) { - setState(() => _queries[index] = value); - }, + onQueryChanged: _onColumnQueryChanged, ) : null; @@ -182,7 +228,7 @@ class _AppDataTableState extends State> { child: Padding( padding: const EdgeInsets.all(32), child: Text( - _hasActiveColumnFilters + _hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty ? widget.noMatchMessage : widget.emptyMessage, style: Theme.of(context).textTheme.bodyLarge?.copyWith( diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index 39d2c9c..ea2c070 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -3,31 +3,117 @@ import 'package:flutter/material.dart'; import '../../core/utils/responsive_utils.dart'; import 'app_toast.dart'; +/// Tracks whether any Quick Add inline form is open in the current panel/scope. +/// +/// Used to dim and block the parent form (and footer) while Quick Add is active. +class QuickAddBlockerController extends ChangeNotifier { + int _depth = 0; + + bool get isBlocked => _depth > 0; + + void push() { + _depth += 1; + notifyListeners(); + } + + void pop() { + if (_depth <= 0) return; + _depth -= 1; + notifyListeners(); + } + + void reset() { + if (_depth == 0) return; + _depth = 0; + notifyListeners(); + } +} + +class QuickAddBlockerScope extends InheritedNotifier { + const QuickAddBlockerScope({ + super.key, + required QuickAddBlockerController controller, + required super.child, + }) : super(notifier: controller); + + static QuickAddBlockerController? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; + } +} + +/// Dims and blocks pointer events on [child] while any Quick Add is open. +class QuickAddBlockable extends StatelessWidget { + const QuickAddBlockable({ + super.key, + required this.child, + this.blockedOpacity = 0.45, + }); + + final Widget child; + final double blockedOpacity; + + @override + Widget build(BuildContext context) { + final blocker = QuickAddBlockerScope.maybeOf(context); + if (blocker == null) return child; + + return ListenableBuilder( + listenable: blocker, + builder: (context, _) { + final blocked = blocker.isBlocked; + return AbsorbPointer( + absorbing: blocked, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + opacity: blocked ? blockedOpacity : 1, + child: child, + ), + ); + }, + ); + } +} + /// Holds a full-width Quick Add form below a form row (so it stays clickable). class QuickAddInlineController extends ChangeNotifier { Object? _owner; - Widget? _form; + Widget Function()? _formBuilder; + QuickAddBlockerController? blocker; - Widget? get form => _form; + bool get isOpen => _formBuilder != null; - void present({required Object owner, required Widget form}) { + Widget? buildForm() => _formBuilder?.call(); + + void present({ + required Object owner, + required Widget Function() formBuilder, + }) { + final wasClosed = _formBuilder == null; _owner = owner; - _form = form; + _formBuilder = formBuilder; + if (wasClosed) blocker?.push(); notifyListeners(); } void dismiss(Object owner) { if (_owner != owner) return; - _owner = null; - _form = null; - notifyListeners(); + _clear(notify: true); } void dismissAll() { - if (_owner == null && _form == null) return; + _clear(notify: true); + } + + void _clear({required bool notify}) { + final wasOpen = _formBuilder != null; + if (_owner == null && _formBuilder == null) return; _owner = null; - _form = null; - notifyListeners(); + _formBuilder = null; + if (wasOpen) blocker?.pop(); + if (notify) notifyListeners(); } } @@ -46,6 +132,10 @@ class QuickAddInlineScope extends InheritedNotifier { } /// Wraps a form row and renders any active Quick Add form at full row width. +/// +/// While Quick Add is open, the parent row fields are dimmed and non-interactive. +/// Sibling fields / footer use [QuickAddBlockerScope] from [SidePanelScaffold] +/// (or a host-owned blocker) for the same lock behavior. class QuickAddInlineHost extends StatefulWidget { const QuickAddInlineHost({super.key, required this.child}); @@ -57,41 +147,112 @@ class QuickAddInlineHost extends StatefulWidget { class _QuickAddInlineHostState extends State { final _controller = QuickAddInlineController(); + QuickAddBlockerController? _ownedBlocker; + bool _wasOpen = false; @override void dispose() { + if (_wasOpen) { + _controller.blocker?.pop(); + } + _ownedBlocker?.dispose(); _controller.dispose(); super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parentBlocker = QuickAddBlockerScope.maybeOf(context); + if (parentBlocker != null) { + _controller.blocker = parentBlocker; + } else { + _ownedBlocker ??= QuickAddBlockerController(); + _controller.blocker = _ownedBlocker; + } + } + @override Widget build(BuildContext context) { - return QuickAddInlineScope( + final theme = Theme.of(context); + final blocker = _controller.blocker; + + Widget host = QuickAddInlineScope( controller: _controller, child: ListenableBuilder( listenable: _controller, builder: (context, _) { - final form = _controller.form; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - widget.child, - AnimatedSize( - duration: const Duration(milliseconds: 280), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: form == null - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only(top: 10, bottom: 4), - child: form, - ), - ), - ], + Widget buildColumn({required bool blockedByOther}) { + final form = _controller.buildForm(); + final isOpen = form != null; + if (isOpen && !_wasOpen) { + _wasOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + FocusManager.instance.primaryFocus?.unfocus(); + }); + } else if (!isOpen) { + _wasOpen = false; + } + + final dimParent = isOpen || blockedByOther; + + // Field dimming is handled by [QuickAddBlockable] inside form rows / + // sections. The host only blocks interaction as a safety net for + // custom hosts that wrap plain children (e.g. PO form grid). + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AbsorbPointer( + absorbing: dimParent, + child: widget.child, + ), + AnimatedSize( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: form == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Material( + elevation: 2, + color: Colors.transparent, + shadowColor: theme.colorScheme.shadow + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + child: form, + ), + ), + ), + ], + ); + } + + if (blocker == null) { + return buildColumn(blockedByOther: false); + } + + return ListenableBuilder( + listenable: blocker, + builder: (context, _) { + final blockedByOther = + !_controller.isOpen && blocker.isBlocked; + return buildColumn(blockedByOther: blockedByOther); + }, ); }, ), ); + + if (_ownedBlocker != null && + QuickAddBlockerScope.maybeOf(context) == null) { + host = QuickAddBlockerScope( + controller: _ownedBlocker!, + child: host, + ); + } + + return host; } } @@ -162,7 +323,7 @@ void showSidePanelSnackBar( ); } -class SidePanelScaffold extends StatelessWidget { +class SidePanelScaffold extends StatefulWidget { const SidePanelScaffold({ super.key, required this.title, @@ -176,9 +337,22 @@ class SidePanelScaffold extends StatelessWidget { final Widget? footer; final VoidCallback? onClose; + @override + State createState() => _SidePanelScaffoldState(); +} + +class _SidePanelScaffoldState extends State { + final _blocker = QuickAddBlockerController(); + + @override + void dispose() { + _blocker.dispose(); + super.dispose(); + } + void _close(BuildContext context) { - if (onClose != null) { - onClose!(); + if (widget.onClose != null) { + widget.onClose!(); return; } Navigator.of(context, rootNavigator: true).pop(); @@ -186,40 +360,77 @@ class SidePanelScaffold extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 12, 16), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + return QuickAddBlockerScope( + controller: _blocker, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListenableBuilder( + listenable: _blocker, + builder: (context, _) { + final blocked = _blocker.isBlocked; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 12, 16), + child: Row( + children: [ + Expanded( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: blocked ? 0.45 : 1, + child: Text( + widget.title, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), ), + ), + AbsorbPointer( + absorbing: blocked, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: blocked ? 0.45 : 1, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () => _close(context), + ), + ), + ), + ], ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _close(context), - ), - ], + ); + }, ), - ), - const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: child, - ), - ), - if (footer != null) ...[ const Divider(height: 1), - Padding(padding: const EdgeInsets.all(24), child: footer), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: widget.child, + ), + ), + if (widget.footer != null) ...[ + const Divider(height: 1), + ListenableBuilder( + listenable: _blocker, + builder: (context, _) { + final blocked = _blocker.isBlocked; + return AbsorbPointer( + absorbing: blocked, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: blocked ? 0.45 : 1, + child: Padding( + padding: const EdgeInsets.all(24), + child: widget.footer, + ), + ), + ); + }, + ), + ], ], - ], + ), ); } } @@ -234,6 +445,22 @@ class SidePanelSection extends StatelessWidget { final String title; final List children; + /// Rows that already host Quick Add — do not wrap or the inline form + /// would be blocked too. + static bool _hostsQuickAdd(Widget child) { + return child is SidePanelFormRow || + child is FormRow || + child is FormRowThree || + child is FormRowFour || + child is QuickAddInlineHost; + } + + static Widget _blockable(Widget child) { + if (child is SizedBox && child.child == null) return child; + if (_hostsQuickAdd(child)) return child; + return QuickAddBlockable(child: child); + } + @override Widget build(BuildContext context) { final dividerColor = @@ -242,18 +469,25 @@ class SidePanelSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - color: Theme.of(context).colorScheme.onSurfaceVariant, + QuickAddBlockable( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), + const SizedBox(height: 8), + Divider(height: 1, color: dividerColor), + ], + ), ), - const SizedBox(height: 8), - Divider(height: 1, color: dividerColor), const SizedBox(height: 12), - ...children, + ...children.map(_blockable), const SizedBox(height: 16), ], ); @@ -275,36 +509,38 @@ class SidePanelFormRow extends StatelessWidget { @override Widget build(BuildContext context) { return QuickAddInlineHost( - child: LayoutBuilder( - builder: (context, constraints) { - final stack = constraints.maxWidth < 420; + child: QuickAddBlockable( + child: LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 420; + + if (stack) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + left, + const SizedBox(height: 12), + right, + ], + ), + ); + } - if (stack) { return Padding( padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - left, - const SizedBox(height: 12), - right, + Expanded(child: left), + SizedBox(width: spacing), + Expanded(child: right), ], ), ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: left), - SizedBox(width: spacing), - Expanded(child: right), - ], - ), - ); - }, + }, + ), ), ); } @@ -499,66 +735,68 @@ class FormRow extends StatelessWidget { @override Widget build(BuildContext context) { return QuickAddInlineHost( - child: LayoutBuilder( - builder: (context, constraints) { - final contentPadding = EdgeInsets.fromLTRB( - horizontalPadding, - 0, - horizontalPadding, - spacing, - ); - - if (constraints.maxWidth < stackBelowWidth) { - return Padding( - padding: contentPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) SizedBox(height: spacing), - children[i], - ], - ], - ), + child: QuickAddBlockable( + child: LayoutBuilder( + builder: (context, constraints) { + final contentPadding = EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + spacing, ); - } - if (columnCount != null) { - final slots = _columnSlots(); - final cols = columnCount!; - final innerWidth = constraints.maxWidth - (horizontalPadding * 2); - final columnWidth = (innerWidth - (cols - 1) * spacing) / cols; + if (constraints.maxWidth < stackBelowWidth) { + return Padding( + padding: contentPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) SizedBox(height: spacing), + children[i], + ], + ], + ), + ); + } + + if (columnCount != null) { + final slots = _columnSlots(); + final cols = columnCount!; + final innerWidth = constraints.maxWidth - (horizontalPadding * 2); + final columnWidth = (innerWidth - (cols - 1) * spacing) / cols; + + return Padding( + padding: contentPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < slots.length; i++) ...[ + if (i > 0) SizedBox(width: spacing), + SizedBox( + width: _spanWidth(columnWidth, slots[i].span), + child: slots[i].child, + ), + ], + ], + ), + ); + } return Padding( padding: contentPadding, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < slots.length; i++) ...[ + for (var i = 0; i < children.length; i++) ...[ if (i > 0) SizedBox(width: spacing), - SizedBox( - width: _spanWidth(columnWidth, slots[i].span), - child: slots[i].child, - ), + Expanded(child: children[i]), ], ], ), ); - } - - return Padding( - padding: contentPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) SizedBox(width: spacing), - Expanded(child: children[i]), - ], - ], - ), - ); - }, + }, + ), ), ); } diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index 378f800..b58749f 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -58,12 +58,29 @@ class _AppSidebarState extends ConsumerState { final Set _manuallyCollapsedMenus = {}; List get _primaryMenuItems => widget.menuItems - .where((item) => !menu.supportMenuRoutes.contains(item.route)) + .where((item) => !menu.isSupportMenuItem(item)) .toList(); - List get _supportMenuItems => widget.menuItems - .where((item) => menu.supportMenuRoutes.contains(item.route)) - .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 get _supportMenuItems { + final items = []; + 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); + } + } + return items; + } bool get _hasSupportSection => _supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;