PO calculation

This commit is contained in:
Surendiran 2026-07-20 11:58:17 +05:30
parent 09ffc5fb6a
commit 4046633340
26 changed files with 952 additions and 550 deletions

View File

@ -93,25 +93,14 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
}
}
items = TableSearch.filter(
items,
search,
(asset) => [
asset.assetCode,
asset.assetName,
asset.assetCategoryName,
asset.locationName,
asset.status,
asset.condition,
],
);
// Keep API page results + enriched matches; pagination stays server-driven.
}
return AssetsListState(
assets: items,
query: query,
total: search.isEmpty ? page.total : items.length,
totalPages: search.isEmpty ? page.totalPages : 1,
total: page.total,
totalPages: page.totalPages,
);
}

View File

@ -158,33 +158,17 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onStatusChanged: notifier.setStatusFilter,
),
),
if (state.assets.isEmpty)
Expanded(
child: Center(
child: Text(
'No assets found',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
)
else
Expanded(
child: _AssetDataTable(
assets: state.assets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
),
Expanded(
child: _AssetDataTable(
assets: state.assets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
onServerSearch: notifier.setSearch,
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
@ -390,6 +374,7 @@ class _AssetDataTable extends StatelessWidget {
required this.onView,
required this.onEdit,
required this.onDelete,
this.onServerSearch,
});
final List<AssetModel> assets;
@ -398,11 +383,13 @@ class _AssetDataTable extends StatelessWidget {
final void Function(AssetModel asset) onView;
final void Function(AssetModel asset) onEdit;
final Future<void> Function(AssetModel asset) onDelete;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
return AppDataTable<AssetModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(
label: 'Asset Code',

View File

@ -637,10 +637,13 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
),
),
const SizedBox(height: 12),
lookupsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load locations'),
data: (lookups) => _locationDropdown(lookups.locations),
SidePanelFormRow(
left: lookupsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load locations'),
data: (lookups) => _locationDropdown(lookups.locations),
),
right: const SizedBox.shrink(),
),
],
),
@ -744,12 +747,15 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
),
),
const SizedBox(height: 12),
_optionalLookupDropdown(
label: 'Assigned To',
value: _assignedToUserId,
options: lookups.users,
onChanged: (v) => setState(() => _assignedToUserId = v),
emptyHint: 'Unassigned',
SidePanelFormRow(
left: _optionalLookupDropdown(
label: 'Assigned To',
value: _assignedToUserId,
options: lookups.users,
onChanged: (v) => setState(() => _assignedToUserId = v),
emptyHint: 'Unassigned',
),
right: const SizedBox.shrink(),
),
],
),

View File

@ -206,30 +206,40 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
),
],
)
: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No audit logs found',
description:
'Try adjusting filters or expanding the date range.',
icon: Icons.history_outlined,
),
),
],
)
: context.isMobile
? _AuditCardList(
items: state.items,
onView: _viewLog,
: context.isMobile
? (state.items.isEmpty
? ListView(
physics:
const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No audit logs found',
description:
'Try adjusting filters or expanding the date range.',
icon: Icons.history_outlined,
),
),
],
)
: _AuditDataTable(
: _AuditCardList(
items: state.items,
onView: _viewLog,
),
))
: _AuditDataTable(
items: state.items,
onView: _viewLog,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
notifier.setSearch(value);
},
),
),
),
),
@ -369,10 +379,12 @@ class _AuditDataTable extends StatelessWidget {
const _AuditDataTable({
required this.items,
required this.onView,
this.onServerSearch,
});
final List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
@ -380,6 +392,7 @@ class _AuditDataTable extends StatelessWidget {
wrapInCard: false,
rows: items,
emptyMessage: 'No audit logs found',
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(
label: 'When',

View File

@ -134,25 +134,13 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
} catch (_) {
// Lookups enrichment is best-effort.
}
items = TableSearch.filter(
items,
search,
(grn) => [
grn.grnNumber,
grn.poNumber,
grn.vendorName,
grn.locationName,
grn.status,
],
);
}
return GrnListState(
grns: items,
query: query,
total: search.isEmpty ? page.total : items.length,
totalPages: search.isEmpty ? page.totalPages : 1,
total: page.total,
totalPages: page.totalPages,
);
}

View File

@ -626,7 +626,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final theme = Theme.of(context);
final title = widget.isEditing
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
: 'Create Goods Received Note';
: 'Create Purchase Receipt';
final subtitle = widget.isEditing
? null
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';

View File

