filter changes
This commit is contained in:
parent
6721b308a9
commit
4360931be4
@ -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(
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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!),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
110
lib/shared/widgets/app_search_filter_toggle.dart
Normal file
110
lib/shared/widgets/app_search_filter_toggle.dart
Normal 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')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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) ...[
|
||||
|
||||
Loading…
Reference in New Issue
Block a user