diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index e9b3998..b3228a9 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -104,13 +104,21 @@ class MasterListNotifier extends FamilyAsyncNotifier { return _load(); } - Future _load({int? page, String? search}) async { + Future _load({ + int? page, + int? limit, + String? search, + }) async { final current = state.valueOrNull; + final nextPage = page ?? current?.page ?? 1; + final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize; + final nextSearch = search ?? current?.search; + final result = await ref.read(masterRepositoryProvider).list( _definition, - page: page ?? current?.page ?? 1, - limit: current?.limit ?? 20, - search: search ?? current?.search, + page: nextPage, + limit: nextLimit, + search: nextSearch, ); if (result.failure != null) throw result.failure!; @@ -118,20 +126,41 @@ class MasterListNotifier extends FamilyAsyncNotifier { return MasterListState( items: data.items, - search: search ?? current?.search ?? '', + search: nextSearch ?? '', page: data.page, - limit: data.limit, + // Keep the requested page size so the /page dropdown stays valid + // even if API meta omits or mismatches `limit`. + limit: nextLimit, total: data.total, - totalPages: data.totalPages, + totalPages: _resolveTotalPages( + apiTotalPages: data.totalPages, + total: data.total, + limit: nextLimit, + ), ); } + int _resolveTotalPages({ + required int apiTotalPages, + required int total, + required int limit, + }) { + if (apiTotalPages > 0) return apiTotalPages; + if (total <= 0 || limit <= 0) return 1; + final pages = (total / limit).ceil(); + return pages < 1 ? 1 : pages; + } + Future refresh() async { final previous = state.valueOrNull; if (previous == null) { state = const AsyncLoading(); } - state = AsyncData(await _load()); + try { + state = AsyncData(await _load()); + } catch (e, st) { + state = AsyncError(e, st); + } } Future setSearch(String search) async { @@ -142,33 +171,22 @@ class MasterListNotifier extends FamilyAsyncNotifier { await _reload(page: page); } - Future _reload({int? page, String? search}) async { + Future setPageSize(int limit) async { + await _reload(page: 1, limit: limit); + } + + Future _reload({int? page, int? limit, String? search}) async { final previous = state.valueOrNull; if (previous == null) { state = const AsyncLoading(); } - state = AsyncData(await _load(page: page, search: search)); - } - - Future setPageSize(int limit) async { - final current = state.valueOrNull ?? const MasterListState(); - final result = await ref.read(masterRepositoryProvider).list( - _definition, - page: 1, - limit: limit, - search: current.search, - ); - if (result.failure != null) throw result.failure!; - final data = result.data!; - state = AsyncData( - current.copyWith( - items: data.items, - page: data.page, - limit: data.limit, - total: data.total, - totalPages: data.totalPages, - ), - ); + try { + state = AsyncData( + await _load(page: page, limit: limit, search: search), + ); + } catch (e, st) { + state = AsyncError(e, st); + } } Future deleteRecord(String id) async { diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index c5a8eb2..792918f 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -7,13 +7,14 @@ import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/providers/permissions_provider.dart'; -import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_search_export_bar.dart'; import '../../../../shared/widgets/app_status_chip.dart'; +import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/page_header.dart'; @@ -136,7 +137,6 @@ class _MasterListScreenState extends ConsumerState { Widget build(BuildContext context) { final listAsync = ref.watch(masterListProvider(widget.masterId)); final def = _definition; - final theme = Theme.of(context); return Padding( padding: const EdgeInsets.all(24), @@ -147,11 +147,6 @@ class _MasterListScreenState extends ConsumerState { onRetry: () => ref.invalidate(masterListProvider(widget.masterId)), ), data: (state) { - final page = state.page; - final pageSize = state.limit; - final total = state.total; - final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1; - final end = (page * pageSize).clamp(0, total); final notifier = ref.read(masterListProvider(widget.masterId).notifier); final canCreate = ref.can('masters', PermissionAction.create); final canExport = ref.can('masters', PermissionAction.export); @@ -181,123 +176,55 @@ class _MasterListScreenState extends ConsumerState { ), const SizedBox(height: 16), Expanded( - child: AppCard( - enableHover: false, - clipBehavior: Clip.none, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.12), - ), + child: AppTableShell( + toolbar: LayoutBuilder( + builder: (context, constraints) { + return AppSearchExportBar( + wrapped: constraints.maxWidth < 640, + searchController: _searchController, + searchHint: _searchHint(def), + isExporting: state.isExporting, + showExport: canExport, + onSearch: notifier.setSearch, + onExport: _exportRecords, + ); + }, ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: LayoutBuilder( - builder: (context, constraints) { - return AppSearchExportBar( - wrapped: constraints.maxWidth < 640, - searchController: _searchController, - searchHint: _searchHint(def), - isExporting: state.isExporting, - showExport: canExport, - onSearch: notifier.setSearch, - onExport: _exportRecords, - ); - }, - ), - ), - const Divider(height: 1), - Expanded( - child: RefreshIndicator( - onRefresh: notifier.refresh, - child: state.items.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: 240, - child: AppEmptyState( - title: 'No ${def.title.toLowerCase()} found', - description: - 'Add your first ${def.title.toLowerCase()} record to get started.', - icon: def.icon, - ), - ), - ], - ) - : _MasterListTable( - definition: def, - items: state.items, - isDeleting: state.isDeleting, - canEdit: canEdit, - canDelete: canDelete, - onEdit: (id) => _openFormPanel(recordId: id), - onDelete: _deleteRecord, + footer: AppPagination( + currentPage: state.page, + totalPages: state.totalPages, + totalItems: state.total, + pageSize: state.limit, + itemLabel: def.title.toLowerCase(), + onPageChanged: notifier.setPage, + onPageSizeChanged: notifier.setPageSize, + ), + child: RefreshIndicator( + onRefresh: notifier.refresh, + child: state.items.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: 240, + child: AppEmptyState( + title: 'No ${def.title.toLowerCase()} found', + description: + 'Add your first ${def.title.toLowerCase()} record to get started.', + icon: def.icon, ), - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - 'Showing $start–$end of $total ${def.title.toLowerCase()}', - style: theme.textTheme.bodySmall, - ), - const Spacer(), - TextButton( - onPressed: page > 1 - ? () => notifier.setPage(page - 1) - : null, - child: const Text('Previous'), - ), - ...List.generate(state.totalPages.clamp(0, 4), (i) { - final pageIndex = i + 1; - final selected = page == pageIndex; - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 2), - child: Material( - color: selected - ? theme.colorScheme.primary - : Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => notifier.setPage(pageIndex), - child: SizedBox( - width: 36, - height: 36, - child: Center( - child: Text( - '$pageIndex', - style: TextStyle( - color: selected - ? theme.colorScheme.onPrimary - : null, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - ); - }), - TextButton( - onPressed: page < state.totalPages - ? () => notifier.setPage(page + 1) - : null, - child: const Text('Next'), - ), - ], - ), - ), - ], + ), + ], + ) + : _MasterListTable( + definition: def, + items: state.items, + isDeleting: state.isDeleting, + canEdit: canEdit, + canDelete: canDelete, + onEdit: (id) => _openFormPanel(recordId: id), + onDelete: _deleteRecord, + ), ), ), ), diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index e2a3099..94a92c1 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -209,13 +209,15 @@ class _PurchaseOrderListScreenState extends ConsumerState> _statusFilterOptions( List orders, ) { - final options = _knownStatuses.entries - .map((entry) => AppDropdownOption( - value: entry.key, - label: entry.value, - )) - .toList() - ..sort((a, b) => a.label.compareTo(b.label)); + final options = List>.from( + _knownStatuses.entries.map( + (entry) => AppDropdownOption( + value: entry.key, + label: entry.value, + ), + ), + ); + options.sort((a, b) => a.label.compareTo(b.label)); return [ const AppDropdownOption(value: null, label: 'All statuses'), @@ -254,42 +256,33 @@ class _FiltersBar extends StatelessWidget { @override Widget build(BuildContext context) { - final searchField = SizedBox( - width: wrapped ? double.infinity : null, - child: AppSearchField( - controller: searchController, - hint: 'Search PO number, vendor...', - onChanged: onSearch, - ), + final searchField = AppSearchField( + controller: searchController, + hint: 'Search PO number, vendor...', + onChanged: onSearch, ); final filters = [ - SizedBox( - width: wrapped ? double.infinity : 180, - child: AppSearchableDropdown( - label: 'Status', - value: query.status, - searchHint: 'Search status...', - isDense: true, - options: statusOptions, - onChanged: onStatusChanged, - ), + AppSearchableDropdown( + label: 'Status', + value: query.status, + searchHint: 'Search status...', + isDense: true, + options: statusOptions, + onChanged: onStatusChanged, ), - SizedBox( - width: wrapped ? double.infinity : 200, - child: AppSearchableDropdown( - label: 'PO Type', - value: query.poType, - searchHint: 'Search type...', - isDense: true, - options: [ - const AppDropdownOption(value: null, label: 'All types'), - ...poTypeOptions.map( - (e) => AppDropdownOption(value: e.$1, label: e.$2), - ), - ], - onChanged: onPoTypeChanged, - ), + AppSearchableDropdown( + label: 'PO Type', + value: query.poType, + searchHint: 'Search type...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All types'), + ...poTypeOptions.map( + (e) => AppDropdownOption(value: e.$1, label: e.$2), + ), + ], + onChanged: onPoTypeChanged, ), ]; @@ -299,12 +292,15 @@ class _FiltersBar extends StatelessWidget { children: [ searchField, const SizedBox(height: 12), - ...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()..removeLast(), + filters[0], + const SizedBox(height: 12), + filters[1], ], ); } return Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(flex: 3, child: searchField), const SizedBox(width: 12), diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index d0dc7f3..d8d0be1 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -322,7 +322,7 @@ class _SummaryStrip extends StatelessWidget { } } -class _FiltersBar extends StatelessWidget { +class _FiltersBar extends StatefulWidget { const _FiltersBar({ required this.searchController, required this.filters, @@ -361,8 +361,39 @@ class _FiltersBar extends StatelessWidget { final VoidCallback onClearPurchaseRange; final VoidCallback onReset; + @override + State<_FiltersBar> createState() => _FiltersBarState(); +} + +class _FiltersBarState extends State<_FiltersBar> { + late bool _moreOpen; + + @override + void initState() { + super.initState(); + _moreOpen = _moreFilterCount(widget.query) > 0; + } + + int _moreFilterCount(DepreciationReportQuery query) { + var count = 0; + if (query.itemSubcategoryId?.isNotEmpty ?? false) count++; + if (query.departmentId?.isNotEmpty ?? false) count++; + if (query.status?.isNotEmpty ?? false) count++; + if (query.depreciationMethod?.isNotEmpty ?? false) count++; + if (query.isActive != null) count++; + if (query.purchaseDateFrom != null || query.purchaseDateTo != null) { + count++; + } + return count; + } + @override Widget build(BuildContext context) { + final query = widget.query; + final filters = widget.filters; + final theme = Theme.of(context); + final moreCount = _moreFilterCount(query); + final subcategoryOptions = query.itemCategoryId == null ? filters.subcategories : filters.subcategories @@ -370,19 +401,19 @@ class _FiltersBar extends StatelessWidget { .toList(); final searchField = TextField( - controller: searchController, - onChanged: onSearch, + controller: widget.searchController, + onChanged: widget.onSearch, decoration: InputDecoration( labelText: 'Search', hintText: 'Search asset code or name...', prefixIcon: const Icon(Icons.search), isDense: true, - suffixIcon: searchController.text.isNotEmpty + suffixIcon: widget.searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { - searchController.clear(); - onSearch(''); + widget.searchController.clear(); + widget.onSearch(''); }, ) : null, @@ -417,37 +448,37 @@ class _FiltersBar extends StatelessWidget { label: 'Plant', value: query.plantId, options: filters.plants, - onChanged: onPlantChanged, + onChanged: widget.onPlantChanged, ); final category = dropdown( label: 'Category', value: query.itemCategoryId, options: filters.categories, - onChanged: onCategoryChanged, + onChanged: widget.onCategoryChanged, ); final subcategory = dropdown( label: 'Subcategory', value: query.itemSubcategoryId, options: subcategoryOptions, - onChanged: onSubcategoryChanged, + onChanged: widget.onSubcategoryChanged, ); final department = dropdown( label: 'Department', value: query.departmentId, options: filters.departments, - onChanged: onDepartmentChanged, + onChanged: widget.onDepartmentChanged, ); final status = dropdown( label: 'Status', value: query.status, options: filters.statuses, - onChanged: onStatusChanged, + onChanged: widget.onStatusChanged, ); final method = dropdown( label: 'Method', value: query.depreciationMethod, options: filters.depreciationMethods, - onChanged: onMethodChanged, + onChanged: widget.onMethodChanged, ); final active = AppSearchableDropdown( @@ -460,78 +491,52 @@ class _FiltersBar extends StatelessWidget { AppDropdownOption(value: true, label: 'Active'), AppDropdownOption(value: false, label: 'Inactive'), ], - onChanged: onIsActiveChanged, + onChanged: widget.onIsActiveChanged, ); final asOfEmpty = query.asOfDate == null; - final asOfValue = asOfEmpty - ? '' - : DateFormatter.displayDate(query.asOfDate); - - final purchaseEmpty = - query.purchaseDateFrom == null && query.purchaseDateTo == null; - final purchaseValue = purchaseEmpty - ? '' - : '${DateFormatter.displayDate(query.purchaseDateFrom)} – ${DateFormatter.displayDate(query.purchaseDateTo)}'; - final asOfField = AppFilterDateField( label: 'As of date', - value: asOfValue, + value: asOfEmpty ? '' : DateFormatter.displayDate(query.asOfDate), placeholder: 'Select date', icon: Icons.calendar_today_outlined, isEmpty: asOfEmpty, - onTap: onPickAsOfDate, - onClear: asOfEmpty ? null : onClearAsOfDate, + onTap: widget.onPickAsOfDate, + onClear: asOfEmpty ? null : widget.onClearAsOfDate, ); + final purchaseEmpty = + query.purchaseDateFrom == null && query.purchaseDateTo == null; final purchaseField = AppFilterDateField( label: 'Purchase dates', - value: purchaseValue, + value: purchaseEmpty + ? '' + : '${DateFormatter.displayDate(query.purchaseDateFrom)} – ${DateFormatter.displayDate(query.purchaseDateTo)}', placeholder: 'Select range', icon: Icons.date_range_outlined, isEmpty: purchaseEmpty, - onTap: onPickPurchaseRange, - onClear: purchaseEmpty ? null : onClearPurchaseRange, + onTap: widget.onPickPurchaseRange, + onClear: purchaseEmpty ? null : widget.onClearPurchaseRange, + ); + + final moreButton = TextButton.icon( + 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'), + ), ); final reset = TextButton( - onPressed: query.hasActiveFilter ? onReset : null, + onPressed: query.hasActiveFilter ? widget.onReset : null, child: const Text('Reset'), ); - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - plant, - const SizedBox(height: 12), - category, - const SizedBox(height: 12), - subcategory, - const SizedBox(height: 12), - department, - const SizedBox(height: 12), - status, - const SizedBox(height: 12), - method, - const SizedBox(height: 12), - active, - const SizedBox(height: 12), - asOfField, - const SizedBox(height: 8), - Row( - children: [ - Expanded(child: purchaseField), - reset, - ], - ), - ], - ); - } - - // Consistent 4-column grid so every row lines up vertically. Widget row(List cells) { assert(cells.length == 4); return Row( @@ -545,18 +550,64 @@ class _FiltersBar extends StatelessWidget { ); } + final actions = Row( + children: [ + moreButton, + reset, + ], + ); + + if (widget.wrapped) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + searchField, + const SizedBox(height: 12), + plant, + const SizedBox(height: 12), + category, + const SizedBox(height: 12), + asOfField, + const SizedBox(height: 4), + actions, + if (_moreOpen) ...[ + const SizedBox(height: 8), + Divider(color: theme.colorScheme.outline.withValues(alpha: 0.2)), + const SizedBox(height: 8), + subcategory, + const SizedBox(height: 12), + department, + const SizedBox(height: 12), + status, + const SizedBox(height: 12), + method, + const SizedBox(height: 12), + active, + const SizedBox(height: 12), + purchaseField, + ], + ], + ); + } + return Column( children: [ - row([searchField, plant, category, subcategory]), - const SizedBox(height: 12), - row([department, status, method, active]), - const SizedBox(height: 12), - row([ - asOfField, - purchaseField, - const SizedBox.shrink(), - Align(alignment: Alignment.centerRight, child: reset), - ]), + row([searchField, plant, category, asOfField]), + const SizedBox(height: 4), + Align(alignment: Alignment.centerRight, child: actions), + if (_moreOpen) ...[ + const SizedBox(height: 8), + Divider(height: 1, color: theme.colorScheme.outline.withValues(alpha: 0.2)), + const SizedBox(height: 12), + row([subcategory, department, status, method]), + const SizedBox(height: 12), + row([ + active, + purchaseField, + const SizedBox.shrink(), + const SizedBox.shrink(), + ]), + ], ], ); } diff --git a/lib/modules/roles/presentation/providers/roles_provider.dart b/lib/modules/roles/presentation/providers/roles_provider.dart index 68bdb33..a21bce5 100644 --- a/lib/modules/roles/presentation/providers/roles_provider.dart +++ b/lib/modules/roles/presentation/providers/roles_provider.dart @@ -9,10 +9,14 @@ class RolesListState { const RolesListState({ this.roles = const [], this.search = '', + this.page = 1, + this.limit = 10, }); final List roles; final String search; + final int page; + final int limit; List get filteredRoles { if (search.isEmpty) return roles; @@ -26,13 +30,32 @@ class RolesListState { .toList(); } + int get total => filteredRoles.length; + + int get totalPages { + if (total == 0) return 1; + return (total / limit).ceil(); + } + + List get pagedRoles { + final all = filteredRoles; + if (all.isEmpty) return const []; + final start = ((page - 1) * limit).clamp(0, all.length); + final end = (start + limit).clamp(0, all.length); + return all.sublist(start, end); + } + RolesListState copyWith({ List? roles, String? search, + int? page, + int? limit, }) { return RolesListState( roles: roles ?? this.roles, search: search ?? this.search, + page: page ?? this.page, + limit: limit ?? this.limit, ); } } @@ -102,7 +125,19 @@ class RolesListNotifier extends AsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - state = AsyncData(current.copyWith(search: search)); + state = AsyncData(current.copyWith(search: search, page: 1)); + } + + void setPage(int page) { + final current = state.valueOrNull; + if (current == null) return; + state = AsyncData(current.copyWith(page: page)); + } + + void setPageSize(int limit) { + final current = state.valueOrNull; + if (current == null) return; + state = AsyncData(current.copyWith(limit: limit, page: 1)); } } diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 7850d4a..66ca9c8 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -9,6 +9,7 @@ import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; @@ -44,7 +45,8 @@ class _RoleListScreenState extends ConsumerState { onRetry: () => ref.invalidate(rolesListProvider), ), data: (state) { - final roles = state.filteredRoles; + final roles = state.pagedRoles; + final notifier = ref.read(rolesListProvider.notifier); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -60,11 +62,20 @@ class _RoleListScreenState extends ConsumerState { child: AppSearchField( controller: _searchController, hint: 'Search roles...', - onChanged: ref.read(rolesListProvider.notifier).setSearch, + onChanged: notifier.setSearch, ), ), + footer: AppPagination( + currentPage: state.page, + totalPages: state.totalPages, + totalItems: state.total, + pageSize: state.limit, + itemLabel: 'roles', + onPageChanged: notifier.setPage, + onPageSizeChanged: notifier.setPageSize, + ), child: RefreshIndicator( - onRefresh: () => ref.read(rolesListProvider.notifier).refresh(), + onRefresh: notifier.refresh, child: roles.isEmpty ? ListView( physics: const AlwaysScrollableScrollPhysics(), diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index c58b8e5..9339100 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -284,6 +284,7 @@ class _FiltersBar extends StatelessWidget { } return Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(flex: 3, child: searchField), const SizedBox(width: 12), diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 4964810..f4d863f 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -191,47 +191,38 @@ class _FiltersBar extends StatelessWidget { @override Widget build(BuildContext context) { - final searchField = SizedBox( - width: wrapped ? double.infinity : null, - child: AppSearchField( - controller: searchController, - hint: 'Search vendors...', - onChanged: onSearch, - ), + final searchField = AppSearchField( + controller: searchController, + hint: 'Search vendors...', + onChanged: onSearch, ); final filters = [ - SizedBox( - width: wrapped ? double.infinity : 180, - child: AppSearchableDropdown( - label: 'Status', - value: query.status, - searchHint: 'Search status...', - isDense: true, - options: [ - const AppDropdownOption(value: null, label: 'All statuses'), - ...vendorStatusOptions.map( - (e) => AppDropdownOption(value: e.$1, label: e.$2), - ), - ], - onChanged: onStatusChanged, - ), + AppSearchableDropdown( + label: 'Status', + value: query.status, + searchHint: 'Search status...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All statuses'), + ...vendorStatusOptions.map( + (e) => AppDropdownOption(value: e.$1, label: e.$2), + ), + ], + onChanged: onStatusChanged, ), - SizedBox( - width: wrapped ? double.infinity : 200, - child: AppSearchableDropdown( - label: 'Vendor Type', - value: query.vendorType, - searchHint: 'Search vendor type...', - isDense: true, - options: [ - const AppDropdownOption(value: null, label: 'All types'), - ...vendorTypeOptions.map( - (e) => AppDropdownOption(value: e.$1, label: e.$2), - ), - ], - onChanged: onVendorTypeChanged, - ), + AppSearchableDropdown( + label: 'Vendor Type', + value: query.vendorType, + searchHint: 'Search vendor type...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All types'), + ...vendorTypeOptions.map( + (e) => AppDropdownOption(value: e.$1, label: e.$2), + ), + ], + onChanged: onVendorTypeChanged, ), ]; @@ -241,13 +232,15 @@ class _FiltersBar extends StatelessWidget { children: [ searchField, const SizedBox(height: 12), - ...filters.expand((filter) => [filter, const SizedBox(height: 12)]).toList() - ..removeLast(), + filters[0], + const SizedBox(height: 12), + filters[1], ], ); } return Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(flex: 3, child: searchField), const SizedBox(width: 12), diff --git a/lib/shared/widgets/app_pagination.dart b/lib/shared/widgets/app_pagination.dart index dd2c399..77f0642 100644 --- a/lib/shared/widgets/app_pagination.dart +++ b/lib/shared/widgets/app_pagination.dart @@ -32,6 +32,11 @@ class AppPagination extends StatelessWidget { final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1; final end = (currentPage * pageSize).clamp(0, totalItems); final visiblePages = _visiblePageNumbers(currentPage, totalPages); + final sizes = List.from(pageSizeOptions); + if (!sizes.contains(pageSize)) { + sizes.add(pageSize); + } + sizes.sort(); return Padding( padding: padding, @@ -50,7 +55,7 @@ class AppPagination extends StatelessWidget { isDense: true, underline: const SizedBox.shrink(), style: theme.textTheme.bodySmall, - items: pageSizeOptions + items: sizes .map( (size) => DropdownMenuItem( value: size, diff --git a/lib/shared/widgets/app_search_field.dart b/lib/shared/widgets/app_search_field.dart index 26674bb..b058cdb 100644 --- a/lib/shared/widgets/app_search_field.dart +++ b/lib/shared/widgets/app_search_field.dart @@ -1,33 +1,43 @@ import 'package:flutter/material.dart'; +/// Dense outlined search field aligned with labeled filter dropdowns. class AppSearchField extends StatelessWidget { const AppSearchField({ super.key, required this.controller, + this.label = 'Search', this.hint = 'Search...', this.onChanged, this.onSubmitted, this.onClear, + this.alignWithLabeledFields = true, }); final TextEditingController controller; + final String label; final String hint; final ValueChanged? onChanged; final ValueChanged? onSubmitted; final VoidCallback? onClear; + /// Adds top padding to line up with [AppSearchableDropdown] (label float space). + final bool alignWithLabeledFields; + @override Widget build(BuildContext context) { - return TextField( + final field = TextField( controller: controller, onChanged: onChanged, onSubmitted: onSubmitted, decoration: InputDecoration( + labelText: label, hintText: hint, - prefixIcon: const Icon(Icons.search), + prefixIcon: const Icon(Icons.search, size: 20), + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, suffixIcon: controller.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear), + icon: const Icon(Icons.clear, size: 18), onPressed: () { controller.clear(); onClear?.call(); @@ -35,8 +45,13 @@ class AppSearchField extends StatelessWidget { }, ) : null, - isDense: true, ), ); + + if (!alignWithLabeledFields) return field; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: field, + ); } }