@ -73,7 +73,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Goods Received Notes',
title: 'Purchase Receipt',
subtitle: 'Record and track purchase order receipts',
actions: [
AppSearchFilterButton(
@ -133,32 +133,41 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
),
child: RefreshIndicator(
onRefresh: () => ref.read(grnListProvider.notifier).refresh(),
child: state.grns.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No GRNs found',
description:
'Try adjusting filters or create a new goods received note.',
icon: Icons.inventory_2_outlined,
),
),
],
)
: context.isMobile
? _GrnCardList(
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
child: context.isMobile
? (state.grns.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No GRNs found',
description:
'Try adjusting filters or create a new goods received note.',
icon: Icons.inventory_2_outlined,
),
),
],
)
: _GrnDataTable(
: _GrnCardList(
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
),
))
: _GrnDataTable(
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
ref.read(grnListProvider.notifier).setSearch(value);
},
),
),
),
),
@ -288,16 +297,19 @@ class _GrnDataTable extends StatelessWidget {
required this.grns,
required this.onView,
this.onEdit,
this.onServerSearch,
});
final List<GrnModel> grns;
final ValueChanged<GrnModel> onView;
final ValueChanged<GrnModel>? onEdit;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
return AppDataTable<GrnModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(
label: 'GRN Number',

View File

@ -152,14 +152,7 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
}
}
items = TableSearch.filter(
items,
nextSearch,
(row) => [
..._definition.listFields.map((field) => masterCellValue(row, field)),
masterStatusValue(row),
],
);
// Keep API page results + enriched matches; pagination stays server-driven.
}
final data = result.data!;
@ -170,10 +163,8 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
// Keep the requested page size so the /page dropdown stays valid
// even if API meta omits or mismatches `limit`.
limit: nextLimit,
total: nextSearch.isEmpty ? data.total : items.length,
totalPages: nextSearch.isEmpty
? resolveTotalPages(total: data.total, limit: nextLimit)
: resolveTotalPages(total: items.length, limit: nextLimit),
total: data.total,
totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
);
}

View File

