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(); 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 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( final result = await ref.read(masterRepositoryProvider).list(
_definition, _definition,
page: page ?? current?.page ?? 1, page: nextPage,
limit: current?.limit ?? 20, limit: nextLimit,
search: search ?? current?.search, search: nextSearch,
); );
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
@ -118,20 +126,41 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
return MasterListState( return MasterListState(
items: data.items, items: data.items,
search: search ?? current?.search ?? '', search: nextSearch ?? '',
page: data.page, 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, 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 { Future<void> refresh() async {
final previous = state.valueOrNull; final previous = state.valueOrNull;
if (previous == null) { if (previous == null) {
state = const AsyncLoading(); 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 { Future<void> setSearch(String search) async {
@ -142,33 +171,22 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
await _reload(page: page); 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; final previous = state.valueOrNull;
if (previous == null) { if (previous == null) {
state = const AsyncLoading(); state = const AsyncLoading();
} }
state = AsyncData(await _load(page: page, search: search)); try {
} state = AsyncData(
await _load(page: page, limit: limit, search: search),
Future<void> setPageSize(int limit) async { );
final current = state.valueOrNull ?? const MasterListState(); } catch (e, st) {
final result = await ref.read(masterRepositoryProvider).list( state = AsyncError(e, st);
_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,
),
);
} }
Future<bool> deleteRecord(String id) async { Future<bool> deleteRecord(String id) async {

View File

@ -7,13 +7,14 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_export_bar.dart'; import '../../../../shared/widgets/app_search_export_bar.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
@ -136,7 +137,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final listAsync = ref.watch(masterListProvider(widget.masterId)); final listAsync = ref.watch(masterListProvider(widget.masterId));
final def = _definition; final def = _definition;
final theme = Theme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@ -147,11 +147,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
onRetry: () => ref.invalidate(masterListProvider(widget.masterId)), onRetry: () => ref.invalidate(masterListProvider(widget.masterId)),
), ),
data: (state) { 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 notifier = ref.read(masterListProvider(widget.masterId).notifier);
final canCreate = ref.can('masters', PermissionAction.create); final canCreate = ref.can('masters', PermissionAction.create);
final canExport = ref.can('masters', PermissionAction.export); final canExport = ref.can('masters', PermissionAction.export);
@ -181,123 +176,55 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: AppCard( child: AppTableShell(
enableHover: false, toolbar: LayoutBuilder(
clipBehavior: Clip.none, builder: (context, constraints) {
elevation: 0, return AppSearchExportBar(
shape: RoundedRectangleBorder( wrapped: constraints.maxWidth < 640,
borderRadius: BorderRadius.circular(12), searchController: _searchController,
side: BorderSide( searchHint: _searchHint(def),
color: theme.colorScheme.outline.withValues(alpha: 0.12), isExporting: state.isExporting,
), showExport: canExport,
onSearch: notifier.setSearch,
onExport: _exportRecords,
);
},
), ),
child: Column( footer: AppPagination(
children: [ currentPage: state.page,
Padding( totalPages: state.totalPages,
padding: const EdgeInsets.all(16), totalItems: state.total,
child: LayoutBuilder( pageSize: state.limit,
builder: (context, constraints) { itemLabel: def.title.toLowerCase(),
return AppSearchExportBar( onPageChanged: notifier.setPage,
wrapped: constraints.maxWidth < 640, onPageSizeChanged: notifier.setPageSize,
searchController: _searchController, ),
searchHint: _searchHint(def), child: RefreshIndicator(
isExporting: state.isExporting, onRefresh: notifier.refresh,
showExport: canExport, child: state.items.isEmpty
onSearch: notifier.setSearch, ? ListView(
onExport: _exportRecords, physics: const AlwaysScrollableScrollPhysics(),
); children: [
}, SizedBox(
), height: 240,
), child: AppEmptyState(
const Divider(height: 1), title: 'No ${def.title.toLowerCase()} found',
Expanded( description:
child: RefreshIndicator( 'Add your first ${def.title.toLowerCase()} record to get started.',
onRefresh: notifier.refresh, icon: def.icon,
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,
), ),
), ),
), ],
const Divider(height: 1), )
Padding( : _MasterListTable(
padding: const EdgeInsets.all(16), definition: def,
child: Row( items: state.items,
children: [ isDeleting: state.isDeleting,
Text( canEdit: canEdit,
'Showing $start$end of $total ${def.title.toLowerCase()}', canDelete: canDelete,
style: theme.textTheme.bodySmall, onEdit: (id) => _openFormPanel(recordId: id),
), onDelete: _deleteRecord,
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'),
),
],
),
),
],
), ),
), ),
), ),

View File

@ -209,13 +209,15 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
List<AppDropdownOption<String?>> _statusFilterOptions( List<AppDropdownOption<String?>> _statusFilterOptions(
List<PurchaseOrderModel> orders, List<PurchaseOrderModel> orders,
) { ) {
final options = _knownStatuses.entries final options = List<AppDropdownOption<String?>>.from(
.map((entry) => AppDropdownOption<String?>( _knownStatuses.entries.map(
value: entry.key, (entry) => AppDropdownOption<String?>(
label: entry.value, value: entry.key,
)) label: entry.value,
.toList() ),
..sort((a, b) => a.label.compareTo(b.label)); ),
);
options.sort((a, b) => a.label.compareTo(b.label));
return [ return [
const AppDropdownOption<String?>(value: null, label: 'All statuses'), const AppDropdownOption<String?>(value: null, label: 'All statuses'),
@ -254,42 +256,33 @@ class _FiltersBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final searchField = SizedBox( final searchField = AppSearchField(
width: wrapped ? double.infinity : null, controller: searchController,
child: AppSearchField( hint: 'Search PO number, vendor...',
controller: searchController, onChanged: onSearch,
hint: 'Search PO number, vendor...',
onChanged: onSearch,
),
); );
final filters = [ final filters = [
SizedBox( AppSearchableDropdown<String?>(
width: wrapped ? double.infinity : 180, label: 'Status',
child: AppSearchableDropdown<String?>( value: query.status,
label: 'Status', searchHint: 'Search status...',
value: query.status, isDense: true,
searchHint: 'Search status...', options: statusOptions,
isDense: true, onChanged: onStatusChanged,
options: statusOptions,
onChanged: onStatusChanged,
),
), ),
SizedBox( AppSearchableDropdown<String?>(
width: wrapped ? double.infinity : 200, label: 'PO Type',
child: AppSearchableDropdown<String?>( value: query.poType,
label: 'PO Type', searchHint: 'Search type...',
value: query.poType, isDense: true,
searchHint: 'Search type...', options: [
isDense: true, const AppDropdownOption(value: null, label: 'All types'),
options: [ ...poTypeOptions.map(
const AppDropdownOption(value: null, label: 'All types'), (e) => AppDropdownOption(value: e.$1, label: e.$2),
...poTypeOptions.map( ),
(e) => AppDropdownOption(value: e.$1, label: e.$2), ],
), onChanged: onPoTypeChanged,
],
onChanged: onPoTypeChanged,
),
), ),
]; ];
@ -299,12 +292,15 @@ class _FiltersBar extends StatelessWidget {
children: [ children: [
searchField, searchField,
const SizedBox(height: 12), const SizedBox(height: 12),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()..removeLast(), filters[0],
const SizedBox(height: 12),
filters[1],
], ],
); );
} }
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded(flex: 3, child: searchField), Expanded(flex: 3, child: searchField),
const SizedBox(width: 12), const SizedBox(width: 12),

View File

@ -322,7 +322,7 @@ class _SummaryStrip extends StatelessWidget {
} }
} }
class _FiltersBar extends StatelessWidget { class _FiltersBar extends StatefulWidget {
const _FiltersBar({ const _FiltersBar({
required this.searchController, required this.searchController,
required this.filters, required this.filters,
@ -361,8 +361,39 @@ class _FiltersBar extends StatelessWidget {
final VoidCallback onClearPurchaseRange; final VoidCallback onClearPurchaseRange;
final VoidCallback onReset; 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 @override
Widget build(BuildContext context) { 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 final subcategoryOptions = query.itemCategoryId == null
? filters.subcategories ? filters.subcategories
: filters.subcategories : filters.subcategories
@ -370,19 +401,19 @@ class _FiltersBar extends StatelessWidget {
.toList(); .toList();
final searchField = TextField( final searchField = TextField(
controller: searchController, controller: widget.searchController,
onChanged: onSearch, onChanged: widget.onSearch,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Search', labelText: 'Search',
hintText: 'Search asset code or name...', hintText: 'Search asset code or name...',
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
isDense: true, isDense: true,
suffixIcon: searchController.text.isNotEmpty suffixIcon: widget.searchController.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
searchController.clear(); widget.searchController.clear();
onSearch(''); widget.onSearch('');
}, },
) )
: null, : null,
@ -417,37 +448,37 @@ class _FiltersBar extends StatelessWidget {
label: 'Plant', label: 'Plant',
value: query.plantId, value: query.plantId,
options: filters.plants, options: filters.plants,
onChanged: onPlantChanged, onChanged: widget.onPlantChanged,
); );
final category = dropdown( final category = dropdown(
label: 'Category', label: 'Category',
value: query.itemCategoryId, value: query.itemCategoryId,
options: filters.categories, options: filters.categories,
onChanged: onCategoryChanged, onChanged: widget.onCategoryChanged,
); );
final subcategory = dropdown( final subcategory = dropdown(
label: 'Subcategory', label: 'Subcategory',
value: query.itemSubcategoryId, value: query.itemSubcategoryId,
options: subcategoryOptions, options: subcategoryOptions,
onChanged: onSubcategoryChanged, onChanged: widget.onSubcategoryChanged,
); );
final department = dropdown( final department = dropdown(
label: 'Department', label: 'Department',
value: query.departmentId, value: query.departmentId,
options: filters.departments, options: filters.departments,
onChanged: onDepartmentChanged, onChanged: widget.onDepartmentChanged,
); );
final status = dropdown( final status = dropdown(
label: 'Status', label: 'Status',
value: query.status, value: query.status,
options: filters.statuses, options: filters.statuses,
onChanged: onStatusChanged, onChanged: widget.onStatusChanged,
); );
final method = dropdown( final method = dropdown(
label: 'Method', label: 'Method',
value: query.depreciationMethod, value: query.depreciationMethod,
options: filters.depreciationMethods, options: filters.depreciationMethods,
onChanged: onMethodChanged, onChanged: widget.onMethodChanged,
); );
final active = AppSearchableDropdown<bool?>( final active = AppSearchableDropdown<bool?>(
@ -460,78 +491,52 @@ class _FiltersBar extends StatelessWidget {
AppDropdownOption(value: true, label: 'Active'), AppDropdownOption(value: true, label: 'Active'),
AppDropdownOption(value: false, label: 'Inactive'), AppDropdownOption(value: false, label: 'Inactive'),
], ],
onChanged: onIsActiveChanged, onChanged: widget.onIsActiveChanged,
); );
final asOfEmpty = query.asOfDate == null; 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( final asOfField = AppFilterDateField(
label: 'As of date', label: 'As of date',
value: asOfValue, value: asOfEmpty ? '' : DateFormatter.displayDate(query.asOfDate),
placeholder: 'Select date', placeholder: 'Select date',
icon: Icons.calendar_today_outlined, icon: Icons.calendar_today_outlined,
isEmpty: asOfEmpty, isEmpty: asOfEmpty,
onTap: onPickAsOfDate, onTap: widget.onPickAsOfDate,
onClear: asOfEmpty ? null : onClearAsOfDate, onClear: asOfEmpty ? null : widget.onClearAsOfDate,
); );
final purchaseEmpty =
query.purchaseDateFrom == null && query.purchaseDateTo == null;
final purchaseField = AppFilterDateField( final purchaseField = AppFilterDateField(
label: 'Purchase dates', label: 'Purchase dates',
value: purchaseValue, value: purchaseEmpty
? ''
: '${DateFormatter.displayDate(query.purchaseDateFrom)} ${DateFormatter.displayDate(query.purchaseDateTo)}',
placeholder: 'Select range', placeholder: 'Select range',
icon: Icons.date_range_outlined, icon: Icons.date_range_outlined,
isEmpty: purchaseEmpty, isEmpty: purchaseEmpty,
onTap: onPickPurchaseRange, onTap: widget.onPickPurchaseRange,
onClear: purchaseEmpty ? null : onClearPurchaseRange, 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( final reset = TextButton(
onPressed: query.hasActiveFilter ? onReset : null, onPressed: query.hasActiveFilter ? widget.onReset : null,
child: const Text('Reset'), 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) { Widget row(List<Widget> cells) {
assert(cells.length == 4); assert(cells.length == 4);
return Row( 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( return Column(
children: [ children: [
row([searchField, plant, category, subcategory]), row([searchField, plant, category, asOfField]),
const SizedBox(height: 12), const SizedBox(height: 4),
row([department, status, method, active]), Align(alignment: Alignment.centerRight, child: actions),
const SizedBox(height: 12), if (_moreOpen) ...[
row([ const SizedBox(height: 8),
asOfField, Divider(height: 1, color: theme.colorScheme.outline.withValues(alpha: 0.2)),
purchaseField, const SizedBox(height: 12),
const SizedBox.shrink(), row([subcategory, department, status, method]),
Align(alignment: Alignment.centerRight, child: reset), const SizedBox(height: 12),
]), row([
active,
purchaseField,
const SizedBox.shrink(),
const SizedBox.shrink(),
]),
],
], ],
); );
} }

View File

@ -9,10 +9,14 @@ class RolesListState {
const RolesListState({ const RolesListState({
this.roles = const [], this.roles = const [],
this.search = '', this.search = '',
this.page = 1,
this.limit = 10,
}); });
final List<RoleCardModel> roles; final List<RoleCardModel> roles;
final String search; final String search;
final int page;
final int limit;
List<RoleCardModel> get filteredRoles { List<RoleCardModel> get filteredRoles {
if (search.isEmpty) return roles; if (search.isEmpty) return roles;
@ -26,13 +30,32 @@ class RolesListState {
.toList(); .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({ RolesListState copyWith({
List<RoleCardModel>? roles, List<RoleCardModel>? roles,
String? search, String? search,
int? page,
int? limit,
}) { }) {
return RolesListState( return RolesListState(
roles: roles ?? this.roles, roles: roles ?? this.roles,
search: search ?? this.search, 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) { void setSearch(String search) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; 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_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -44,7 +45,8 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
onRetry: () => ref.invalidate(rolesListProvider), onRetry: () => ref.invalidate(rolesListProvider),
), ),
data: (state) { data: (state) {
final roles = state.filteredRoles; final roles = state.pagedRoles;
final notifier = ref.read(rolesListProvider.notifier);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -60,11 +62,20 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
child: AppSearchField( child: AppSearchField(
controller: _searchController, controller: _searchController,
hint: 'Search roles...', 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( child: RefreshIndicator(
onRefresh: () => ref.read(rolesListProvider.notifier).refresh(), onRefresh: notifier.refresh,
child: roles.isEmpty child: roles.isEmpty
? ListView( ? ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),

View File

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

View File

@ -191,47 +191,38 @@ class _FiltersBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final searchField = SizedBox( final searchField = AppSearchField(
width: wrapped ? double.infinity : null, controller: searchController,
child: AppSearchField( hint: 'Search vendors...',
controller: searchController, onChanged: onSearch,
hint: 'Search vendors...',
onChanged: onSearch,
),
); );
final filters = [ final filters = [
SizedBox( AppSearchableDropdown<String?>(
width: wrapped ? double.infinity : 180, label: 'Status',
child: AppSearchableDropdown<String?>( value: query.status,
label: 'Status', searchHint: 'Search status...',
value: query.status, isDense: true,
searchHint: 'Search status...', options: [
isDense: true, const AppDropdownOption(value: null, label: 'All statuses'),
options: [ ...vendorStatusOptions.map(
const AppDropdownOption(value: null, label: 'All statuses'), (e) => AppDropdownOption(value: e.$1, label: e.$2),
...vendorStatusOptions.map( ),
(e) => AppDropdownOption(value: e.$1, label: e.$2), ],
), onChanged: onStatusChanged,
],
onChanged: onStatusChanged,
),
), ),
SizedBox( AppSearchableDropdown<String?>(
width: wrapped ? double.infinity : 200, label: 'Vendor Type',
child: AppSearchableDropdown<String?>( value: query.vendorType,
label: 'Vendor Type', searchHint: 'Search vendor type...',
value: query.vendorType, isDense: true,
searchHint: 'Search vendor type...', options: [
isDense: true, const AppDropdownOption(value: null, label: 'All types'),
options: [ ...vendorTypeOptions.map(
const AppDropdownOption(value: null, label: 'All types'), (e) => AppDropdownOption(value: e.$1, label: e.$2),
...vendorTypeOptions.map( ),
(e) => AppDropdownOption(value: e.$1, label: e.$2), ],
), onChanged: onVendorTypeChanged,
],
onChanged: onVendorTypeChanged,
),
), ),
]; ];
@ -241,13 +232,15 @@ class _FiltersBar extends StatelessWidget {
children: [ children: [
searchField, searchField,
const SizedBox(height: 12), const SizedBox(height: 12),
...filters.expand((filter) => [filter, const SizedBox(height: 12)]).toList() filters[0],
..removeLast(), const SizedBox(height: 12),
filters[1],
], ],
); );
} }
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded(flex: 3, child: searchField), Expanded(flex: 3, child: searchField),
const SizedBox(width: 12), const SizedBox(width: 12),

View File

@ -32,6 +32,11 @@ class AppPagination extends StatelessWidget {
final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1; final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1;
final end = (currentPage * pageSize).clamp(0, totalItems); final end = (currentPage * pageSize).clamp(0, totalItems);
final visiblePages = _visiblePageNumbers(currentPage, totalPages); final visiblePages = _visiblePageNumbers(currentPage, totalPages);
final sizes = List<int>.from(pageSizeOptions);
if (!sizes.contains(pageSize)) {
sizes.add(pageSize);
}
sizes.sort();
return Padding( return Padding(
padding: padding, padding: padding,
@ -50,7 +55,7 @@ class AppPagination extends StatelessWidget {
isDense: true, isDense: true,
underline: const SizedBox.shrink(), underline: const SizedBox.shrink(),
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
items: pageSizeOptions items: sizes
.map( .map(
(size) => DropdownMenuItem( (size) => DropdownMenuItem(
value: size, value: size,

View File

@ -1,33 +1,43 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Dense outlined search field aligned with labeled filter dropdowns.
class AppSearchField extends StatelessWidget { class AppSearchField extends StatelessWidget {
const AppSearchField({ const AppSearchField({
super.key, super.key,
required this.controller, required this.controller,
this.label = 'Search',
this.hint = 'Search...', this.hint = 'Search...',
this.onChanged, this.onChanged,
this.onSubmitted, this.onSubmitted,
this.onClear, this.onClear,
this.alignWithLabeledFields = true,
}); });
final TextEditingController controller; final TextEditingController controller;
final String label;
final String hint; final String hint;
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted; final ValueChanged<String>? onSubmitted;
final VoidCallback? onClear; final VoidCallback? onClear;
/// Adds top padding to line up with [AppSearchableDropdown] (label float space).
final bool alignWithLabeledFields;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( final field = TextField(
controller: controller, controller: controller,
onChanged: onChanged, onChanged: onChanged,
onSubmitted: onSubmitted, onSubmitted: onSubmitted,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label,
hintText: hint, hintText: hint,
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
suffixIcon: controller.text.isNotEmpty suffixIcon: controller.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear, size: 18),
onPressed: () { onPressed: () {
controller.clear(); controller.clear();
onClear?.call(); onClear?.call();
@ -35,8 +45,13 @@ class AppSearchField extends StatelessWidget {
}, },
) )
: null, : null,
isDense: true,
), ),
); );
if (!alignWithLabeledFields) return field;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: field,
);
} }
} }