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_searchable_dropdown.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.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_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
@ -39,6 +40,8 @@ class AssetListScreen extends ConsumerStatefulWidget {
} }
class _AssetListScreenState extends ConsumerState<AssetListScreen> { class _AssetListScreenState extends ConsumerState<AssetListScreen> {
bool _filtersExpanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final assetsAsync = ref.watch(assetsListProvider); final assetsAsync = ref.watch(assetsListProvider);
@ -79,6 +82,13 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
title: 'Asset Master', title: 'Asset Master',
subtitle: 'Manage plant assets, AMC, service & insurance', subtitle: 'Manage plant assets, AMC, service & insurance',
actions: [ actions: [
AppSearchFilterButton(
expanded: _filtersExpanded,
onPressed: () => setState(
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
if (canExport) if (canExport)
OutlinedButton.icon( OutlinedButton.icon(
onPressed: state.isExporting ? null : _exportAssets, onPressed: state.isExporting ? null : _exportAssets,
@ -134,7 +144,8 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
), ),
child: Column( child: Column(
children: [ children: [
Padding( AppCollapsibleFilterPanel(
expanded: _filtersExpanded,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: _AssetsFilterBar( child: _AssetsFilterBar(
query: state.query, query: state.query,
@ -147,7 +158,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onStatusChanged: notifier.setStatusFilter, onStatusChanged: notifier.setStatusFilter,
), ),
), ),
const Divider(height: 1),
if (state.assets.isEmpty) if (state.assets.isEmpty)
Expanded( Expanded(
child: Center( child: Center(

View File

@ -646,7 +646,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
}, },
), ),
), ),
const SizedBox(height: 12),
_SidePanelDateField( _SidePanelDateField(
label: 'Visit Date', label: 'Visit Date',
isRequired: true, isRequired: true,
@ -677,7 +676,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
const SizedBox(height: 12), const SizedBox(height: 12),
SidePanelFormRow( SidePanelFormRow(
left: lookupsAsync.when( left: lookupsAsync.when(
loading: () => const LinearProgressIndicator(), loading: () => const Padding(
padding: EdgeInsets.only(top: 8),
child: LinearProgressIndicator(),
),
error: (_, __) => const Text('Failed to load vendors'), error: (_, __) => const Text('Failed to load vendors'),
data: (lookups) => AppSearchableDropdown<int>( data: (lookups) => AppSearchableDropdown<int>(
label: 'Vendor', label: 'Vendor',
@ -702,7 +704,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
onChanged: (v) => setState(() => _assetConditionAfter = v), onChanged: (v) => setState(() => _assetConditionAfter = v),
), ),
), ),
const SizedBox(height: 12),
SidePanelFormRow( SidePanelFormRow(
left: AppTextField( left: AppTextField(
controller: _complaintNoController, controller: _complaintNoController,
@ -717,7 +718,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
), ),
), ),
), ),
const SizedBox(height: 12),
AppTextField( AppTextField(
controller: _complaintDescController, controller: _complaintDescController,
label: 'Complaint Description', label: 'Complaint Description',
@ -737,7 +737,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
inputFormatters: Validators.mobileInput, inputFormatters: Validators.mobileInput,
), ),
), ),
const SizedBox(height: 12),
AppTextField( AppTextField(
controller: _workDoneController, controller: _workDoneController,
label: 'Work Done', label: 'Work Done',
@ -769,7 +768,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
), ),
), ),
), ),
const SizedBox(height: 12),
AppTextField( AppTextField(
controller: _serviceCostController, controller: _serviceCostController,
label: 'Service Cost', label: 'Service Cost',
@ -1651,7 +1649,7 @@ Widget _panelFooter(
); );
} }
class _SidePanelDateField extends StatelessWidget { class _SidePanelDateField extends StatefulWidget {
const _SidePanelDateField({ const _SidePanelDateField({
required this.label, required this.label,
required this.value, required this.value,
@ -1665,19 +1663,54 @@ class _SidePanelDateField extends StatelessWidget {
final bool isRequired; final bool isRequired;
@override @override
Widget build(BuildContext context) { State<_SidePanelDateField> createState() => _SidePanelDateFieldState();
final labelText = isRequired ? '$label *' : label; }
final displayText = value != null ? DateFormatter.displayDate(value) : '';
return TextFormField( class _SidePanelDateFieldState extends State<_SidePanelDateField> {
readOnly: true, late final TextEditingController _controller;
onTap: onPick,
decoration: InputDecoration( String get _displayText =>
labelText: labelText, widget.value != null ? DateFormatter.displayDate(widget.value) : '';
hintText: 'Select date',
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20), 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_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.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_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
@ -38,6 +39,7 @@ class AuditLogsScreen extends ConsumerStatefulWidget {
class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> { class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override @override
void dispose() { void dispose() {
@ -135,7 +137,14 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
title: 'Audit Logs', title: 'Audit Logs',
subtitle: 'System activity and change history', subtitle: 'System activity and change history',
actions: [ actions: [
if (canExport) AppSearchFilterButton(
expanded: _filtersExpanded,
onPressed: () => setState(
() => _filtersExpanded = !_filtersExpanded,
),
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: state.isExporting || !state.query.hasActiveFilter onPressed: state.isExporting || !state.query.hasActiveFilter
? null ? null
@ -149,10 +158,12 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
: const Icon(Icons.download_outlined), : const Icon(Icons.download_outlined),
label: Text(state.isExporting ? 'Exporting...' : 'Export'), label: Text(state.isExporting ? 'Exporting...' : 'Export'),
), ),
],
], ],
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar( toolbar: _FiltersBar(
searchController: _searchController, searchController: _searchController,
filters: state.filters, filters: state.filters,
@ -327,9 +338,14 @@ class _FiltersBar extends StatelessWidget {
onClear: _dateEmpty ? null : onClearDateRange, 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, onPressed: query.hasActiveFilter ? onReset : null,
child: const Text('Reset'), icon: const Icon(Icons.restart_alt, size: 20),
); );
return AppResponsiveFilterGrid( return AppResponsiveFilterGrid(
@ -339,11 +355,8 @@ class _FiltersBar extends StatelessWidget {
actionDropdown, actionDropdown,
performerDropdown, performerDropdown,
dateField, 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_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.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/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
@ -38,6 +39,7 @@ class GrnListScreen extends ConsumerStatefulWidget {
class _GrnListScreenState extends ConsumerState<GrnListScreen> { class _GrnListScreenState extends ConsumerState<GrnListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override @override
void dispose() { void dispose() {
@ -74,6 +76,27 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
title: 'Goods Received Notes', title: 'Goods Received Notes',
subtitle: 'Record and track purchase order receipts', subtitle: 'Record and track purchase order receipts',
actions: [ 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( CanPermission(
module: 'grn', module: 'grn',
action: PermissionAction.create, action: PermissionAction.create,
@ -87,13 +110,11 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar( toolbar: _FiltersBar(
searchController: _searchController, searchController: _searchController,
query: state.query, query: state.query,
locationOptions: lookupsAsync.valueOrNull?.locations ?? const [], locationOptions: lookupsAsync.valueOrNull?.locations ?? const [],
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportGrns,
onSearch: ref.read(grnListProvider.notifier).setSearch, onSearch: ref.read(grnListProvider.notifier).setSearch,
onStatusChanged: onStatusChanged:
ref.read(grnListProvider.notifier).setStatusFilter, ref.read(grnListProvider.notifier).setStatusFilter,
@ -197,9 +218,6 @@ class _FiltersBar extends StatelessWidget {
required this.onSearch, required this.onSearch,
required this.onStatusChanged, required this.onStatusChanged,
required this.onLocationChanged, required this.onLocationChanged,
this.showExport = false,
this.isExporting = false,
this.onExport,
}); });
final TextEditingController searchController; final TextEditingController searchController;
@ -208,9 +226,6 @@ class _FiltersBar extends StatelessWidget {
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged; final ValueChanged<String?> onStatusChanged;
final ValueChanged<int?> onLocationChanged; final ValueChanged<int?> onLocationChanged;
final bool showExport;
final bool isExporting;
final VoidCallback? onExport;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -263,19 +278,6 @@ class _FiltersBar extends StatelessWidget {
return AppResponsiveFilterBar( return AppResponsiveFilterBar(
search: searchField, search: searchField,
filters: filters, 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_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_export_bar.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_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_shell.dart';
@ -35,6 +36,7 @@ class MasterListScreen extends ConsumerStatefulWidget {
class _MasterListScreenState extends ConsumerState<MasterListScreen> { class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
MasterDefinition get _definition { MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId); final def = masterDefinitionById(widget.masterId);
@ -173,6 +175,27 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
title: def.title, title: def.title,
subtitle: def.subtitle, subtitle: def.subtitle,
actions: [ 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( OutlinedButton.icon(
onPressed: () => context.push(RouteConstants.masterData), onPressed: () => context.push(RouteConstants.masterData),
icon: const Icon(Icons.grid_view_outlined), icon: const Icon(Icons.grid_view_outlined),
@ -189,11 +212,11 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: AppSearchExportBar( toolbar: AppSearchExportBar(
searchController: _searchController, searchController: _searchController,
searchHint: _searchHint(def), searchHint: _searchHint(def),
isExporting: state.isExporting, showExport: false,
showExport: canExport,
onSearch: notifier.setSearch, onSearch: notifier.setSearch,
onExport: _exportRecords, 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_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.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_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
@ -46,6 +47,7 @@ class PurchaseOrderListScreen extends ConsumerStatefulWidget {
class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScreen> { class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final Map<String, String> _knownStatuses = {}; final Map<String, String> _knownStatuses = {};
bool _filtersExpanded = false;
@override @override
void initState() { void initState() {
@ -122,7 +124,28 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
? 'Purchase orders awaiting approval' ? 'Purchase orders awaiting approval'
: 'Create, approve and track procurement orders', : 'Create, approve and track procurement orders',
actions: [ 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( CanPermission(
module: 'purchase_orders', module: 'purchase_orders',
action: PermissionAction.create, action: PermissionAction.create,
@ -133,18 +156,17 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
label: const Text('Create PO'), label: const Text('Create PO'),
), ),
), ),
],
], ],
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar( toolbar: _FiltersBar(
searchController: _searchController, searchController: _searchController,
query: state.query, query: state.query,
statusOptions: _statusFilterOptions(state.orders), statusOptions: _statusFilterOptions(state.orders),
showStatusFilter: !pendingOnly, showStatusFilter: !pendingOnly,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportPurchaseOrders,
onSearch: pendingOnly onSearch: pendingOnly
? ref ? ref
.read( .read(
@ -361,9 +383,6 @@ class _FiltersBar extends StatelessWidget {
required this.onSearch, required this.onSearch,
required this.onStatusChanged, required this.onStatusChanged,
this.showStatusFilter = true, this.showStatusFilter = true,
this.showExport = false,
this.isExporting = false,
this.onExport,
}); });
final TextEditingController searchController; final TextEditingController searchController;
@ -372,9 +391,6 @@ class _FiltersBar extends StatelessWidget {
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged; final ValueChanged<String?> onStatusChanged;
final bool showStatusFilter; final bool showStatusFilter;
final bool showExport;
final bool isExporting;
final VoidCallback? onExport;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -399,19 +415,6 @@ class _FiltersBar extends StatelessWidget {
return AppResponsiveFilterBar( return AppResponsiveFilterBar(
search: searchField, search: searchField,
filters: filters, 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_card.dart';
import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_loading_view.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/app_searchable_dropdown.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../users/presentation/providers/users_provider.dart'; import '../../../users/presentation/providers/users_provider.dart';
import '../../domain/entities/rbac_entities.dart';
import '../providers/add_user_form_provider.dart'; import '../providers/add_user_form_provider.dart';
import '../providers/role_form_provider.dart'; import '../providers/role_form_provider.dart';
import '../providers/rbac_provider.dart'; import '../providers/rbac_provider.dart';
@ -51,6 +51,8 @@ class UsersRoleManagementScreen extends ConsumerStatefulWidget {
class _UsersRoleManagementScreenState class _UsersRoleManagementScreenState
extends ConsumerState<UsersRoleManagementScreen> { extends ConsumerState<UsersRoleManagementScreen> {
bool _userFiltersExpanded = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -233,6 +235,16 @@ class _UsersRoleManagementScreenState
canViewUsers: canViewUsers, canViewUsers: canViewUsers,
canViewRoles: canViewRoles, canViewRoles: canViewRoles,
canEditRoles: canEditRoles, 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), const SizedBox(height: 16),
Expanded( Expanded(
@ -246,6 +258,7 @@ class _UsersRoleManagementScreenState
children: [ children: [
if (canViewUsers) if (canViewUsers)
_UsersTab( _UsersTab(
filtersExpanded: _userFiltersExpanded,
onAddUser: _openAddUser, onAddUser: _openAddUser,
onEditUser: (user) => _openUserPanel(userId: user.id), onEditUser: (user) => _openUserPanel(userId: user.id),
), ),
@ -360,6 +373,9 @@ class _TabBar extends ConsumerWidget {
required this.canViewUsers, required this.canViewUsers,
required this.canViewRoles, required this.canViewRoles,
required this.canEditRoles, required this.canEditRoles,
required this.usersFiltersExpanded,
required this.onToggleUsersFilters,
required this.onSelectTab,
}); });
final RbacState state; final RbacState state;
@ -368,41 +384,104 @@ class _TabBar extends ConsumerWidget {
final bool canViewUsers; final bool canViewUsers;
final bool canViewRoles; final bool canViewRoles;
final bool canEditRoles; final bool canEditRoles;
final bool usersFiltersExpanded;
final VoidCallback onToggleUsersFilters;
final ValueChanged<RbacTab> onSelectTab;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return SingleChildScrollView( final isUsersTab = state.selectedTab == RbacTab.users;
scrollDirection: Axis.horizontal, final usersState = ref.watch(usersListProvider).valueOrNull;
child: Row( final canExport = ref.can('users', PermissionAction.export);
children: [ final isExporting = usersState?.isExporting ?? false;
if (canViewUsers)
_TabButton( return Row(
label: 'Users ($userCount)', crossAxisAlignment: CrossAxisAlignment.center,
icon: Icons.people_outline, children: [
selected: state.selectedTab == RbacTab.users, Expanded(
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users), 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)', if (isUsersTab) ...[
icon: Icons.shield_outlined, const SizedBox(width: 12),
selected: state.selectedTab == RbacTab.roles, AppSearchFilterButton(
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles), expanded: usersFiltersExpanded,
), onPressed: onToggleUsersFilters,
if (canEditRoles) ),
_TabButton( if (canExport) ...[
label: 'Permission Matrix', const SizedBox(width: 8),
icon: Icons.vpn_key_outlined, OutlinedButton.icon(
selected: state.selectedTab == RbacTab.permissions, onPressed: isExporting
onTap: () => ? null
ref.read(rbacProvider.notifier).setTab(RbacTab.permissions), : () => _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 { class _TabButton extends StatelessWidget {
const _TabButton({ const _TabButton({
required this.label, required this.label,
@ -458,10 +537,12 @@ class _TabButton extends StatelessWidget {
class _UsersTab extends ConsumerStatefulWidget { class _UsersTab extends ConsumerStatefulWidget {
const _UsersTab({ const _UsersTab({
required this.filtersExpanded,
required this.onAddUser, required this.onAddUser,
required this.onEditUser, required this.onEditUser,
}); });
final bool filtersExpanded;
final VoidCallback onAddUser; final VoidCallback onAddUser;
final void Function(ManagedUserModel user) onEditUser; final void Function(ManagedUserModel user) onEditUser;
@ -674,7 +755,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
), ),
data: (usersState) { data: (usersState) {
final filters = usersState.filters; final filters = usersState.filters;
final canExport = ref.can('users', PermissionAction.export);
final canEditUser = ref.can('users', PermissionAction.update); final canEditUser = ref.can('users', PermissionAction.update);
final canDeleteUser = ref.can('users', PermissionAction.delete); final canDeleteUser = ref.can('users', PermissionAction.delete);
final roles = ['All Roles', ...?filters?.roles.map((r) => r.name)]; final roles = ['All Roles', ...?filters?.roles.map((r) => r.name)];
@ -714,7 +794,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
), ),
child: Column( child: Column(
children: [ children: [
Padding( AppCollapsibleFilterPanel(
expanded: widget.filtersExpanded,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: _UsersFilterBar( child: _UsersFilterBar(
roleFilter: roleFilter, roleFilter: roleFilter,
@ -723,11 +804,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
roles: roles, roles: roles,
departments: departments, departments: departments,
statuses: statuses, statuses: statuses,
isExporting: usersState.isExporting, showExport: false,
showExport: canExport,
searchController: _searchController, searchController: _searchController,
onExport: _exportUsers, onExport: _exportUsers,
onSearch: ref.read(usersListProvider.notifier).setSearch, isExporting: usersState.isExporting,
onSearch: (value) =>
ref.read(usersListProvider.notifier).setSearch(value),
onRoleChanged: (value) { onRoleChanged: (value) {
ref.read(usersListProvider.notifier).setRoleFilter( ref.read(usersListProvider.notifier).setRoleFilter(
value == 'All Roles' value == 'All Roles'
@ -751,7 +833,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
}, },
), ),
), ),
const Divider(height: 1),
if (usersState.users.isEmpty) if (usersState.users.isEmpty)
Expanded( Expanded(
child: Center( 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_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.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/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
@ -39,6 +40,7 @@ class DepreciationReportScreen extends ConsumerStatefulWidget {
class _DepreciationReportScreenState class _DepreciationReportScreenState
extends ConsumerState<DepreciationReportScreen> { extends ConsumerState<DepreciationReportScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override @override
void dispose() { void dispose() {
@ -149,7 +151,14 @@ class _DepreciationReportScreenState
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
), ),
actions: [ actions: [
if (canExport) AppSearchFilterButton(
expanded: _filtersExpanded,
onPressed: () => setState(
() => _filtersExpanded = !_filtersExpanded,
),
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: state.isExporting ? null : _export, onPressed: state.isExporting ? null : _export,
icon: state.isExporting icon: state.isExporting
@ -163,12 +172,14 @@ class _DepreciationReportScreenState
state.isExporting ? 'Exporting...' : 'Export', state.isExporting ? 'Exporting...' : 'Export',
), ),
), ),
],
], ],
), ),
_SummaryStrip(summary: state.summary, asOfDate: state.asOfDate), _SummaryStrip(summary: state.summary, asOfDate: state.asOfDate),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar( toolbar: _FiltersBar(
searchController: _searchController, searchController: _searchController,
filters: state.filters, filters: state.filters,
@ -500,25 +511,37 @@ class _FiltersBarState extends State<_FiltersBar> {
onClear: purchaseEmpty ? null : widget.onClearPurchaseRange, 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), onPressed: () => setState(() => _moreOpen = !_moreOpen),
icon: Icon( icon: Badge(
_moreOpen ? Icons.expand_less : Icons.tune_outlined, isLabelVisible: moreCount > 0,
size: 18, label: Text('$moreCount'),
), child: Icon(
label: Text( _moreOpen ? Icons.expand_less : Icons.tune_outlined,
moreCount > 0 size: 20,
? (_moreOpen ? 'Less filters ($moreCount)' : 'More filters ($moreCount)') color: iconColor,
: (_moreOpen ? 'Less filters' : 'More filters'), ),
), ),
); );
final reset = TextButton( final reset = IconButton(
tooltip: 'Reset',
color: iconColor,
disabledColor: iconColor.withValues(alpha: 0.38),
onPressed: query.hasActiveFilter ? widget.onReset : null, onPressed: query.hasActiveFilter ? widget.onReset : null,
child: const Text('Reset'), icon: const Icon(Icons.restart_alt, size: 20),
); );
final actions = Row( final actions = Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
moreButton, moreButton,
reset, reset,
@ -550,10 +573,7 @@ class _FiltersBarState extends State<_FiltersBar> {
return AppResponsiveFilterGrid( return AppResponsiveFilterGrid(
fields: [searchField, location, category, asOfField], fields: [searchField, location, category, asOfField],
footer: Align( trailing: actions,
alignment: Alignment.centerRight,
child: actions,
),
extra: moreFilters, 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_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.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_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -26,6 +27,7 @@ class RoleListScreen extends ConsumerStatefulWidget {
class _RoleListScreenState extends ConsumerState<RoleListScreen> { class _RoleListScreenState extends ConsumerState<RoleListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override @override
void dispose() { void dispose() {
@ -51,12 +53,21 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const PageHeader( PageHeader(
title: 'Roles', title: 'Roles',
subtitle: 'Manage roles and permission assignments', subtitle: 'Manage roles and permission assignments',
actions: [
AppSearchFilterButton(
expanded: _filtersExpanded,
onPressed: () => setState(
() => _filtersExpanded = !_filtersExpanded,
),
),
],
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: SizedBox( toolbar: SizedBox(
width: context.isMobile ? double.infinity : 320, width: context.isMobile ? double.infinity : 320,
child: AppSearchField( 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_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.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_searchable_dropdown.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -34,6 +35,7 @@ class UserListScreen extends ConsumerStatefulWidget {
class _UserListScreenState extends ConsumerState<UserListScreen> { class _UserListScreenState extends ConsumerState<UserListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override @override
void dispose() { void dispose() {
@ -60,6 +62,13 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
title: 'Users', title: 'Users',
subtitle: 'Manage employee accounts', subtitle: 'Manage employee accounts',
actions: [ actions: [
AppSearchFilterButton(
expanded: _filtersExpanded,
onPressed: () => setState(
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => context.push(RouteConstants.userAdd), onPressed: () => context.push(RouteConstants.userAdd),
icon: const Icon(Icons.person_add), icon: const Icon(Icons.person_add),
@ -73,6 +82,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
], ],
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar( toolbar: _FiltersBar(
searchController: _searchController, searchController: _searchController,
filters: state.filters, 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_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.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_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
@ -38,6 +39,7 @@ class VendorListScreen extends ConsumerStatefulWidget {
class _VendorListScreenState extends ConsumerState<VendorListScreen> { class _VendorListScreenState extends ConsumerState<VendorListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false;
@override @override
void dispose() { void dispose() {
@ -78,6 +80,27 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
title: 'Vendors', title: 'Vendors',
subtitle: 'Manage suppliers, service providers and vendor master data', subtitle: 'Manage suppliers, service providers and vendor master data',
actions: [ 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( CanPermission(
module: 'vendors', module: 'vendors',
action: PermissionAction.create, action: PermissionAction.create,
@ -91,12 +114,10 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
), ),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbarExpanded: _filtersExpanded,
toolbar: _FiltersBar( toolbar: _FiltersBar(
searchController: _searchController, searchController: _searchController,
query: state.query, query: state.query,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportVendors,
onSearch: ref.read(vendorsListProvider.notifier).setSearch, onSearch: ref.read(vendorsListProvider.notifier).setSearch,
onStatusChanged: onStatusChanged:
ref.read(vendorsListProvider.notifier).setStatusFilter, ref.read(vendorsListProvider.notifier).setStatusFilter,
@ -208,9 +229,6 @@ class _FiltersBar extends StatelessWidget {
required this.onSearch, required this.onSearch,
required this.onStatusChanged, required this.onStatusChanged,
required this.onVendorTypeChanged, required this.onVendorTypeChanged,
this.showExport = false,
this.isExporting = false,
this.onExport,
}); });
final TextEditingController searchController; final TextEditingController searchController;
@ -218,9 +236,6 @@ class _FiltersBar extends StatelessWidget {
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged; final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onVendorTypeChanged; final ValueChanged<String?> onVendorTypeChanged;
final bool showExport;
final bool isExporting;
final VoidCallback? onExport;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -262,19 +277,6 @@ class _FiltersBar extends StatelessWidget {
return AppResponsiveFilterBar( return AppResponsiveFilterBar(
search: searchField, search: searchField,
filters: filters, 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.runSpacing = 12,
this.minFieldWidth = 160, this.minFieldWidth = 160,
this.maxColumns = 4, this.maxColumns = 4,
this.trailing,
this.footer, this.footer,
this.extra, this.extra,
}); });
@ -158,6 +159,8 @@ class AppResponsiveFilterGrid extends StatelessWidget {
final double runSpacing; final double runSpacing;
final double minFieldWidth; final double minFieldWidth;
final int maxColumns; final int maxColumns;
/// Placed at the end of the primary filter row (wide layouts).
final Widget? trailing;
final Widget? footer; final Widget? footer;
final Widget? extra; final Widget? extra;
@ -169,6 +172,7 @@ class AppResponsiveFilterGrid extends StatelessWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxWidth = constraints.maxWidth; final maxWidth = constraints.maxWidth;
final hasTrailing = trailing != null;
Widget content; Widget content;
if (maxWidth >= _rowBreakpoint && fields.length <= maxColumns) { if (maxWidth >= _rowBreakpoint && fields.length <= maxColumns) {
@ -179,6 +183,13 @@ class AppResponsiveFilterGrid extends StatelessWidget {
if (i > 0) SizedBox(width: spacing), if (i > 0) SizedBox(width: spacing),
Expanded(child: fields[i]), Expanded(child: fields[i]),
], ],
if (hasTrailing) ...[
SizedBox(width: spacing),
Padding(
padding: const EdgeInsets.only(top: 8),
child: trailing!,
),
],
], ],
); );
} else if (maxWidth >= _stackBreakpoint) { } else if (maxWidth >= _stackBreakpoint) {
@ -191,9 +202,15 @@ class AppResponsiveFilterGrid extends StatelessWidget {
content = Wrap( content = Wrap(
spacing: spacing, spacing: spacing,
runSpacing: runSpacing, runSpacing: runSpacing,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final field in fields) for (final field in fields)
SizedBox(width: itemWidth, child: field), SizedBox(width: itemWidth, child: field),
if (hasTrailing)
Padding(
padding: const EdgeInsets.only(top: 8),
child: trailing!,
),
], ],
); );
} else { } else {
@ -204,6 +221,10 @@ class AppResponsiveFilterGrid extends StatelessWidget {
if (i > 0) SizedBox(height: runSpacing), if (i > 0) SizedBox(height: runSpacing),
fields[i], 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 'package:flutter/material.dart';
import 'app_card.dart'; import 'app_card.dart';
import 'app_search_filter_toggle.dart';
/// Card shell for list pages: toolbar, divider, full-width table body, footer. /// Card shell for list pages: toolbar, divider, full-width table body, footer.
class AppTableShell extends StatelessWidget { class AppTableShell extends StatelessWidget {
@ -9,12 +10,16 @@ class AppTableShell extends StatelessWidget {
required this.toolbar, required this.toolbar,
required this.child, required this.child,
this.footer, this.footer,
this.toolbarExpanded = false,
}); });
final Widget toolbar; final Widget toolbar;
final Widget child; final Widget child;
final Widget? footer; final Widget? footer;
/// When false (default), the search/filter toolbar is collapsed.
final bool toolbarExpanded;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -32,11 +37,10 @@ class AppTableShell extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( AppCollapsibleFilterPanel(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), expanded: toolbarExpanded,
child: toolbar, child: toolbar,
), ),
const Divider(height: 1),
// Clip so scrolling rows cannot paint over the footer/pagination. // Clip so scrolling rows cannot paint over the footer/pagination.
Expanded(child: ClipRect(child: child)), Expanded(child: ClipRect(child: child)),
if (footer != null) ...[ if (footer != null) ...[