pagenation bug
This commit is contained in:
parent
4b4a51e553
commit
7a6bde09dd
@ -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();
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
state = AsyncData(await _load());
|
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 {
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
state = AsyncData(
|
||||||
current.copyWith(
|
await _load(page: page, limit: limit, search: search),
|
||||||
items: data.items,
|
|
||||||
page: data.page,
|
|
||||||
limit: data.limit,
|
|
||||||
total: data.total,
|
|
||||||
totalPages: data.totalPages,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> deleteRecord(String id) async {
|
Future<bool> deleteRecord(String id) async {
|
||||||
|
|||||||
@ -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,21 +176,8 @@ 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,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
side: BorderSide(
|
|
||||||
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return AppSearchExportBar(
|
return AppSearchExportBar(
|
||||||
wrapped: constraints.maxWidth < 640,
|
wrapped: constraints.maxWidth < 640,
|
||||||
@ -208,9 +190,15 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
footer: AppPagination(
|
||||||
|
currentPage: state.page,
|
||||||
|
totalPages: state.totalPages,
|
||||||
|
totalItems: state.total,
|
||||||
|
pageSize: state.limit,
|
||||||
|
itemLabel: def.title.toLowerCase(),
|
||||||
|
onPageChanged: notifier.setPage,
|
||||||
|
onPageSizeChanged: notifier.setPageSize,
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: notifier.refresh,
|
onRefresh: notifier.refresh,
|
||||||
child: state.items.isEmpty
|
child: state.items.isEmpty
|
||||||
@ -239,67 +227,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(
|
||||||
|
(entry) => AppDropdownOption<String?>(
|
||||||
value: entry.key,
|
value: entry.key,
|
||||||
label: entry.value,
|
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,19 +256,14 @@ 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,
|
|
||||||
child: AppSearchField(
|
|
||||||
controller: searchController,
|
controller: searchController,
|
||||||
hint: 'Search PO number, vendor...',
|
hint: 'Search PO number, vendor...',
|
||||||
onChanged: onSearch,
|
onChanged: onSearch,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final filters = [
|
final filters = [
|
||||||
SizedBox(
|
AppSearchableDropdown<String?>(
|
||||||
width: wrapped ? double.infinity : 180,
|
|
||||||
child: AppSearchableDropdown<String?>(
|
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: query.status,
|
value: query.status,
|
||||||
searchHint: 'Search status...',
|
searchHint: 'Search status...',
|
||||||
@ -274,10 +271,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
options: statusOptions,
|
options: statusOptions,
|
||||||
onChanged: onStatusChanged,
|
onChanged: onStatusChanged,
|
||||||
),
|
),
|
||||||
),
|
AppSearchableDropdown<String?>(
|
||||||
SizedBox(
|
|
||||||
width: wrapped ? double.infinity : 200,
|
|
||||||
child: AppSearchableDropdown<String?>(
|
|
||||||
label: 'PO Type',
|
label: 'PO Type',
|
||||||
value: query.poType,
|
value: query.poType,
|
||||||
searchHint: 'Search type...',
|
searchHint: 'Search type...',
|
||||||
@ -290,7 +284,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onChanged: onPoTypeChanged,
|
onChanged: onPoTypeChanged,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (wrapped) {
|
if (wrapped) {
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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,19 +550,65 @@ 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: 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),
|
const SizedBox(height: 12),
|
||||||
row([department, status, method, active]),
|
row([subcategory, department, status, method]),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
row([
|
row([
|
||||||
asOfField,
|
active,
|
||||||
purchaseField,
|
purchaseField,
|
||||||
const SizedBox.shrink(),
|
const SizedBox.shrink(),
|
||||||
Align(alignment: Alignment.centerRight, child: reset),
|
const SizedBox.shrink(),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -191,19 +191,14 @@ 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,
|
|
||||||
child: AppSearchField(
|
|
||||||
controller: searchController,
|
controller: searchController,
|
||||||
hint: 'Search vendors...',
|
hint: 'Search vendors...',
|
||||||
onChanged: onSearch,
|
onChanged: onSearch,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final filters = [
|
final filters = [
|
||||||
SizedBox(
|
AppSearchableDropdown<String?>(
|
||||||
width: wrapped ? double.infinity : 180,
|
|
||||||
child: AppSearchableDropdown<String?>(
|
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: query.status,
|
value: query.status,
|
||||||
searchHint: 'Search status...',
|
searchHint: 'Search status...',
|
||||||
@ -216,10 +211,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onChanged: onStatusChanged,
|
onChanged: onStatusChanged,
|
||||||
),
|
),
|
||||||
),
|
AppSearchableDropdown<String?>(
|
||||||
SizedBox(
|
|
||||||
width: wrapped ? double.infinity : 200,
|
|
||||||
child: AppSearchableDropdown<String?>(
|
|
||||||
label: 'Vendor Type',
|
label: 'Vendor Type',
|
||||||
value: query.vendorType,
|
value: query.vendorType,
|
||||||
searchHint: 'Search vendor type...',
|
searchHint: 'Search vendor type...',
|
||||||
@ -232,7 +224,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onChanged: onVendorTypeChanged,
|
onChanged: onVendorTypeChanged,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (wrapped) {
|
if (wrapped) {
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user