filter changes

This commit is contained in:
Surendiran 2026-07-17 12:21:02 +05:30
parent 6721b308a9
commit 4360931be4
14 changed files with 491 additions and 148 deletions

View File

@ -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<AssetListScreen> {
bool _filtersExpanded = false;
@override
Widget build(BuildContext context) {
final assetsAsync = ref.watch(assetsListProvider);
@ -79,6 +82,13 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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<AssetListScreen> {
),
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<AssetListScreen> {
onStatusChanged: notifier.setStatusFilter,
),
),
const Divider(height: 1),
if (state.assets.isEmpty)
Expanded(
child: Center(

View File

@ -646,7 +646,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
},
),
),
const SizedBox(height: 12),
_SidePanelDateField(
label: 'Visit Date',
isRequired: true,
@ -677,7 +676,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
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<int>(
label: 'Vendor',
@ -702,7 +704,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
onChanged: (v) => setState(() => _assetConditionAfter = v),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _complaintNoController,
@ -717,7 +718,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
),
),
),
const SizedBox(height: 12),
AppTextField(
controller: _complaintDescController,
label: 'Complaint Description',
@ -737,7 +737,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
inputFormatters: Validators.mobileInput,
),
),
const SizedBox(height: 12),
AppTextField(
controller: _workDoneController,
label: 'Work Done',
@ -769,7 +768,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
),
),
),
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),
);
}
}

View File

@ -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<AuditLogsScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override
void dispose() {
@ -135,7 +137,14 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
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<AuditLogsScreen> {
: 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,
);
}
}

View File

@ -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<GrnListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override
void dispose() {
@ -74,6 +76,27 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
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<GrnListScreen> {
),
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<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<int?> 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,
);
}
}

View File

@ -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<MasterListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
@ -173,6 +175,27 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
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<MasterListScreen> {
),
Expanded(
child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: AppSearchExportBar(
searchController: _searchController,
searchHint: _searchHint(def),
isExporting: state.isExporting,
showExport: canExport,
showExport: false,
onSearch: notifier.setSearch,
onExport: _exportRecords,
),

View File

@ -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<PurchaseOrderListScreen> {
final _searchController = TextEditingController();
final Map<String, String> _knownStatuses = {};
bool _filtersExpanded = false;
@override
void initState() {
@ -122,7 +124,28 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
? 'Purchase orders awaiting approval'
: 'Create, approve and track procurement orders',
actions: [
if (!pendingOnly)
AppSearchFilterButton(
expanded: _filtersExpanded,
onPressed: () => 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<PurchaseOrderListScree
label: const Text('Create PO'),
),
),
],
],
),
Expanded(
child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar(
searchController: _searchController,
query: state.query,
statusOptions: _statusFilterOptions(state.orders),
showStatusFilter: !pendingOnly,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportPurchaseOrders,
onSearch: pendingOnly
? ref
.read(
@ -361,9 +383,6 @@ class _FiltersBar extends StatelessWidget {
required this.onSearch,
required this.onStatusChanged,
this.showStatusFilter = true,
this.showExport = false,
this.isExporting = false,
this.onExport,
});
final TextEditingController searchController;
@ -372,9 +391,6 @@ class _FiltersBar extends StatelessWidget {
final ValueChanged<String> onSearch;
final ValueChanged<String?> 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,
);
}
}

View File

@ -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<UsersRoleManagementScreen> {
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<RbacTab> 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<void> _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(

View File

@ -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<DepreciationReportScreen> {
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,
);
}

View File

@ -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<RoleListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override
void dispose() {
@ -51,12 +53,21 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
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(

View File

@ -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<UserListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override
void dispose() {
@ -60,6 +62,13 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
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<UserListScreen> {
],
Expanded(
child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar(
searchController: _searchController,
filters: state.filters,

View File

@ -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<VendorListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override
void dispose() {
@ -78,6 +80,27 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
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<VendorListScreen> {
),
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<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> 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,
);
}
}

View File

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

View File

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

View File

@ -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) ...[