pagenation bug

This commit is contained in:
Surendiran 2026-07-13 16:02:43 +05:30
parent 4b4a51e553
commit 7a6bde09dd
10 changed files with 366 additions and 314 deletions

View File

@ -104,13 +104,21 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
return _load();
}
Future<MasterListState> _load({int? page, String? search}) async {
Future<MasterListState> _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<MasterListState, String> {
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<void> 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<void> setSearch(String search) async {
@ -142,33 +171,22 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
await _reload(page: page);
}
Future<void> _reload({int? page, String? search}) async {
Future<void> setPageSize(int limit) async {
await _reload(page: 1, limit: limit);
}
Future<void> _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<void> 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<bool> deleteRecord(String id) async {

View File

@ -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<MasterListScreen> {
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<MasterListScreen> {
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<MasterListScreen> {
),
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,
),
),
),
),

View File

@ -209,13 +209,15 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
List<AppDropdownOption<String?>> _statusFilterOptions(
List<PurchaseOrderModel> orders,
) {
final options = _knownStatuses.entries
.map((entry) => AppDropdownOption<String?>(
value: entry.key,
label: entry.value,
))
.toList()
..sort((a, b) => a.label.compareTo(b.label));
final options = List<AppDropdownOption<String?>>.from(
_knownStatuses.entries.map(
(entry) => AppDropdownOption<String?>(
value: entry.key,
label: entry.value,
),
),
);
options.sort((a, b) => a.label.compareTo(b.label));
return [
const AppDropdownOption<String?>(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<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: statusOptions,
onChanged: onStatusChanged,
),
AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: statusOptions,
onChanged: onStatusChanged,
),
SizedBox(
width: wrapped ? double.infinity : 200,
child: AppSearchableDropdown<String?>(
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<String?>(
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),

View File

@ -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<bool?>(
@ -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<Widget> 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(),
]),
],
],
);
}

View File

@ -9,10 +9,14 @@ class RolesListState {
const RolesListState({
this.roles = const [],
this.search = '',
this.page = 1,
this.limit = 10,
});
final List<RoleCardModel> roles;
final String search;
final int page;
final int limit;
List<RoleCardModel> 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<RoleCardModel> 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<RoleCardModel>? 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<RolesListState> {
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));
}
}

View File

@ -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<RoleListScreen> {
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<RoleListScreen> {
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(),

View File

@ -284,6 +284,7 @@ class _FiltersBar extends StatelessWidget {
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),

View File

@ -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<String?>(
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<String?>(
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<String?>(
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<String?>(
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),

View File

@ -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<int>.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,

View File

@ -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<String>? onChanged;
final ValueChanged<String>? 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,
);
}
}