From 4360931be49642c2e27fc4a2e5b5529df20c53fd Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Fri, 17 Jul 2026 12:21:02 +0530 Subject: [PATCH] filter changes --- .../screens/asset_list_screen.dart | 14 +- .../widgets/asset_side_panels.dart | 69 ++++++--- .../screens/audit_logs_screen.dart | 27 +++- .../presentation/screens/grn_list_screen.dart | 46 +++--- .../screens/master_list_screen.dart | 27 +++- .../screens/purchase_order_list_screen.dart | 49 +++--- .../screens/users_role_management_screen.dart | 145 ++++++++++++++---- .../screens/depreciation_report_screen.dart | 52 +++++-- .../screens/role_list_screen.dart | 13 +- .../screens/user_list_screen.dart | 10 ++ .../screens/vendor_list_screen.dart | 46 +++--- .../widgets/app_responsive_filter_bar.dart | 21 +++ .../widgets/app_search_filter_toggle.dart | 110 +++++++++++++ lib/shared/widgets/app_table_shell.dart | 10 +- 14 files changed, 491 insertions(+), 148 deletions(-) create mode 100644 lib/shared/widgets/app_search_filter_toggle.dart diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 5c406a1..16593cb 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -19,6 +19,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/can_permission.dart'; @@ -39,6 +40,8 @@ class AssetListScreen extends ConsumerStatefulWidget { } class _AssetListScreenState extends ConsumerState { + bool _filtersExpanded = false; + @override Widget build(BuildContext context) { final assetsAsync = ref.watch(assetsListProvider); @@ -79,6 +82,13 @@ class _AssetListScreenState extends ConsumerState { title: 'Asset Master', subtitle: 'Manage plant assets, AMC, service & insurance', actions: [ + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + const SizedBox(width: 8), if (canExport) OutlinedButton.icon( onPressed: state.isExporting ? null : _exportAssets, @@ -134,7 +144,8 @@ class _AssetListScreenState extends ConsumerState { ), child: Column( children: [ - Padding( + AppCollapsibleFilterPanel( + expanded: _filtersExpanded, padding: const EdgeInsets.all(16), child: _AssetsFilterBar( query: state.query, @@ -147,7 +158,6 @@ class _AssetListScreenState extends ConsumerState { onStatusChanged: notifier.setStatusFilter, ), ), - const Divider(height: 1), if (state.assets.isEmpty) Expanded( child: Center( diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 192879a..81dbd8b 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -646,7 +646,6 @@ class _LogServiceVisitPanelState extends ConsumerState { }, ), ), - const SizedBox(height: 12), _SidePanelDateField( label: 'Visit Date', isRequired: true, @@ -677,7 +676,10 @@ class _LogServiceVisitPanelState extends ConsumerState { const SizedBox(height: 12), SidePanelFormRow( left: lookupsAsync.when( - loading: () => const LinearProgressIndicator(), + loading: () => const Padding( + padding: EdgeInsets.only(top: 8), + child: LinearProgressIndicator(), + ), error: (_, __) => const Text('Failed to load vendors'), data: (lookups) => AppSearchableDropdown( label: 'Vendor', @@ -702,7 +704,6 @@ class _LogServiceVisitPanelState extends ConsumerState { onChanged: (v) => setState(() => _assetConditionAfter = v), ), ), - const SizedBox(height: 12), SidePanelFormRow( left: AppTextField( controller: _complaintNoController, @@ -717,7 +718,6 @@ class _LogServiceVisitPanelState extends ConsumerState { ), ), ), - const SizedBox(height: 12), AppTextField( controller: _complaintDescController, label: 'Complaint Description', @@ -737,7 +737,6 @@ class _LogServiceVisitPanelState extends ConsumerState { inputFormatters: Validators.mobileInput, ), ), - const SizedBox(height: 12), AppTextField( controller: _workDoneController, label: 'Work Done', @@ -769,7 +768,6 @@ class _LogServiceVisitPanelState extends ConsumerState { ), ), ), - const SizedBox(height: 12), AppTextField( controller: _serviceCostController, label: 'Service Cost', @@ -1651,7 +1649,7 @@ Widget _panelFooter( ); } -class _SidePanelDateField extends StatelessWidget { +class _SidePanelDateField extends StatefulWidget { const _SidePanelDateField({ required this.label, required this.value, @@ -1665,19 +1663,54 @@ class _SidePanelDateField extends StatelessWidget { final bool isRequired; @override - Widget build(BuildContext context) { - final labelText = isRequired ? '$label *' : label; - final displayText = value != null ? DateFormatter.displayDate(value) : ''; + State<_SidePanelDateField> createState() => _SidePanelDateFieldState(); +} - return TextFormField( - readOnly: true, - onTap: onPick, - decoration: InputDecoration( - labelText: labelText, - hintText: 'Select date', - suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20), +class _SidePanelDateFieldState extends State<_SidePanelDateField> { + late final TextEditingController _controller; + + String get _displayText => + widget.value != null ? DateFormatter.displayDate(widget.value) : ''; + + String get _labelText => + widget.isRequired ? '${widget.label} *' : widget.label; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _displayText); + } + + @override + void didUpdateWidget(covariant _SidePanelDateField oldWidget) { + super.didUpdateWidget(oldWidget); + final text = _displayText; + if (_controller.text != text) { + _controller.text = text; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: TextFormField( + readOnly: true, + onTap: widget.onPick, + controller: _controller, + decoration: InputDecoration( + labelText: _labelText, + hintText: 'Select date', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20), + ), ), - controller: TextEditingController(text: displayText), ); } } diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 02d98b4..190bd8c 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_status_chip.dart'; @@ -38,6 +39,7 @@ class AuditLogsScreen extends ConsumerStatefulWidget { class _AuditLogsScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; @override void dispose() { @@ -135,7 +137,14 @@ class _AuditLogsScreenState extends ConsumerState { title: 'Audit Logs', subtitle: 'System activity and change history', actions: [ - if (canExport) + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + if (canExport) ...[ + const SizedBox(width: 8), OutlinedButton.icon( onPressed: state.isExporting || !state.query.hasActiveFilter ? null @@ -149,10 +158,12 @@ class _AuditLogsScreenState extends ConsumerState { : const Icon(Icons.download_outlined), label: Text(state.isExporting ? 'Exporting...' : 'Export'), ), + ], ], ), Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: _FiltersBar( searchController: _searchController, filters: state.filters, @@ -327,9 +338,14 @@ class _FiltersBar extends StatelessWidget { onClear: _dateEmpty ? null : onClearDateRange, ); - final resetButton = TextButton( + final theme = Theme.of(context); + final iconColor = theme.colorScheme.primary; + final resetButton = IconButton( + tooltip: 'Reset', + color: iconColor, + disabledColor: iconColor.withValues(alpha: 0.38), onPressed: query.hasActiveFilter ? onReset : null, - child: const Text('Reset'), + icon: const Icon(Icons.restart_alt, size: 20), ); return AppResponsiveFilterGrid( @@ -339,11 +355,8 @@ class _FiltersBar extends StatelessWidget { actionDropdown, performerDropdown, dateField, - Align( - alignment: Alignment.centerRight, - child: resetButton, - ), ], + trailing: resetButton, ); } } diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index 34769c5..ca46edd 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -17,6 +17,7 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; @@ -38,6 +39,7 @@ class GrnListScreen extends ConsumerStatefulWidget { class _GrnListScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; @override void dispose() { @@ -74,6 +76,27 @@ class _GrnListScreenState extends ConsumerState { title: 'Goods Received Notes', subtitle: 'Record and track purchase order receipts', actions: [ + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + if (canExport) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: state.isExporting ? null : _exportGrns, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(state.isExporting ? 'Exporting...' : 'Export'), + ), + ], + const SizedBox(width: 8), CanPermission( module: 'grn', action: PermissionAction.create, @@ -87,13 +110,11 @@ class _GrnListScreenState extends ConsumerState { ), Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: _FiltersBar( searchController: _searchController, query: state.query, locationOptions: lookupsAsync.valueOrNull?.locations ?? const [], - showExport: canExport, - isExporting: state.isExporting, - onExport: _exportGrns, onSearch: ref.read(grnListProvider.notifier).setSearch, onStatusChanged: ref.read(grnListProvider.notifier).setStatusFilter, @@ -197,9 +218,6 @@ class _FiltersBar extends StatelessWidget { required this.onSearch, required this.onStatusChanged, required this.onLocationChanged, - this.showExport = false, - this.isExporting = false, - this.onExport, }); final TextEditingController searchController; @@ -208,9 +226,6 @@ class _FiltersBar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onStatusChanged; final ValueChanged onLocationChanged; - final bool showExport; - final bool isExporting; - final VoidCallback? onExport; @override Widget build(BuildContext context) { @@ -263,19 +278,6 @@ class _FiltersBar extends StatelessWidget { return AppResponsiveFilterBar( search: searchField, filters: filters, - trailing: showExport - ? OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ) - : null, ); } } 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 a3611e4..eb45a6c 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -13,6 +13,7 @@ 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'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_shell.dart'; @@ -35,6 +36,7 @@ class MasterListScreen extends ConsumerStatefulWidget { class _MasterListScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; MasterDefinition get _definition { final def = masterDefinitionById(widget.masterId); @@ -173,6 +175,27 @@ class _MasterListScreenState extends ConsumerState { title: def.title, subtitle: def.subtitle, actions: [ + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + if (canExport) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: state.isExporting ? null : _exportRecords, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(state.isExporting ? 'Exporting...' : 'Export'), + ), + ], + const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => context.push(RouteConstants.masterData), icon: const Icon(Icons.grid_view_outlined), @@ -189,11 +212,11 @@ class _MasterListScreenState extends ConsumerState { ), Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: AppSearchExportBar( searchController: _searchController, searchHint: _searchHint(def), - isExporting: state.isExporting, - showExport: canExport, + showExport: false, onSearch: notifier.setSearch, onExport: _exportRecords, ), diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 74c00bc..a644706 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -19,6 +19,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; @@ -46,6 +47,7 @@ class PurchaseOrderListScreen extends ConsumerStatefulWidget { class _PurchaseOrderListScreenState extends ConsumerState { final _searchController = TextEditingController(); final Map _knownStatuses = {}; + bool _filtersExpanded = false; @override void initState() { @@ -122,7 +124,28 @@ class _PurchaseOrderListScreenState extends ConsumerState setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + if (canExport) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: state.isExporting ? null : _exportPurchaseOrders, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(state.isExporting ? 'Exporting...' : 'Export'), + ), + ], + if (!pendingOnly) ...[ + const SizedBox(width: 8), CanPermission( module: 'purchase_orders', action: PermissionAction.create, @@ -133,18 +156,17 @@ class _PurchaseOrderListScreenState extends ConsumerState onSearch; final ValueChanged onStatusChanged; final bool showStatusFilter; - final bool showExport; - final bool isExporting; - final VoidCallback? onExport; @override Widget build(BuildContext context) { @@ -399,19 +415,6 @@ class _FiltersBar extends StatelessWidget { return AppResponsiveFilterBar( search: searchField, filters: filters, - trailing: showExport - ? OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ) - : null, ); } } 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 c03d595..684bcee 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -18,10 +18,10 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../users/presentation/providers/users_provider.dart'; -import '../../domain/entities/rbac_entities.dart'; import '../providers/add_user_form_provider.dart'; import '../providers/role_form_provider.dart'; import '../providers/rbac_provider.dart'; @@ -51,6 +51,8 @@ class UsersRoleManagementScreen extends ConsumerStatefulWidget { class _UsersRoleManagementScreenState extends ConsumerState { + bool _userFiltersExpanded = false; + @override void initState() { super.initState(); @@ -233,6 +235,16 @@ class _UsersRoleManagementScreenState canViewUsers: canViewUsers, canViewRoles: canViewRoles, canEditRoles: canEditRoles, + usersFiltersExpanded: _userFiltersExpanded, + onToggleUsersFilters: () => setState( + () => _userFiltersExpanded = !_userFiltersExpanded, + ), + onSelectTab: (tab) { + if (tab != RbacTab.users && _userFiltersExpanded) { + setState(() => _userFiltersExpanded = false); + } + ref.read(rbacProvider.notifier).setTab(tab); + }, ), const SizedBox(height: 16), Expanded( @@ -246,6 +258,7 @@ class _UsersRoleManagementScreenState children: [ if (canViewUsers) _UsersTab( + filtersExpanded: _userFiltersExpanded, onAddUser: _openAddUser, onEditUser: (user) => _openUserPanel(userId: user.id), ), @@ -360,6 +373,9 @@ class _TabBar extends ConsumerWidget { required this.canViewUsers, required this.canViewRoles, required this.canEditRoles, + required this.usersFiltersExpanded, + required this.onToggleUsersFilters, + required this.onSelectTab, }); final RbacState state; @@ -368,41 +384,104 @@ class _TabBar extends ConsumerWidget { final bool canViewUsers; final bool canViewRoles; final bool canEditRoles; + final bool usersFiltersExpanded; + final VoidCallback onToggleUsersFilters; + final ValueChanged onSelectTab; @override Widget build(BuildContext context, WidgetRef ref) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - if (canViewUsers) - _TabButton( - label: 'Users ($userCount)', - icon: Icons.people_outline, - selected: state.selectedTab == RbacTab.users, - onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users), + final isUsersTab = state.selectedTab == RbacTab.users; + final usersState = ref.watch(usersListProvider).valueOrNull; + final canExport = ref.can('users', PermissionAction.export); + final isExporting = usersState?.isExporting ?? false; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + if (canViewUsers) + _TabButton( + label: 'Users ($userCount)', + icon: Icons.people_outline, + selected: isUsersTab, + onTap: () => onSelectTab(RbacTab.users), + ), + if (canViewRoles) + _TabButton( + label: 'Roles ($roleCount)', + icon: Icons.shield_outlined, + selected: state.selectedTab == RbacTab.roles, + onTap: () => onSelectTab(RbacTab.roles), + ), + if (canEditRoles) + _TabButton( + label: 'Permission Matrix', + icon: Icons.vpn_key_outlined, + selected: state.selectedTab == RbacTab.permissions, + onTap: () => onSelectTab(RbacTab.permissions), + ), + ], ), - if (canViewRoles) - _TabButton( - label: 'Roles ($roleCount)', - icon: Icons.shield_outlined, - selected: state.selectedTab == RbacTab.roles, - onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles), - ), - if (canEditRoles) - _TabButton( - label: 'Permission Matrix', - icon: Icons.vpn_key_outlined, - selected: state.selectedTab == RbacTab.permissions, - onTap: () => - ref.read(rbacProvider.notifier).setTab(RbacTab.permissions), + ), + ), + if (isUsersTab) ...[ + const SizedBox(width: 12), + AppSearchFilterButton( + expanded: usersFiltersExpanded, + onPressed: onToggleUsersFilters, + ), + if (canExport) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: isExporting + ? null + : () => _exportUsersFromTabBar(context, ref), + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), ), + ], ], - ), + ], ); } } +Future _exportUsersFromTabBar(BuildContext context, WidgetRef ref) async { + final file = await ref.read(usersListProvider.notifier).exportUsers(); + if (!context.mounted) return; + + if (file == null) { + final error = ref.read(usersListProvider).valueOrNull?.actionError; + if (error != null) { + showAppToastFromSnackBar(context, SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!context.mounted) return; + + showAppToastFromSnackBar( + context, + SnackBar( + content: Text(saved ? 'Downloaded ${file.fileName}' : 'Export cancelled'), + ), + ); +} + class _TabButton extends StatelessWidget { const _TabButton({ required this.label, @@ -458,10 +537,12 @@ class _TabButton extends StatelessWidget { class _UsersTab extends ConsumerStatefulWidget { const _UsersTab({ + required this.filtersExpanded, required this.onAddUser, required this.onEditUser, }); + final bool filtersExpanded; final VoidCallback onAddUser; final void Function(ManagedUserModel user) onEditUser; @@ -674,7 +755,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> { ), data: (usersState) { final filters = usersState.filters; - final canExport = ref.can('users', PermissionAction.export); final canEditUser = ref.can('users', PermissionAction.update); final canDeleteUser = ref.can('users', PermissionAction.delete); final roles = ['All Roles', ...?filters?.roles.map((r) => r.name)]; @@ -714,7 +794,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> { ), child: Column( children: [ - Padding( + AppCollapsibleFilterPanel( + expanded: widget.filtersExpanded, padding: const EdgeInsets.all(16), child: _UsersFilterBar( roleFilter: roleFilter, @@ -723,11 +804,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> { roles: roles, departments: departments, statuses: statuses, - isExporting: usersState.isExporting, - showExport: canExport, + showExport: false, searchController: _searchController, onExport: _exportUsers, - onSearch: ref.read(usersListProvider.notifier).setSearch, + isExporting: usersState.isExporting, + onSearch: (value) => + ref.read(usersListProvider.notifier).setSearch(value), onRoleChanged: (value) { ref.read(usersListProvider.notifier).setRoleFilter( value == 'All Roles' @@ -751,7 +833,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> { }, ), ), - const Divider(height: 1), if (usersState.users.isEmpty) Expanded( child: Center( diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index b1db0d7..b1c7043 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -20,6 +20,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; @@ -39,6 +40,7 @@ class DepreciationReportScreen extends ConsumerStatefulWidget { class _DepreciationReportScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; @override void dispose() { @@ -149,7 +151,14 @@ class _DepreciationReportScreenState icon: const Icon(Icons.arrow_back), ), actions: [ - if (canExport) + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + if (canExport) ...[ + const SizedBox(width: 8), OutlinedButton.icon( onPressed: state.isExporting ? null : _export, icon: state.isExporting @@ -163,12 +172,14 @@ class _DepreciationReportScreenState state.isExporting ? 'Exporting...' : 'Export', ), ), + ], ], ), _SummaryStrip(summary: state.summary, asOfDate: state.asOfDate), const SizedBox(height: 12), Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: _FiltersBar( searchController: _searchController, filters: state.filters, @@ -500,25 +511,37 @@ class _FiltersBarState extends State<_FiltersBar> { onClear: purchaseEmpty ? null : widget.onClearPurchaseRange, ); - final moreButton = TextButton.icon( + final moreTooltip = moreCount > 0 + ? (_moreOpen ? 'Less filters ($moreCount)' : 'More filters ($moreCount)') + : (_moreOpen ? 'Less filters' : 'More filters'); + + final iconColor = theme.colorScheme.primary; + + final moreButton = IconButton( + tooltip: moreTooltip, + color: iconColor, onPressed: () => setState(() => _moreOpen = !_moreOpen), - icon: Icon( - _moreOpen ? Icons.expand_less : Icons.tune_outlined, - size: 18, - ), - label: Text( - moreCount > 0 - ? (_moreOpen ? 'Less filters ($moreCount)' : 'More filters ($moreCount)') - : (_moreOpen ? 'Less filters' : 'More filters'), + icon: Badge( + isLabelVisible: moreCount > 0, + label: Text('$moreCount'), + child: Icon( + _moreOpen ? Icons.expand_less : Icons.tune_outlined, + size: 20, + color: iconColor, + ), ), ); - final reset = TextButton( + final reset = IconButton( + tooltip: 'Reset', + color: iconColor, + disabledColor: iconColor.withValues(alpha: 0.38), onPressed: query.hasActiveFilter ? widget.onReset : null, - child: const Text('Reset'), + icon: const Icon(Icons.restart_alt, size: 20), ); final actions = Row( + mainAxisSize: MainAxisSize.min, children: [ moreButton, reset, @@ -550,10 +573,7 @@ class _FiltersBarState extends State<_FiltersBar> { return AppResponsiveFilterGrid( fields: [searchField, location, category, asOfField], - footer: Align( - alignment: Alignment.centerRight, - child: actions, - ), + trailing: actions, extra: moreFilters, ); } diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 28d0aa1..608d0a7 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -11,6 +11,7 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_search_field.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/error_view.dart'; @@ -26,6 +27,7 @@ class RoleListScreen extends ConsumerStatefulWidget { class _RoleListScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; @override void dispose() { @@ -51,12 +53,21 @@ class _RoleListScreenState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const PageHeader( + PageHeader( title: 'Roles', subtitle: 'Manage roles and permission assignments', + actions: [ + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + ], ), Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: SizedBox( width: context.isMobile ? double.infinity : 320, child: AppSearchField( diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 54edb4e..0764ce9 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -14,6 +14,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/error_view.dart'; @@ -34,6 +35,7 @@ class UserListScreen extends ConsumerStatefulWidget { class _UserListScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; @override void dispose() { @@ -60,6 +62,13 @@ class _UserListScreenState extends ConsumerState { title: 'Users', subtitle: 'Manage employee accounts', actions: [ + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + const SizedBox(width: 8), ElevatedButton.icon( onPressed: () => context.push(RouteConstants.userAdd), icon: const Icon(Icons.person_add), @@ -73,6 +82,7 @@ class _UserListScreenState extends ConsumerState { ], Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: _FiltersBar( searchController: _searchController, filters: state.filters, diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 80bd60e..426bcfe 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; +import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/can_permission.dart'; @@ -38,6 +39,7 @@ class VendorListScreen extends ConsumerStatefulWidget { class _VendorListScreenState extends ConsumerState { final _searchController = TextEditingController(); + bool _filtersExpanded = false; @override void dispose() { @@ -78,6 +80,27 @@ class _VendorListScreenState extends ConsumerState { title: 'Vendors', subtitle: 'Manage suppliers, service providers and vendor master data', actions: [ + AppSearchFilterButton( + expanded: _filtersExpanded, + onPressed: () => setState( + () => _filtersExpanded = !_filtersExpanded, + ), + ), + if (canExport) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: state.isExporting ? null : _exportVendors, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(state.isExporting ? 'Exporting...' : 'Export'), + ), + ], + const SizedBox(width: 8), CanPermission( module: 'vendors', action: PermissionAction.create, @@ -91,12 +114,10 @@ class _VendorListScreenState extends ConsumerState { ), Expanded( child: AppTableShell( + toolbarExpanded: _filtersExpanded, toolbar: _FiltersBar( searchController: _searchController, query: state.query, - showExport: canExport, - isExporting: state.isExporting, - onExport: _exportVendors, onSearch: ref.read(vendorsListProvider.notifier).setSearch, onStatusChanged: ref.read(vendorsListProvider.notifier).setStatusFilter, @@ -208,9 +229,6 @@ class _FiltersBar extends StatelessWidget { required this.onSearch, required this.onStatusChanged, required this.onVendorTypeChanged, - this.showExport = false, - this.isExporting = false, - this.onExport, }); final TextEditingController searchController; @@ -218,9 +236,6 @@ class _FiltersBar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onStatusChanged; final ValueChanged onVendorTypeChanged; - final bool showExport; - final bool isExporting; - final VoidCallback? onExport; @override Widget build(BuildContext context) { @@ -262,19 +277,6 @@ class _FiltersBar extends StatelessWidget { return AppResponsiveFilterBar( search: searchField, filters: filters, - trailing: showExport - ? OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ) - : null, ); } } diff --git a/lib/shared/widgets/app_responsive_filter_bar.dart b/lib/shared/widgets/app_responsive_filter_bar.dart index afcdaf7..40450a3 100644 --- a/lib/shared/widgets/app_responsive_filter_bar.dart +++ b/lib/shared/widgets/app_responsive_filter_bar.dart @@ -149,6 +149,7 @@ class AppResponsiveFilterGrid extends StatelessWidget { this.runSpacing = 12, this.minFieldWidth = 160, this.maxColumns = 4, + this.trailing, this.footer, this.extra, }); @@ -158,6 +159,8 @@ class AppResponsiveFilterGrid extends StatelessWidget { final double runSpacing; final double minFieldWidth; final int maxColumns; + /// Placed at the end of the primary filter row (wide layouts). + final Widget? trailing; final Widget? footer; final Widget? extra; @@ -169,6 +172,7 @@ class AppResponsiveFilterGrid extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; + final hasTrailing = trailing != null; Widget content; if (maxWidth >= _rowBreakpoint && fields.length <= maxColumns) { @@ -179,6 +183,13 @@ class AppResponsiveFilterGrid extends StatelessWidget { if (i > 0) SizedBox(width: spacing), Expanded(child: fields[i]), ], + if (hasTrailing) ...[ + SizedBox(width: spacing), + Padding( + padding: const EdgeInsets.only(top: 8), + child: trailing!, + ), + ], ], ); } else if (maxWidth >= _stackBreakpoint) { @@ -191,9 +202,15 @@ class AppResponsiveFilterGrid extends StatelessWidget { content = Wrap( spacing: spacing, runSpacing: runSpacing, + crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final field in fields) SizedBox(width: itemWidth, child: field), + if (hasTrailing) + Padding( + padding: const EdgeInsets.only(top: 8), + child: trailing!, + ), ], ); } else { @@ -204,6 +221,10 @@ class AppResponsiveFilterGrid extends StatelessWidget { if (i > 0) SizedBox(height: runSpacing), fields[i], ], + if (hasTrailing) ...[ + SizedBox(height: runSpacing), + Align(alignment: Alignment.centerRight, child: trailing!), + ], ], ); } diff --git a/lib/shared/widgets/app_search_filter_toggle.dart b/lib/shared/widgets/app_search_filter_toggle.dart new file mode 100644 index 0000000..3ec5fb8 --- /dev/null +++ b/lib/shared/widgets/app_search_filter_toggle.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +/// Header action that toggles the collapsible search/filter panel. +class AppSearchFilterButton extends StatelessWidget { + const AppSearchFilterButton({ + super.key, + required this.expanded, + required this.onPressed, + }); + + final bool expanded; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeColor = theme.colorScheme.primary; + + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: + expanded ? activeColor.withValues(alpha: 0.08) : null, + foregroundColor: + expanded ? activeColor : theme.colorScheme.onSurface, + side: BorderSide( + color: expanded + ? activeColor.withValues(alpha: 0.45) + : theme.colorScheme.outline.withValues(alpha: 0.35), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search, size: 18, color: expanded ? activeColor : null), + const SizedBox(width: 2), + Icon( + Icons.filter_list, + size: 18, + color: expanded ? activeColor : null, + ), + const SizedBox(width: 8), + const Text('Search & Filter'), + const SizedBox(width: 4), + Icon( + expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: expanded ? activeColor : null, + ), + ], + ), + ); + } +} + +/// Animates a search/filter panel open/closed, pushing content below. +class AppCollapsibleFilterPanel extends StatelessWidget { + const AppCollapsibleFilterPanel({ + super.key, + required this.expanded, + required this.child, + this.padding = const EdgeInsets.fromLTRB(16, 12, 16, 12), + this.showDivider = true, + this.duration = const Duration(milliseconds: 280), + this.curve = Curves.easeInOutCubic, + }); + + final bool expanded; + final Widget child; + final EdgeInsetsGeometry padding; + final bool showDivider; + final Duration duration; + final Curve curve; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRect( + child: AnimatedAlign( + duration: duration, + curve: curve, + alignment: Alignment.topCenter, + heightFactor: expanded ? 1.0 : 0.0, + child: Padding( + padding: padding, + child: child, + ), + ), + ), + AnimatedSwitcher( + duration: duration, + switchInCurve: curve, + switchOutCurve: curve, + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: animation, + alignment: Alignment.topCenter, + child: child, + ), + child: expanded && showDivider + ? const Divider(key: ValueKey('filter-divider'), height: 1) + : const SizedBox.shrink(key: ValueKey('filter-divider-gone')), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/app_table_shell.dart b/lib/shared/widgets/app_table_shell.dart index 5ae11b4..5248e03 100644 --- a/lib/shared/widgets/app_table_shell.dart +++ b/lib/shared/widgets/app_table_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'app_card.dart'; +import 'app_search_filter_toggle.dart'; /// Card shell for list pages: toolbar, divider, full-width table body, footer. class AppTableShell extends StatelessWidget { @@ -9,12 +10,16 @@ class AppTableShell extends StatelessWidget { required this.toolbar, required this.child, this.footer, + this.toolbarExpanded = false, }); final Widget toolbar; final Widget child; final Widget? footer; + /// When false (default), the search/filter toolbar is collapsed. + final bool toolbarExpanded; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -32,11 +37,10 @@ class AppTableShell extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + AppCollapsibleFilterPanel( + expanded: toolbarExpanded, child: toolbar, ), - const Divider(height: 1), // Clip so scrolling rows cannot paint over the footer/pagination. Expanded(child: ClipRect(child: child)), if (footer != null) ...[