@ -9,7 +9,6 @@ import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.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';
@ -257,30 +256,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
),
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,
child: _MasterListTable(
definition: def,
items: state.items,
isDeleting: state.isDeleting,
canEdit: canEdit,
canDelete: canDelete,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
notifier.setSearch(value);
},
),
),
),
),
@ -301,6 +294,7 @@ class _MasterListTable extends StatelessWidget {
required this.canDelete,
required this.onEdit,
required this.onDelete,
this.onServerSearch,
});
final MasterDefinition definition;
@ -310,6 +304,7 @@ class _MasterListTable extends StatelessWidget {
final bool canDelete;
final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
@ -317,6 +312,8 @@ class _MasterListTable extends StatelessWidget {
return AppDataTable<Map<String, dynamic>>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
emptyMessage: 'No ${definition.title.toLowerCase()} found',
columns: [
...definition.listFields.map(
(field) => AppDataColumn<Map<String, dynamic>>(

View File

@ -412,11 +412,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final fullWidth = left.multiline || left.key == 'is_asset_item';
if (fullWidth) {
widgets.add(
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SizedBox(
width: double.infinity,
child: _buildField(context, field: left, formState: formState),
QuickAddBlockable(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SizedBox(
width: double.infinity,
child: _buildField(context, field: left, formState: formState),
),
),
),
);
@ -438,11 +440,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
i += 2;
} else {
widgets.add(
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SizedBox(
width: double.infinity,
child: _buildField(context, field: left, formState: formState),
QuickAddBlockable(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SizedBox(
width: double.infinity,
child: _buildField(context, field: left, formState: formState),
),
),
),
);
@ -452,11 +456,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
if (activeField != null) {
widgets.add(
SidePanelSection(
title: 'STATUS',
children: [
_buildField(context, field: activeField, formState: formState),
],
QuickAddBlockable(
child: SidePanelSection(
title: 'STATUS',
children: [
_buildField(context, field: activeField, formState: formState),
],
),
),
);
}

View File

@ -148,8 +148,14 @@ class _MasterQuickAddDropdownState<T>
}
void _collapse() {
if (!_expanded) return;
_host?.dismiss(this);
final host = _host ?? QuickAddInlineScope.maybeOf(context);
// Prefer owner dismiss; fall back to dismissAll so Cancel/Close always works
// even if the host state was remounted while the form was open.
if (host != null) {
host.dismiss(this);
if (host.isOpen) host.dismissAll();
}
if (!_expanded && _sessionId == null) return;
setState(() {
_expanded = false;
_sessionId = null;
@ -161,24 +167,32 @@ class _MasterQuickAddDropdownState<T>
Future<void> _onSaved(String createdId) async {
await _applyCreated(createdId);
_collapse();
if (mounted) _collapse();
}
Future<void> _applyCreated(String createdId) async {
final parsed = (createdId.isNotEmpty && createdId != 'created')
? widget.parseCreatedId(createdId)
: null;
// Select first so parent form keeps the new id even if this State is
// disposed during the options reload rebuild.
if (parsed != null) {
widget.onChanged(parsed);
}
try {
final refresh = widget.refreshLookups;
if (refresh != null) await refresh();
} catch (_) {
// Still select the created row even if lookup refresh fails.
// Still keep the created selection even if lookup refresh fails.
}
if (!mounted) return;
// Prefer a real id from create; fall back only when API omits it.
if (createdId.isNotEmpty && createdId != 'created') {
final parsed = widget.parseCreatedId(createdId);
if (parsed != null) {
widget.onChanged(parsed);
}
// Re-apply after reload in case options refresh cleared dependent values.
if (parsed != null) {
widget.onChanged(parsed);
}
if (!mounted) return;
@ -207,7 +221,7 @@ class _MasterQuickAddDropdownState<T>
final host = _host ?? QuickAddInlineScope.maybeOf(context);
if (host == null) return;
_host = host;
host.present(owner: this, form: _buildCreateForm());
host.present(owner: this, formBuilder: _buildCreateForm);
}
@override
@ -246,7 +260,14 @@ class _MasterQuickAddDropdownState<T>
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
dropdown,
IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: 0.45,
child: dropdown,
),
),
AnimatedSize(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,

View File

@ -84,12 +84,11 @@ class PurchaseOrdersListNotifier
items: page.items,
load: repository.getPurchaseOrders,
);
final search = TableSearch.normalize(query.search);
return PurchaseOrdersListState(
orders: orders,
query: query,
total: search.isEmpty ? page.total : orders.length,
totalPages: search.isEmpty ? page.totalPages : 1,
total: page.total,
totalPages: page.totalPages,
);
}
@ -202,12 +201,11 @@ class PendingApprovalPurchaseOrdersListNotifier
items: page.items,
load: repository.getPendingApprovalPurchaseOrders,
);
final search = TableSearch.normalize(query.search);
return PurchaseOrdersListState(
orders: orders,
query: query,
total: search.isEmpty ? page.total : orders.length,
totalPages: search.isEmpty ? page.totalPages : 1,
total: page.total,
totalPages: page.totalPages,
);
}
@ -479,15 +477,5 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
// Lookups/vendor enrichment is best-effort.
}
return TableSearch.filter(
merged,
search,
(order) => [
order.poNo,
order.vendorName,
order.status,
order.billingName,
order.shippingName,
],
);
return merged;
}

View File

@ -396,7 +396,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
_SectionCard(
title: 'ORDER DETAILS',
child: QuickAddInlineHost(
child: ResponsiveFormGrid(
child: QuickAddBlockable(
child: ResponsiveFormGrid(
smallColumns: 1,
mediumColumns: 2,
largeColumns: 4,
@ -535,6 +536,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
),
],
),
),
),
),
const SizedBox(height: 16),

View File

@ -214,41 +214,61 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
)
.refresh()
: ref.read(purchaseOrdersListProvider.notifier).refresh(),
child: state.orders.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: 240,
child: AppEmptyState(
title: pendingOnly
? 'No pending approvals'
: 'No purchase orders found',
description: pendingOnly
? 'There are no purchase orders waiting for approval.'
: 'Try adjusting filters or create a new purchase order.',
icon: pendingOnly
? Icons.pending_actions_outlined
: Icons.receipt_long_outlined,
),
),
],
)
: context.isMobile
? _PoCardList(
orders: state.orders,
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
child: context.isMobile
? (state.orders.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: 240,
child: AppEmptyState(
title: pendingOnly
? 'No pending approvals'
: 'No purchase orders found',
description: pendingOnly
? 'There are no purchase orders waiting for approval.'
: 'Try adjusting filters or create a new purchase order.',
icon: pendingOnly
? Icons.pending_actions_outlined
: Icons.receipt_long_outlined,
),
),
],
)
: _PoDataTable(
: _PoCardList(
orders: state.orders,
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
),
))
: _PoDataTable(
orders: state.orders,
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
if (pendingOnly) {
ref
.read(
pendingApprovalPurchaseOrdersListProvider
.notifier,
)
.setSearch(value);
} else {
ref
.read(purchaseOrdersListProvider.notifier)
.setSearch(value);
}
},
),
),
),
),
@ -427,6 +447,7 @@ class _PoDataTable extends StatelessWidget {
this.onEdit,
this.onDelete,
this.onApprove,
this.onServerSearch,
});
final List<PurchaseOrderModel> orders;
@ -434,12 +455,14 @@ class _PoDataTable extends StatelessWidget {
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? onApprove;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppDataTable<PurchaseOrderModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(
label: 'PO Number',

View File

@ -85,7 +85,7 @@ class PoOrderTotals {
/// Sub Total = sum of line amounts
/// Taxable = Sub Total + Freight + Other Discount
/// Tax = sum of line GST amounts
/// Tax = Taxable × effective GST rate (from line GST ÷ Sub Total)
/// Grand Total = Taxable + Tax
factory PoOrderTotals.compute({
required Iterable<PoLineCalculation> lines,
@ -94,17 +94,20 @@ class PoOrderTotals {
required double discountAmount,
}) {
var subTotal = 0.0;
var tax = 0.0;
var lineTax = 0.0;
for (final line in lines) {
subTotal += line.lineAmount;
tax += line.gstAmount;
lineTax += line.gstAmount;
}
final freightSafe = freight < 0 ? 0.0 : freight;
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
final maxDiscount = subTotal + tax + freightSafe + otherSafe;
// Apply the blended line GST rate to Taxable Amount (not Sub Total),
// so freight / other / discount are included in the tax base.
final tax = subTotal > 0 ? lineTax * (taxable / subTotal) : 0.0;
final maxDiscount = subTotal + lineTax + freightSafe + otherSafe;
final grandTotal = taxable + tax;
return PoOrderTotals(
subTotal: subTotal,
@ -629,7 +632,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
borderRadius: BorderRadius.circular(10),
),
child: QuickAddInlineHost(
child: LayoutBuilder(
child: QuickAddBlockable(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
@ -675,6 +679,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
);
},
),
),
),
);
}

View File

@ -833,33 +833,28 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
},
),
),
if (usersState.users.isEmpty)
Expanded(
child: Center(
child: Text(
'No users found',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
Expanded(
child: UserRichDataTable(
wrapInCard: false,
users: usersState.users,
actionsBuilder: (_, user) => UserTableActions(
user: user,
canEdit: canEditUser,
canResetPassword: canEditUser,
canDeactivate: canDeleteUser,
onEdit: () => _editUser(user),
onResetPassword: () => _resetPassword(user),
onDeactivate: () => _deactivateUser(user),
),
Expanded(
child: UserRichDataTable(
wrapInCard: false,
users: usersState.users,
onServerSearchChanged: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(offset: value.length),
);
ref.read(usersListProvider.notifier).setSearch(value);
},
actionsBuilder: (_, user) => UserTableActions(
user: user,
canEdit: canEditUser,
canResetPassword: canEditUser,
canDeactivate: canDeleteUser,
onEdit: () => _editUser(user),
onResetPassword: () => _resetPassword(user),
onDeactivate: () => _deactivateUser(user),
),
),
),
const Divider(height: 1),
AppPagination(
currentPage: page,

View File

@ -215,24 +215,35 @@ class _DepreciationReportScreenState
),
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No depreciation data',
description:
'Try adjusting filters or the as-of date.',
icon: Icons.trending_down_outlined,
child: context.isMobile
? (state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No depreciation data',
description:
'Try adjusting filters or the as-of date.',
icon: Icons.trending_down_outlined,
),
),
],
)
: _MobileList(items: state.items))
: _ReportTable(
items: state.items,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
),
],
)
: context.isMobile
? _MobileList(items: state.items)
: _ReportTable(items: state.items),
);
notifier.setSearch(value);
},
),
),
),
),
@ -581,15 +592,20 @@ class _FiltersBarState extends State<_FiltersBar> {
}
class _ReportTable extends StatelessWidget {
const _ReportTable({required this.items});
const _ReportTable({
required this.items,
this.onServerSearch,
});
final List<DepreciationReportRow> items;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
return AppDataTable<DepreciationReportRow>(
wrapInCard: false,
rows: items,
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(
label: 'Asset Code',

View File

@ -21,15 +21,8 @@ class RolesListState {
final int limit;
List<RoleCardModel> get filteredRoles {
if (search.isEmpty) return roles;
final q = search.toLowerCase();
return roles
.where(
(role) =>
role.name.toLowerCase().contains(q) ||
(role.description?.toLowerCase().contains(q) ?? false),
)
.toList();
// Roles are already filtered by the API when [search] is set.
return roles;
}
int get total => filteredRoles.length;
@ -124,10 +117,17 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
return true;
}
void setSearch(String search) {
Future<void> setSearch(String search) async {
final current = state.valueOrNull;
if (current == null) return;
state = AsyncData(current.copyWith(search: TableSearch.normalize(search), page: 1));
final normalized = TableSearch.normalize(search);
state = const AsyncLoading();
try {
final loaded = await _load(search: normalized);
state = AsyncData(loaded.copyWith(page: 1, limit: current.limit));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void setPage(int page) {

View File

@ -88,24 +88,36 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
),
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: roles.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No roles found',
description:
'Roles from the API will appear here.',
icon: Icons.security_outlined,
child: context.isMobile
? (roles.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No roles found',
description:
'Roles from the API will appear here.',
icon: Icons.security_outlined,
),
),
],
)
: _RoleCardList(roles: roles, onOpen: _openRole))
: _RoleDataTable(
roles: roles,
onOpen: _openRole,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
),
],
)
: context.isMobile
? _RoleCardList(roles: roles, onOpen: _openRole)
: _RoleDataTable(roles: roles, onOpen: _openRole),
);
notifier.setSearch(value);
},
),
),
),
),
@ -122,15 +134,21 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
}
class _RoleDataTable extends StatelessWidget {
const _RoleDataTable({required this.roles, required this.onOpen});
const _RoleDataTable({
required this.roles,
required this.onOpen,
this.onServerSearch,
});
final List<RoleCardModel> roles;
final void Function(RoleCardModel role) onOpen;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
return AppDataTable<RoleCardModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
AppDataColumn(

View File

@ -109,41 +109,52 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
),
child: RefreshIndicator(
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
child: state.users.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No users found',
description:
'Try adjusting filters or add a new user.',
icon: Icons.people_outline,
),
),
],
)
: context.isMobile
? _UserCardList(
users: state.users,
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
child: context.isMobile
? (state.users.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No users found',
description:
'Try adjusting filters or add a new user.',
icon: Icons.people_outline,
),
),
],
)
: _UserDataTable(
: _UserCardList(
users: state.users,
sortBy: state.query.sortBy,
sortOrder: state.query.sortOrder,
onSort: (column, ascending) => ref
.read(usersListProvider.notifier)
.setSort(column, ascending ? 'asc' : 'desc'),
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
),
))
: _UserDataTable(
users: state.users,
sortBy: state.query.sortBy,
sortOrder: state.query.sortOrder,
onSort: (column, ascending) => ref
.read(usersListProvider.notifier)
.setSort(column, ascending ? 'asc' : 'desc'),
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
ref
.read(usersListProvider.notifier)
.setSearch(value);
},
),
),
),
),
@ -301,6 +312,7 @@ class _UserDataTable extends StatelessWidget {
required this.onEdit,
required this.onToggleStatus,
required this.onDeactivate,
this.onServerSearch,
});
final List<ManagedUserModel> users;
@ -311,6 +323,7 @@ class _UserDataTable extends StatelessWidget {
final void Function(ManagedUserModel user) onEdit;
final Future<void> Function(ManagedUserModel user) onToggleStatus;
final Future<void> Function(ManagedUserModel user) onDeactivate;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
@ -320,6 +333,7 @@ class _UserDataTable extends StatelessWidget {
sortAscending: sortOrder == 'asc',
onSort: onSort,
wrapInCard: false,
onServerSearchChanged: onServerSearch,
actionsBuilder: (_, user) => _UserActions(
user: user,
onView: onView,

View File

@ -20,6 +20,7 @@ class UserRichDataTable extends StatelessWidget {
this.sortAscending = true,
this.onSort,
this.wrapInCard = false,
this.onServerSearchChanged,
});
final List<ManagedUserModel> users;
@ -28,6 +29,7 @@ class UserRichDataTable extends StatelessWidget {
final bool sortAscending;
final void Function(String column, bool ascending)? onSort;
final bool wrapInCard;
final ValueChanged<String>? onServerSearchChanged;
@override
Widget build(BuildContext context) {
@ -38,6 +40,7 @@ class UserRichDataTable extends StatelessWidget {
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
onServerSearchChanged: onServerSearchChanged,
columns: [
AppDataColumn(
label: 'User',

View File

@ -137,34 +137,45 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
),
child: RefreshIndicator(
onRefresh: () => ref.read(vendorsListProvider.notifier).refresh(),
child: state.vendors.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No vendors found',
description:
'Try adjusting filters or add a new vendor.',
icon: Icons.store_outlined,
),
),
],
)
: context.isMobile
? _VendorCardList(
vendors: state.vendors,
onView: _viewVendor,
onEdit: canEdit ? _editVendor : null,
onDelete: canDelete ? _deleteVendor : null,
child: context.isMobile
? (state.vendors.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No vendors found',
description:
'Try adjusting filters or add a new vendor.',
icon: Icons.store_outlined,
),
),
],
)
: _VendorDataTable(
: _VendorCardList(
vendors: state.vendors,
onView: _viewVendor,
onEdit: canEdit ? _editVendor : null,
onDelete: canDelete ? _deleteVendor : null,
),
))
: _VendorDataTable(
vendors: state.vendors,
onView: _viewVendor,
onEdit: canEdit ? _editVendor : null,
onDelete: canDelete ? _deleteVendor : null,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
ref
.read(vendorsListProvider.notifier)
.setSearch(value);
},
),
),
),
),
@ -288,17 +299,20 @@ class _VendorDataTable extends StatelessWidget {
required this.onView,
this.onEdit,
this.onDelete,
this.onServerSearch,
});
final List<VendorModel> vendors;
final ValueChanged<VendorModel> onView;
final ValueChanged<VendorModel>? onEdit;
final ValueChanged<VendorModel>? onDelete;
final ValueChanged<String>? onServerSearch;
@override
Widget build(BuildContext context) {
return AppDataTable<VendorModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
columns: [
AppDataColumn(
label: 'Code',

View File

@ -46,6 +46,27 @@ const List<MenuItem> appMenuItems = [
// route: RouteConstants.branches,
// module: 'branches',
// ),
MenuItem(
label: 'Purchase Orders',
icon: Icons.receipt_long_outlined,
route: RouteConstants.purchaseOrders,
module: 'purchase_orders',
children: [
MenuItem(
label: 'All Orders',
icon: Icons.receipt_long_outlined,
route: RouteConstants.purchaseOrders,
module: 'purchase_orders',
),
MenuItem(
label: 'Pending Approval',
icon: Icons.pending_actions_outlined,
route: RouteConstants.purchaseOrdersPendingApproval,
module: 'purchase_orders',
requiredAction: PermissionAction.approve,
),
],
),
MenuItem(
label: 'Assets',
icon: Icons.inventory_2_outlined,
@ -73,34 +94,7 @@ const List<MenuItem> appMenuItems = [
],
),
MenuItem(
label: 'Vendors',
icon: Icons.store_outlined,
route: RouteConstants.vendors,
module: 'vendors',
),
MenuItem(
label: 'Purchase Orders',
icon: Icons.receipt_long_outlined,
route: RouteConstants.purchaseOrders,
module: 'purchase_orders',
children: [
MenuItem(
label: 'All Orders',
icon: Icons.receipt_long_outlined,
route: RouteConstants.purchaseOrders,
module: 'purchase_orders',
),
MenuItem(
label: 'Pending Approval',
icon: Icons.pending_actions_outlined,
route: RouteConstants.purchaseOrdersPendingApproval,
module: 'purchase_orders',
requiredAction: PermissionAction.approve,
),
],
),
MenuItem(
label: 'GRN',
label: 'Purchase Receipt',
icon: Icons.inventory_2_outlined,
route: RouteConstants.grn,
module: 'grn',
@ -112,33 +106,52 @@ const List<MenuItem> appMenuItems = [
module: 'reports',
),
MenuItem(
label: 'Users & Roles',
icon: Icons.admin_panel_settings_outlined,
route: RouteConstants.usersRoleManagement,
module: 'users',
),
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,
route: RouteConstants.masterData,
module: 'master_data',
),
MenuItem(
label: 'Audit Logs',
icon: Icons.history_outlined,
route: RouteConstants.auditLogs,
module: 'audit_logs',
),
MenuItem(
label: 'Settings',
icon: Icons.settings_outlined,
route: RouteConstants.settings,
module: 'settings',
label: 'Support',
icon: Icons.support_agent_outlined,
route: RouteConstants.vendors,
module: 'support',
children: [
MenuItem(
label: 'Vendors',
icon: Icons.store_outlined,
route: RouteConstants.vendors,
module: 'vendors',
),
MenuItem(
label: 'Users & Roles',
icon: Icons.admin_panel_settings_outlined,
route: RouteConstants.usersRoleManagement,
module: 'users',
),
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,
route: RouteConstants.masterData,
module: 'master_data',
),
MenuItem(
label: 'Audit Logs',
icon: Icons.history_outlined,
route: RouteConstants.auditLogs,
module: 'audit_logs',
),
MenuItem(
label: 'Settings',
icon: Icons.settings_outlined,
route: RouteConstants.settings,
module: 'settings',
),
],
),
];
/// Routes rendered under the Support section (after primary ops menus).
/// True when [item] is the Support group (top-nav dropdown / sidebar section).
bool isSupportMenuItem(MenuItem item) =>
item.module == 'support' || item.label == 'Support';
/// Child routes that belong under Support (sidebar section flattening).
const Set<String> supportMenuRoutes = {
RouteConstants.vendors,
RouteConstants.usersRoleManagement,
RouteConstants.masterData,
RouteConstants.auditLogs,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/utils/table_search.dart';
import 'app_card.dart';
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
@ -78,6 +79,7 @@ class AppDataTable<T> extends StatefulWidget {
this.noMatchMessage = 'No matching records',
this.wrapInCard = true,
this.shrinkWrap = false,
this.onServerSearchChanged,
});
final List<AppDataColumn<T>> columns;
@ -92,6 +94,13 @@ class AppDataTable<T> extends StatefulWidget {
/// Set true when the table is placed inside another scrollable.
final bool shrinkWrap;
/// When set, column filters are sent to the parent for API search instead of
/// filtering only the currently loaded [rows] (current page).
///
/// The callback receives a normalized query (trimmed). Empty string means
/// clear search and reload the full paginated list.
final ValueChanged<String>? onServerSearchChanged;
@override
State<AppDataTable<T>> createState() => _AppDataTableState<T>();
}
@ -100,9 +109,15 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
/// Column index search query (raw, including spaces until applied).
final Map<int, String> _queries = {};
final Map<int, TextEditingController> _controllers = {};
final SearchDebouncer _serverSearchDebouncer = SearchDebouncer();
int? _lastEditedColumnIndex;
String _lastEmittedServerSearch = '';
bool get _serverSideSearch => widget.onServerSearchChanged != null;
@override
void dispose() {
_serverSearchDebouncer.dispose();
for (final c in _controllers.values) {
c.dispose();
}
@ -113,7 +128,38 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
return _controllers.putIfAbsent(index, TextEditingController.new);
}
String _composeServerSearch() {
if (_lastEditedColumnIndex != null) {
final latest = (_queries[_lastEditedColumnIndex!] ?? '').trim();
if (latest.isNotEmpty) return latest;
}
for (final entry in _queries.entries) {
final q = entry.value.trim();
if (q.isNotEmpty) return q;
}
return '';
}
void _onColumnQueryChanged(int index, String value) {
setState(() {
_queries[index] = value;
_lastEditedColumnIndex = index;
});
if (!_serverSideSearch) return;
_serverSearchDebouncer.run(value, (_) {
final composed = _composeServerSearch();
if (composed == _lastEmittedServerSearch) return;
_lastEmittedServerSearch = composed;
widget.onServerSearchChanged!(composed);
});
}
List<T> get _filteredRows {
// Server-side mode: parent already fetched matching rows from the API.
if (_serverSideSearch) return widget.rows;
final active = <int, String>{};
for (final entry in _queries.entries) {
final q = entry.value.trim().toLowerCase();
@ -139,7 +185,12 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
@override
Widget build(BuildContext context) {
if (widget.rows.isEmpty) {
final showFilterRow = widget.columns.any((c) => c.isSearchable);
final filtered = _filteredRows;
// Keep header + column filters visible even when the API returns no rows,
// so users can refine or clear a server-side search.
if (widget.rows.isEmpty && !showFilterRow) {
final empty = Center(
child: Padding(
padding: const EdgeInsets.all(32),
@ -155,9 +206,6 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
}
final filtered = _filteredRows;
final showFilterRow = widget.columns.any((c) => c.isSearchable);
final header = _TableHeaderRow<T>(
columns: widget.columns,
sortColumn: widget.sortColumn,
@ -170,9 +218,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
columns: widget.columns,
controllerFor: _controllerFor,
queryFor: (i) => _queries[i] ?? '',
onQueryChanged: (index, value) {
setState(() => _queries[index] = value);
},
onQueryChanged: _onColumnQueryChanged,
)
: null;
@ -182,7 +228,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
_hasActiveColumnFilters
_hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty
? widget.noMatchMessage
: widget.emptyMessage,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(

View File

@ -3,31 +3,117 @@ import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';
import 'app_toast.dart';
/// Tracks whether any Quick Add inline form is open in the current panel/scope.
///
/// Used to dim and block the parent form (and footer) while Quick Add is active.
class QuickAddBlockerController extends ChangeNotifier {
int _depth = 0;
bool get isBlocked => _depth > 0;
void push() {
_depth += 1;
notifyListeners();
}
void pop() {
if (_depth <= 0) return;
_depth -= 1;
notifyListeners();
}
void reset() {
if (_depth == 0) return;
_depth = 0;
notifyListeners();
}
}
class QuickAddBlockerScope extends InheritedNotifier<QuickAddBlockerController> {
const QuickAddBlockerScope({
super.key,
required QuickAddBlockerController controller,
required super.child,
}) : super(notifier: controller);
static QuickAddBlockerController? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<QuickAddBlockerScope>()
?.notifier;
}
}
/// Dims and blocks pointer events on [child] while any Quick Add is open.
class QuickAddBlockable extends StatelessWidget {
const QuickAddBlockable({
super.key,
required this.child,
this.blockedOpacity = 0.45,
});
final Widget child;
final double blockedOpacity;
@override
Widget build(BuildContext context) {
final blocker = QuickAddBlockerScope.maybeOf(context);
if (blocker == null) return child;
return ListenableBuilder(
listenable: blocker,
builder: (context, _) {
final blocked = blocker.isBlocked;
return AbsorbPointer(
absorbing: blocked,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
opacity: blocked ? blockedOpacity : 1,
child: child,
),
);
},
);
}
}
/// Holds a full-width Quick Add form below a form row (so it stays clickable).
class QuickAddInlineController extends ChangeNotifier {
Object? _owner;
Widget? _form;
Widget Function()? _formBuilder;
QuickAddBlockerController? blocker;
Widget? get form => _form;
bool get isOpen => _formBuilder != null;
void present({required Object owner, required Widget form}) {
Widget? buildForm() => _formBuilder?.call();
void present({
required Object owner,
required Widget Function() formBuilder,
}) {
final wasClosed = _formBuilder == null;
_owner = owner;
_form = form;
_formBuilder = formBuilder;
if (wasClosed) blocker?.push();
notifyListeners();
}
void dismiss(Object owner) {
if (_owner != owner) return;
_owner = null;
_form = null;
notifyListeners();
_clear(notify: true);
}
void dismissAll() {
if (_owner == null && _form == null) return;
_clear(notify: true);
}
void _clear({required bool notify}) {
final wasOpen = _formBuilder != null;
if (_owner == null && _formBuilder == null) return;
_owner = null;
_form = null;
notifyListeners();
_formBuilder = null;
if (wasOpen) blocker?.pop();
if (notify) notifyListeners();
}
}
@ -46,6 +132,10 @@ class QuickAddInlineScope extends InheritedNotifier<QuickAddInlineController> {
}
/// Wraps a form row and renders any active Quick Add form at full row width.
///
/// While Quick Add is open, the parent row fields are dimmed and non-interactive.
/// Sibling fields / footer use [QuickAddBlockerScope] from [SidePanelScaffold]
/// (or a host-owned blocker) for the same lock behavior.
class QuickAddInlineHost extends StatefulWidget {
const QuickAddInlineHost({super.key, required this.child});
@ -57,41 +147,112 @@ class QuickAddInlineHost extends StatefulWidget {
class _QuickAddInlineHostState extends State<QuickAddInlineHost> {
final _controller = QuickAddInlineController();
QuickAddBlockerController? _ownedBlocker;
bool _wasOpen = false;
@override
void dispose() {
if (_wasOpen) {
_controller.blocker?.pop();
}
_ownedBlocker?.dispose();
_controller.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final parentBlocker = QuickAddBlockerScope.maybeOf(context);
if (parentBlocker != null) {
_controller.blocker = parentBlocker;
} else {
_ownedBlocker ??= QuickAddBlockerController();
_controller.blocker = _ownedBlocker;
}
}
@override
Widget build(BuildContext context) {
return QuickAddInlineScope(
final theme = Theme.of(context);
final blocker = _controller.blocker;
Widget host = QuickAddInlineScope(
controller: _controller,
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final form = _controller.form;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
widget.child,
AnimatedSize(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: form == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: form,
),
),
],
Widget buildColumn({required bool blockedByOther}) {
final form = _controller.buildForm();
final isOpen = form != null;
if (isOpen && !_wasOpen) {
_wasOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusManager.instance.primaryFocus?.unfocus();
});
} else if (!isOpen) {
_wasOpen = false;
}
final dimParent = isOpen || blockedByOther;
// Field dimming is handled by [QuickAddBlockable] inside form rows /
// sections. The host only blocks interaction as a safety net for
// custom hosts that wrap plain children (e.g. PO form grid).
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AbsorbPointer(
absorbing: dimParent,
child: widget.child,
),
AnimatedSize(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: form == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Material(
elevation: 2,
color: Colors.transparent,
shadowColor: theme.colorScheme.shadow
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
child: form,
),
),
),
],
);
}
if (blocker == null) {
return buildColumn(blockedByOther: false);
}
return ListenableBuilder(
listenable: blocker,
builder: (context, _) {
final blockedByOther =
!_controller.isOpen && blocker.isBlocked;
return buildColumn(blockedByOther: blockedByOther);
},
);
},
),
);
if (_ownedBlocker != null &&
QuickAddBlockerScope.maybeOf(context) == null) {
host = QuickAddBlockerScope(
controller: _ownedBlocker!,
child: host,
);
}
return host;
}
}
@ -162,7 +323,7 @@ void showSidePanelSnackBar(
);
}
class SidePanelScaffold extends StatelessWidget {
class SidePanelScaffold extends StatefulWidget {
const SidePanelScaffold({
super.key,
required this.title,
@ -176,9 +337,22 @@ class SidePanelScaffold extends StatelessWidget {
final Widget? footer;
final VoidCallback? onClose;
@override
State<SidePanelScaffold> createState() => _SidePanelScaffoldState();
}
class _SidePanelScaffoldState extends State<SidePanelScaffold> {
final _blocker = QuickAddBlockerController();
@override
void dispose() {
_blocker.dispose();
super.dispose();
}
void _close(BuildContext context) {
if (onClose != null) {
onClose!();
if (widget.onClose != null) {
widget.onClose!();
return;
}
Navigator.of(context, rootNavigator: true).pop();
@ -186,40 +360,77 @@ class SidePanelScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
return QuickAddBlockerScope(
controller: _blocker,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListenableBuilder(
listenable: _blocker,
builder: (context, _) {
final blocked = _blocker.isBlocked;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
child: Row(
children: [
Expanded(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: blocked ? 0.45 : 1,
child: Text(
widget.title,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
),
AbsorbPointer(
absorbing: blocked,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: blocked ? 0.45 : 1,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () => _close(context),
),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _close(context),
),
],
);
},
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: child,
),
),
if (footer != null) ...[
const Divider(height: 1),
Padding(padding: const EdgeInsets.all(24), child: footer),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: widget.child,
),
),
if (widget.footer != null) ...[
const Divider(height: 1),
ListenableBuilder(
listenable: _blocker,
builder: (context, _) {
final blocked = _blocker.isBlocked;
return AbsorbPointer(
absorbing: blocked,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: blocked ? 0.45 : 1,
child: Padding(
padding: const EdgeInsets.all(24),
child: widget.footer,
),
),
);
},
),
],
],
],
),
);
}
}
@ -234,6 +445,22 @@ class SidePanelSection extends StatelessWidget {
final String title;
final List<Widget> children;
/// Rows that already host Quick Add do not wrap or the inline form
/// would be blocked too.
static bool _hostsQuickAdd(Widget child) {
return child is SidePanelFormRow ||
child is FormRow ||
child is FormRowThree ||
child is FormRowFour ||
child is QuickAddInlineHost;
}
static Widget _blockable(Widget child) {
if (child is SizedBox && child.child == null) return child;
if (_hostsQuickAdd(child)) return child;
return QuickAddBlockable(child: child);
}
@override
Widget build(BuildContext context) {
final dividerColor =
@ -242,18 +469,25 @@ class SidePanelSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
QuickAddBlockable(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Divider(height: 1, color: dividerColor),
],
),
),
const SizedBox(height: 8),
Divider(height: 1, color: dividerColor),
const SizedBox(height: 12),
...children,
...children.map(_blockable),
const SizedBox(height: 16),
],
);
@ -275,36 +509,38 @@ class SidePanelFormRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QuickAddInlineHost(
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 420;
child: QuickAddBlockable(
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 420;
if (stack) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
left,
const SizedBox(height: 12),
right,
],
),
);
}
if (stack) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
left,
const SizedBox(height: 12),
right,
Expanded(child: left),
SizedBox(width: spacing),
Expanded(child: right),
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
SizedBox(width: spacing),
Expanded(child: right),
],
),
);
},
},
),
),
);
}
@ -499,66 +735,68 @@ class FormRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QuickAddInlineHost(
child: LayoutBuilder(
builder: (context, constraints) {
final contentPadding = EdgeInsets.fromLTRB(
horizontalPadding,
0,
horizontalPadding,
spacing,
);
if (constraints.maxWidth < stackBelowWidth) {
return Padding(
padding: contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(height: spacing),
children[i],
],
],
),
child: QuickAddBlockable(
child: LayoutBuilder(
builder: (context, constraints) {
final contentPadding = EdgeInsets.fromLTRB(
horizontalPadding,
0,
horizontalPadding,
spacing,
);
}
if (columnCount != null) {
final slots = _columnSlots();
final cols = columnCount!;
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
if (constraints.maxWidth < stackBelowWidth) {
return Padding(
padding: contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(height: spacing),
children[i],
],
],
),
);
}
if (columnCount != null) {
final slots = _columnSlots();
final cols = columnCount!;
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < slots.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
SizedBox(
width: _spanWidth(columnWidth, slots[i].span),
child: slots[i].child,
),
],
],
),
);
}
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < slots.length; i++) ...[
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
SizedBox(
width: _spanWidth(columnWidth, slots[i].span),
child: slots[i].child,
),
Expanded(child: children[i]),
],
],
),
);
}
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
Expanded(child: children[i]),
],
],
),
);
},
},
),
),
);
}

View File

@ -58,12 +58,29 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
final Set<String> _manuallyCollapsedMenus = <String>{};
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
.where((item) => !menu.supportMenuRoutes.contains(item.route))
.where((item) => !menu.isSupportMenuItem(item))
.toList();
List<menu.MenuItem> get _supportMenuItems => widget.menuItems
.where((item) => menu.supportMenuRoutes.contains(item.route))
.toList();
/// Sidebar shows Support children flat under the SUPPORT section label.
/// Top nav keeps the Support parent as a dropdown (via [menuItems] as-is).
List<menu.MenuItem> get _supportMenuItems {
final items = <menu.MenuItem>[];
for (final item in widget.menuItems) {
if (menu.isSupportMenuItem(item)) {
if (item.children.isNotEmpty) {
items.addAll(item.children);
} else {
items.add(item);
}
continue;
}
if (menu.supportMenuRoutes.contains(item.route) &&
item.children.isEmpty) {
items.add(item);
}
}
return items;
}
bool get _hasSupportSection =>
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;