pagenation bug
This commit is contained in:
parent
4b4a51e553
commit
7a6bde09dd
@ -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 {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(),
|
||||
]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -284,6 +284,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(flex: 3, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user