added product import and billing integration
This commit is contained in:
@@ -126,7 +126,14 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
);
|
||||
}
|
||||
|
||||
void signOut() => state = const Unauthenticated();
|
||||
/// The next shift should only ever bill against what the back office
|
||||
/// answers with, never a catalogue instance carried over from this
|
||||
/// session — so the local product table is dropped before the session
|
||||
/// itself is.
|
||||
Future<void> signOut() async {
|
||||
await _ref.read(localStoreProvider).clearCatalogue();
|
||||
state = const Unauthenticated();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (state is AuthFailure) state = const Unauthenticated();
|
||||
|
||||
@@ -106,14 +106,7 @@ class _BrandPanel extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Flexible(
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
@@ -24,14 +23,6 @@ class CustomersView extends ConsumerStatefulWidget {
|
||||
|
||||
class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
String _query = '';
|
||||
MembershipTier? _tier;
|
||||
|
||||
Color _tierColor(MembershipTier t) => switch (t) {
|
||||
MembershipTier.bronze => AppColors.tierBronze,
|
||||
MembershipTier.silver => AppColors.tierSilver,
|
||||
MembershipTier.gold => AppColors.tierGold,
|
||||
MembershipTier.platinum => AppColors.tierPlatinum,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -39,10 +30,9 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
|
||||
final filtered = all.where((c) {
|
||||
final q = _query.trim().toLowerCase();
|
||||
final matchesQuery = q.isEmpty ||
|
||||
return q.isEmpty ||
|
||||
c.name.toLowerCase().contains(q) ||
|
||||
c.mobile.contains(q);
|
||||
return matchesQuery && (_tier == null || c.tier == _tier);
|
||||
}).toList();
|
||||
|
||||
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
|
||||
@@ -85,26 +75,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Tier distribution',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final t in MembershipTier.values)
|
||||
ProgressRow(
|
||||
label: '${t.label} · '
|
||||
'${(t.discountRate * 100).toStringAsFixed(0)}% off',
|
||||
value: '${all.where((c) => c.tier == t).length}',
|
||||
fraction: all.isEmpty
|
||||
? 0
|
||||
: all.where((c) => c.tier == t).length / all.length,
|
||||
color: _tierColor(t),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Customer book',
|
||||
subtitle: '${filtered.length} shown',
|
||||
@@ -134,45 +104,11 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('All tiers'),
|
||||
selected: _tier == null,
|
||||
onSelected: (_) => setState(() => _tier = null),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _tier == null
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
for (final t in MembershipTier.values)
|
||||
ChoiceChip(
|
||||
label: Text(t.label),
|
||||
selected: _tier == t,
|
||||
onSelected: (_) =>
|
||||
setState(() => _tier = _tier == t ? null : t),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _tier == t
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Customer', flex: 4),
|
||||
TableCol('Mobile', flex: 3, priority: 1),
|
||||
TableCol('Tier', flex: 2),
|
||||
TableCol('Points', flex: 2, numeric: true, priority: 1),
|
||||
TableCol('Lifetime', flex: 2, numeric: true),
|
||||
TableCol('Visits', flex: 2, numeric: true, priority: 1),
|
||||
@@ -199,7 +135,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
],
|
||||
),
|
||||
Cell(Formatters.mobile(c.mobile), mono: true),
|
||||
StatusPill.tier(c.tier, dense: true),
|
||||
Cell('${c.loyaltyPoints}', mono: true),
|
||||
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
||||
mono: true, bold: true,),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../data/sync/sync_engine.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
@@ -26,10 +28,21 @@ class EventsView extends ConsumerWidget {
|
||||
final syncState = ref.watch(orderSyncProvider);
|
||||
final events = ref.watch(syncEventsProvider);
|
||||
|
||||
// The engine may not have emitted yet on a cold start, so fall back to
|
||||
// its current value rather than showing nothing. This is the same state
|
||||
// that drives the header pill — it is what actually knows whether the
|
||||
// automatic push right after a sale succeeded, not just what the manual
|
||||
// "Sync" button on this page last did.
|
||||
final engine = ref.watch(syncEngineStateProvider).value ??
|
||||
ref.watch(syncEngineProvider).state;
|
||||
|
||||
final r = report.value;
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
if (engine.lastError != null && pending > 0)
|
||||
_EngineWarningBanner(engine: engine),
|
||||
|
||||
Wrap(
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.lg,
|
||||
@@ -310,3 +323,81 @@ class EventsView extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flags a bill that could not reach the server on its own — the automatic
|
||||
/// push right after checkout, not the manual "Sync" button below.
|
||||
///
|
||||
/// Sits above everything else on the page because a bill stuck here is the
|
||||
/// one thing on this screen that needs a person to notice it, rather than
|
||||
/// just waiting for the next background retry.
|
||||
class _EngineWarningBanner extends StatelessWidget {
|
||||
const _EngineWarningBanner({required this.engine});
|
||||
|
||||
final SyncEngineState engine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final halted = engine.isHalted;
|
||||
final color = halted ? AppColors.danger : AppColors.warning;
|
||||
final surface = halted ? AppColors.dangerSurface : AppColors.warningSurface;
|
||||
|
||||
final retry = engine.nextAttemptAt;
|
||||
final retryNote = halted
|
||||
? 'Retrying will not help until this is fixed — press Sync below '
|
||||
'once it is sorted.'
|
||||
: retry == null
|
||||
? 'It will retry automatically, or press Sync below to try now.'
|
||||
: 'It will retry automatically at ${Formatters.time(retry)}, or '
|
||||
'press Sync below to try now.';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(color: surface, borderRadius: AppRadius.brLg),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.wifi_off_rounded, size: 20, color: color),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
halted
|
||||
? 'Sync halted — ${engine.pending} bill(s) not sent'
|
||||
: "Couldn't reach the server — "
|
||||
'${engine.pending} bill(s) not sent',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
engine.lastError ?? 'The last upload attempt failed.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: color,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$retryNote Every bill is still safe on this terminal — '
|
||||
'nothing is lost while it waits.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../data/sync/sync_engine.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/usecases/checkout_sale.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
@@ -80,29 +79,27 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
|
||||
/// Whether Complete Sale should be enabled.
|
||||
///
|
||||
/// Cash is the strict case: the drawer cannot be reconciled and no change can
|
||||
/// be calculated unless the cashier states what was handed over. Card, UPI
|
||||
/// and wallet settle on the external terminal, so they need no amount here.
|
||||
/// Every method now requires the cashier to state what was actually
|
||||
/// received before the sale can complete — a tap on Exact for the common
|
||||
/// case, or a typed amount for a partial tender. `cashTendered` is the
|
||||
/// amount entered for whichever method is currently active, not literally
|
||||
/// cash; the name stayed to keep this change out of the rest of the app.
|
||||
bool get canConfirm {
|
||||
if (_billTotal <= 0) return false;
|
||||
|
||||
// Staged splits already cover the bill.
|
||||
if (balanceDue <= 0.01) return true;
|
||||
|
||||
if (state.activeMethod.needsChange) {
|
||||
return state.cashTendered >= balanceDue;
|
||||
}
|
||||
return true;
|
||||
return state.cashTendered >= balanceDue;
|
||||
}
|
||||
|
||||
/// Why the button is disabled, for display next to it.
|
||||
String? get blockedReason {
|
||||
if (_billTotal <= 0) return 'Add at least one item before charging.';
|
||||
if (canConfirm) return null;
|
||||
if (state.activeMethod.needsChange) {
|
||||
return 'Enter the cash received, or tap Exact.';
|
||||
}
|
||||
return null;
|
||||
return state.activeMethod.needsChange
|
||||
? 'Enter the cash received, or tap Exact.'
|
||||
: 'Enter the amount received, or tap Exact.';
|
||||
}
|
||||
|
||||
void selectMethod(PaymentMethod method) {
|
||||
@@ -212,10 +209,11 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
_ref.invalidate(visibleProductsProvider);
|
||||
_ref.read(orderVersionProvider.notifier).state++;
|
||||
|
||||
// The bill is safely on disk; getting it to the back office is the
|
||||
// engine's problem now. Deliberately not awaited — the cashier must
|
||||
// reach the receipt screen at network speed of zero.
|
||||
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
|
||||
// Deliberately NOT nudging the sync engine here. The bill sits on this
|
||||
// terminal, unsynced, until the receipt screen's cancellation window
|
||||
// closes — either it is pressed past early with New Sale, or the
|
||||
// window runs out — or the sale is voided if the cashier cancels
|
||||
// instead. See ReceiptScreen.
|
||||
|
||||
return result;
|
||||
} on CheckoutFailure catch (e) {
|
||||
|
||||
@@ -350,20 +350,41 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: state.activeMethod.needsChange
|
||||
? _cashTender(controller)
|
||||
: _referenceTender(controller, state),
|
||||
child: _amountTender(controller, state),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cashTender(PaymentController controller) {
|
||||
/// Amount entry for whichever method is active. Every method works the
|
||||
/// same way now — a keypad, an Exact shortcut, and an explicit amount —
|
||||
/// rather than cash alone asking what was received while card/UPI/wallet
|
||||
/// silently assumed the full balance. Cash additionally gets denomination
|
||||
/// chips and a change-due row, since only cash can be over-tendered; a
|
||||
/// method that captures a reference (card, UPI, gift card) additionally
|
||||
/// gets that field below the keypad.
|
||||
Widget _amountTender(PaymentController controller, PaymentState state) {
|
||||
final cash = state.activeMethod.needsChange;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Cash received',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
Row(
|
||||
children: [
|
||||
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
cash
|
||||
? 'Cash received'
|
||||
: '${state.activeMethod.label} amount received',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Container(
|
||||
@@ -405,16 +426,20 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
label: const Text('Exact'),
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
),
|
||||
for (final note in const [50, 100, 200, 500, 2000])
|
||||
ActionChip(
|
||||
label: Text('₹$note'),
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
),
|
||||
// Denomination shortcuts only make sense for physical notes.
|
||||
if (cash)
|
||||
for (final note in const [50, 100, 200, 500, 2000])
|
||||
ActionChip(
|
||||
label: Text('₹$note'),
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_changeRow(controller.changeDue),
|
||||
if (cash) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_changeRow(controller.changeDue),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
@@ -424,6 +449,21 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
onBackspace: _backspaceCash,
|
||||
),
|
||||
),
|
||||
if (state.activeMethod.needsReference) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
onChanged: controller.setReference,
|
||||
decoration: InputDecoration(
|
||||
labelText: switch (state.activeMethod) {
|
||||
PaymentMethod.card => 'Approval code',
|
||||
PaymentMethod.upi => 'UPI transaction ID',
|
||||
PaymentMethod.giftCard => 'Gift card number',
|
||||
_ => 'Reference',
|
||||
},
|
||||
prefixIcon: const Icon(Icons.tag_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
||||
],
|
||||
@@ -473,71 +513,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _referenceTender(PaymentController controller, PaymentState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 20),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${state.activeMethod.label} payment',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 104,
|
||||
height: 104,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 46),),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Charge ${Formatters.money(controller.balanceDue)} on the '
|
||||
'${state.activeMethod.label.toLowerCase()} terminal',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
if (state.activeMethod.needsReference)
|
||||
TextField(
|
||||
onChanged: controller.setReference,
|
||||
decoration: InputDecoration(
|
||||
labelText: switch (state.activeMethod) {
|
||||
PaymentMethod.card => 'Approval code',
|
||||
PaymentMethod.upi => 'UPI transaction ID',
|
||||
PaymentMethod.giftCard => 'Gift card number',
|
||||
_ => 'Reference',
|
||||
},
|
||||
prefixIcon: const Icon(Icons.tag_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_splitButton(controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _splitButton(PaymentController controller, {double? amount}) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: controller.balanceDue > 0
|
||||
@@ -622,18 +597,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.activeMethod.needsChange)
|
||||
TextButton(
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 30),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 30),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
),
|
||||
child: const Text('Exact',
|
||||
style: TextStyle(fontSize: 12.5),),
|
||||
),
|
||||
child: const Text('Exact',
|
||||
style: TextStyle(fontSize: 12.5),),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -302,6 +302,15 @@ class CartController extends StateNotifier<Cart> {
|
||||
}
|
||||
|
||||
Future<void> resume(ParkedBill bill) async {
|
||||
// Resuming a bill on top of an already-active cart would otherwise
|
||||
// silently overwrite whatever was already there — picking a second
|
||||
// parked bill while the first one's items are still sitting in the
|
||||
// cart, unsaved. Same hole startNewSale closes for the header button;
|
||||
// this closes it here by parking what's active first.
|
||||
if (state.isNotEmpty) {
|
||||
await park(label: 'Auto-parked — replaced by resuming another bill');
|
||||
}
|
||||
|
||||
await _transactions.removeParked(bill.id);
|
||||
_undoStack.clear();
|
||||
// Re-evaluated rather than restored: a campaign that has since ended must
|
||||
@@ -309,6 +318,32 @@ class CartController extends StateNotifier<Cart> {
|
||||
_commit(bill.cart);
|
||||
}
|
||||
|
||||
/// What the header's "New Sale" button actually calls.
|
||||
///
|
||||
/// A cashier can always start over — that's the whole point of the escape
|
||||
/// hatch — but silently wiping a non-empty cart here would be the exact
|
||||
/// same hole the removal PIN closes: scan an item, then abandon the cart
|
||||
/// instead of removing that one line, and it disappears just the same,
|
||||
/// with nobody having approved anything. So a non-empty cart is parked,
|
||||
/// not discarded — visible and resumable from Parked bills — and only an
|
||||
/// already-empty cart takes the plain reset path.
|
||||
Future<void> startNewSale() async {
|
||||
if (state.isNotEmpty) {
|
||||
await park(label: 'Auto-parked — new sale started with items still in cart');
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
/// Puts a cart back exactly as it was just before a sale that has since
|
||||
/// been voided, so cancelling a completed bill doesn't make the cashier
|
||||
/// re-scan everything. Promos are re-evaluated for the same reason
|
||||
/// [resume] re-evaluates them, not restored verbatim.
|
||||
void restore(Cart cart) {
|
||||
_undoStack.clear();
|
||||
_commit(cart);
|
||||
}
|
||||
|
||||
List<CartLine> _replace(CartLine updated) => [
|
||||
for (final l in state.lines)
|
||||
if (l.product.id == updated.product.id) updated else l,
|
||||
|
||||
@@ -41,8 +41,8 @@ class PosView extends ConsumerWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const CustomerBar(),
|
||||
const Divider(height: 1),
|
||||
|
||||
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
|
||||
|
||||
197
lib/presentation/pos/widgets/admin_pin_dialog.dart
Normal file
197
lib/presentation/pos/widgets/admin_pin_dialog.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/numeric_keypad.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
|
||||
/// Prompts for an admin PIN before a theft-sensitive cart action — taking a
|
||||
/// scanned item back out of the bill, or clearing it — and resolves `true`
|
||||
/// only once a PIN belonging to an [StaffRole.admin] account is verified.
|
||||
///
|
||||
/// A cashier can always start a brand new sale; what this exists to stop is
|
||||
/// quietly taking something back out of a bill a customer has already been
|
||||
/// shown, after it was rung up.
|
||||
Future<bool> requireAdminPin(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String reason,
|
||||
}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _AdminPinDialog(reason: reason),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
class _AdminPinDialog extends ConsumerStatefulWidget {
|
||||
const _AdminPinDialog({required this.reason});
|
||||
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
ConsumerState<_AdminPinDialog> createState() => _AdminPinDialogState();
|
||||
}
|
||||
|
||||
class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
||||
String _pin = '';
|
||||
String? _error;
|
||||
bool _checking = false;
|
||||
|
||||
void _key(String digit) {
|
||||
if (_checking || _pin.length >= 8) return;
|
||||
setState(() {
|
||||
_pin += digit;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _backspace() {
|
||||
if (_checking || _pin.isEmpty) return;
|
||||
setState(() => _pin = _pin.substring(0, _pin.length - 1));
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
if (_checking) return;
|
||||
setState(() => _pin = '');
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_pin.isEmpty || _checking) return;
|
||||
setState(() {
|
||||
_checking = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final store = ref.read(localStoreProvider);
|
||||
final user = await store.staff.authenticate(_pin);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (user == null) {
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_error = 'Incorrect PIN.';
|
||||
_pin = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role != StaffRole.admin) {
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_error = "${user.name}'s PIN is ${user.role.label.toLowerCase()} — "
|
||||
'this needs an admin.';
|
||||
_pin = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brLg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_outline_rounded,
|
||||
color: AppColors.danger, size: 20,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Admin PIN required',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
borderRadius: AppRadius.brSm,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(Icons.close_rounded,
|
||||
size: 20, color: AppColors.textSecondary,),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
widget.reason,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
height: 20,
|
||||
child: _pin.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Enter PIN',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 10,
|
||||
children: [
|
||||
for (var i = 0; i < _pin.length; i++)
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
NumericKeypad(
|
||||
onKey: _key,
|
||||
onBackspace: _backspace,
|
||||
onClear: _clear,
|
||||
onSubmit: _checking ? null : _submit,
|
||||
submitLabel: _checking ? 'Checking…' : 'Approve',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
@@ -15,6 +16,7 @@ import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/cart.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
import 'admin_pin_dialog.dart';
|
||||
import 'cart_line_tile.dart';
|
||||
import 'discount_sheet.dart';
|
||||
|
||||
@@ -58,7 +60,12 @@ class BillingPanel extends ConsumerWidget {
|
||||
line: line,
|
||||
onIncrement: () => controller.increment(line.product.id),
|
||||
onDecrement: () => controller.decrement(line.product.id),
|
||||
onRemove: () => controller.removeLine(line.product.id),
|
||||
onRemove: () => _removeLine(
|
||||
context,
|
||||
ref,
|
||||
controller,
|
||||
line.product.id,
|
||||
),
|
||||
onDiscount: () =>
|
||||
showLineDiscountSheet(context, ref, line),
|
||||
);
|
||||
@@ -72,6 +79,37 @@ class BillingPanel extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once an item is on the bill, taking it back off needs an admin's
|
||||
/// approval — a cashier can always start an entirely new sale instead. This
|
||||
/// is the one gate both removal paths (a single line, or the whole cart) go
|
||||
/// through, so they can never drift out of sync with each other.
|
||||
Future<void> _removeLine(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
CartController controller,
|
||||
String productId,
|
||||
) async {
|
||||
final ok = await requireAdminPin(
|
||||
context,
|
||||
ref,
|
||||
reason: 'Removing a scanned item from the bill needs admin approval.',
|
||||
);
|
||||
if (ok) controller.removeLine(productId);
|
||||
}
|
||||
|
||||
Future<void> _clearCart(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
CartController controller,
|
||||
) async {
|
||||
final ok = await requireAdminPin(
|
||||
context,
|
||||
ref,
|
||||
reason: 'Clearing the whole bill needs admin approval.',
|
||||
);
|
||||
if (ok) controller.clear();
|
||||
}
|
||||
|
||||
class _Header extends ConsumerWidget {
|
||||
const _Header({required this.cart, required this.inSheet});
|
||||
|
||||
@@ -81,15 +119,15 @@ class _Header extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = ref.read(cartControllerProvider.notifier);
|
||||
// Same fixed height as the page header on the left, so the two bars
|
||||
// line up on one visual line instead of the cart title floating lower.
|
||||
final contentPadding = PosLayout.of(context).contentPadding;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.md,
|
||||
),
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||
padding: EdgeInsets.symmetric(horizontal: contentPadding),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
@@ -99,9 +137,9 @@ class _Header extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brPill,
|
||||
@@ -116,40 +154,52 @@ class _Header extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
SizedBox(),
|
||||
SizedBox(),
|
||||
|
||||
Spacer(),
|
||||
|
||||
|
||||
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
||||
if (controller.canUndo)
|
||||
_IconAction(
|
||||
icon: Icons.undo_rounded,
|
||||
tooltip: 'Undo (F8)',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: controller.undo,
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
_IconAction(
|
||||
icon: Icons.pause_circle_outline_rounded,
|
||||
tooltip: 'Park bill',
|
||||
color: AppColors.warning,
|
||||
onTap: () async {
|
||||
await controller.park();
|
||||
ref.invalidate(parkedBillsProvider);
|
||||
if (context.mounted) context.showSnack('Bill parked');
|
||||
},
|
||||
),
|
||||
_IconAction(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
tooltip: 'Clear bill',
|
||||
color: AppColors.danger,
|
||||
onTap: controller.clear,
|
||||
),
|
||||
],
|
||||
if (inSheet)
|
||||
_IconAction(
|
||||
icon: Icons.close_rounded,
|
||||
tooltip: 'Close',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
// Grouped tight with even spacing, flush against the same right
|
||||
// edge the header buttons on the left use.
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (controller.canUndo)
|
||||
_IconAction(
|
||||
icon: Icons.undo_rounded,
|
||||
tooltip: 'Undo (F8)',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: controller.undo,
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
_IconAction(
|
||||
icon: Icons.pause_circle_outline_rounded,
|
||||
tooltip: 'Park bill',
|
||||
color: AppColors.warning,
|
||||
onTap: () async {
|
||||
await controller.park();
|
||||
ref.invalidate(parkedBillsProvider);
|
||||
if (context.mounted) context.showSnack('Bill parked');
|
||||
},
|
||||
),
|
||||
_IconAction(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
tooltip: 'Clear bill',
|
||||
color: AppColors.danger,
|
||||
onTap: () => _clearCart(context, ref, controller),
|
||||
),
|
||||
],
|
||||
if (inSheet)
|
||||
_IconAction(
|
||||
icon: Icons.close_rounded,
|
||||
tooltip: 'Close',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -360,7 +410,9 @@ class _Row extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
||||
child: Row(children: [
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
@@ -390,7 +442,7 @@ class _Row extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (trailingIcon != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
|
||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||
],
|
||||
],),
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
|
||||
/// Strip above the product grid showing who the sale belongs to.
|
||||
class CustomerBar extends ConsumerWidget {
|
||||
const CustomerBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final customer = ref.watch(
|
||||
cartControllerProvider.select((cart) => cart.customer),
|
||||
);
|
||||
|
||||
return Container(
|
||||
// A hard height clipped the subtitle once it wrapped. Minimum height
|
||||
// keeps the strip its usual size but lets it grow if it must.
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSizes.customerBarHeight,
|
||||
),
|
||||
color: AppColors.surface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: Row(children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: customer == null
|
||||
? AppColors.border
|
||||
: AppColors.primarySurface,
|
||||
child: customer == null
|
||||
? const Icon(Icons.directions_walk_rounded,
|
||||
size: 20, color: AppColors.textSecondary,)
|
||||
: Text(
|
||||
Formatters.initials(customer.name),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
customer?.name ?? 'Walk-in Customer',
|
||||
style: context.text.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (customer != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
StatusPill.tier(customer.tier, dense: true),
|
||||
],
|
||||
],),
|
||||
if (customer != null)
|
||||
Text(
|
||||
'${Formatters.mobile(customer.mobile)} · '
|
||||
'${customer.loyaltyPoints} pts',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.text.bodySmall,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'No loyalty tracking for this sale',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.text.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (customer != null)
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(cartControllerProvider.notifier).attachCustomer(null),
|
||||
icon: const Icon(Icons.person_off_outlined, size: 17),
|
||||
label: const Text('Detach'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => showCustomerCaptureSheet(context),
|
||||
icon: const Icon(Icons.sync_alt_rounded, size: 17),
|
||||
label: Text(customer == null ? 'Add Customer' : 'Change'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
),
|
||||
),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,28 +68,7 @@ class PageHeader extends ConsumerWidget {
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
module.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
if (!compact) _Breadcrumb(module: module),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (showStatus) ...[
|
||||
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
||||
@@ -117,42 +96,6 @@ class PageHeader extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Breadcrumb extends StatelessWidget {
|
||||
const _Breadcrumb({required this.module});
|
||||
|
||||
final PosModule module;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const style = TextStyle(fontSize: 12, color: AppColors.textTertiary);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Home', style: style),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
|
||||
child: Icon(Icons.chevron_right_rounded,
|
||||
size: 13, color: AppColors.textTertiary,),
|
||||
),
|
||||
Text(module.section.label, style: style),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
|
||||
child: Icon(Icons.chevron_right_rounded,
|
||||
size: 13, color: AppColors.textTertiary,),
|
||||
),
|
||||
Text(
|
||||
module.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the pill is saying, in the order it takes precedence.
|
||||
enum _Liveness { offlineSim, halted, syncing, queued, live }
|
||||
@@ -342,11 +285,24 @@ class _ParkedBillsButton extends ConsumerWidget {
|
||||
'${Formatters.time(bill.parkedAt)}',
|
||||
),
|
||||
onTap: () async {
|
||||
final hadItems =
|
||||
ref.read(cartControllerProvider).isNotEmpty;
|
||||
await ref
|
||||
.read(cartControllerProvider.notifier)
|
||||
.resume(bill);
|
||||
ref.invalidate(parkedBillsProvider);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
if (hadItems) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'The cart you were on was saved back to Parked '
|
||||
'bills.',
|
||||
),
|
||||
),);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -370,12 +326,22 @@ class _NewSaleButton extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void start() {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
Future<void> start() async {
|
||||
final hadItems = ref.read(cartControllerProvider).isNotEmpty;
|
||||
await ref.read(cartControllerProvider.notifier).startNewSale();
|
||||
if (hadItems) ref.invalidate(parkedBillsProvider);
|
||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(content: Text('Started a new sale.')));
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
hadItems
|
||||
? 'Previous cart saved to Parked bills. Started a new sale.'
|
||||
: 'Started a new sale.',
|
||||
),
|
||||
),);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
|
||||
@@ -84,9 +84,10 @@ class _ProductCardState extends State<ProductCard> {
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
child: Text(
|
||||
p.emoji,
|
||||
style: TextStyle(fontSize: emoji),
|
||||
child: _ProductVisual(
|
||||
imageUrl: p.imageUrl,
|
||||
emoji: p.emoji,
|
||||
size: emoji,
|
||||
),
|
||||
),
|
||||
SizedBox(height: tight ? 2 : AppSpacing.sm),
|
||||
@@ -252,3 +253,62 @@ class _ProductCardState extends State<ProductCard> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the catalogue's product photo when the imported record has one,
|
||||
/// otherwise falls back to the emoji.
|
||||
///
|
||||
/// Today's imports don't carry `image_url` yet, so the emoji path is still
|
||||
/// the common case — this quietly takes over per product once the back
|
||||
/// office starts sending photos, with nothing else on the card changing.
|
||||
class _ProductVisual extends StatelessWidget {
|
||||
const _ProductVisual({
|
||||
required this.imageUrl,
|
||||
required this.emoji,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final String emoji;
|
||||
|
||||
/// Matches the emoji font size the caller computed for this tile, so the
|
||||
/// two are visually interchangeable.
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = imageUrl;
|
||||
if (url == null || url.isEmpty) {
|
||||
return Text(emoji, style: TextStyle(fontSize: size));
|
||||
}
|
||||
|
||||
final box = size * 1.7;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: box,
|
||||
height: box,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return SizedBox(
|
||||
width: box,
|
||||
height: box,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// A missing or unreachable photo falls back to the emoji rather than
|
||||
// Flutter's default broken-image icon, so one bad URL in a catalogue
|
||||
// of thousands never leaves a tile looking broken.
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Text(emoji, style: TextStyle(fontSize: size)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,22 @@ import '../../../core/utils/extensions.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/glass_card.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../data/sync/sync_engine.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../modules/providers/printer_settings.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/receipt_preview.dart';
|
||||
|
||||
/// Confirmation screen. Counts down and starts the next sale on its own so an
|
||||
/// unattended terminal never sits on a finished bill.
|
||||
/// Confirmation screen.
|
||||
///
|
||||
/// The bill is written to SQLite the instant checkout completes, but is
|
||||
/// deliberately held back from the server for [AppConstants.postSaleResetDelay]
|
||||
/// — long enough to catch a mistake. Counts down and starts the next sale (and
|
||||
/// releases the bill to the sync engine) on its own so an unattended terminal
|
||||
/// never sits on a finished bill forever; "Cancel sale" inside the window
|
||||
/// voids it instead and hands the cart straight back.
|
||||
class ReceiptScreen extends ConsumerStatefulWidget {
|
||||
const ReceiptScreen({super.key, required this.transaction});
|
||||
|
||||
@@ -32,9 +41,13 @@ class ReceiptScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5;
|
||||
late int _seconds = AppConstants.postSaleResetDelay.inSeconds;
|
||||
Timer? _timer;
|
||||
|
||||
/// True while a cancellation is being written to disk — guards against a
|
||||
/// second tap voiding a sale that is already half-reversed.
|
||||
bool _voiding = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -82,13 +95,36 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
|
||||
void _newSale() {
|
||||
_timer?.cancel();
|
||||
// This is the one moment the bill is actually released to the server —
|
||||
// whether the window ran out on its own or New Sale was pressed early,
|
||||
// the cashier has let this bill stand.
|
||||
ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
if (mounted) context.go(AppRoutes.pos);
|
||||
}
|
||||
|
||||
void _continueBilling() {
|
||||
/// Undoes the sale: deletes the order, puts the stock back, restores an
|
||||
/// attached shopper's loyalty balance, and hands the exact same cart back
|
||||
/// to the billing screen. Never touches the sync engine — this bill must
|
||||
/// never reach the server.
|
||||
Future<void> _cancelSale() async {
|
||||
if (_voiding) return;
|
||||
setState(() => _voiding = true);
|
||||
_timer?.cancel();
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
|
||||
final txn = widget.transaction;
|
||||
await ref.read(transactionRepositoryProvider).voidSale(
|
||||
transaction: txn,
|
||||
stockMovements: {
|
||||
for (final line in txn.cart.lines) line.product.id: line.quantity,
|
||||
},
|
||||
);
|
||||
|
||||
ref.invalidate(allProductsProvider);
|
||||
ref.invalidate(visibleProductsProvider);
|
||||
ref.read(orderVersionProvider.notifier).state++;
|
||||
ref.read(cartControllerProvider.notifier).restore(txn.cart);
|
||||
|
||||
if (mounted) context.go(AppRoutes.pos);
|
||||
}
|
||||
|
||||
@@ -293,12 +329,23 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
: 'New Sale',
|
||||
icon: Icons.add_shopping_cart_rounded,
|
||||
large: true,
|
||||
onPressed: _newSale,
|
||||
onPressed: _voiding ? null : _newSale,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
TextButton(
|
||||
onPressed: _continueBilling,
|
||||
child: const Text('Back to billing screen'),
|
||||
TextButton.icon(
|
||||
onPressed: _voiding ? null : _cancelSale,
|
||||
icon: _voiding
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.undo_rounded, size: 16),
|
||||
label: Text(_voiding ? 'Cancelling…' : 'Cancel sale'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../providers/sync_controller.dart';
|
||||
|
||||
@@ -35,9 +36,21 @@ class _SignOutDialog extends ConsumerStatefulWidget {
|
||||
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
SyncOutcome? _result;
|
||||
|
||||
void _finish() {
|
||||
Future<void> _finish() async {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
ref.read(authControllerProvider.notifier).signOut();
|
||||
await ref.read(authControllerProvider.notifier).signOut();
|
||||
|
||||
// Mirrors what a successful import does on the way in: bump the version
|
||||
// so catalogueReadyProvider re-reads hasCatalogue as false, and drop the
|
||||
// cached product lists so the next session's grid doesn't flash this
|
||||
// session's now-cleared data before it re-fetches.
|
||||
ref.read(catalogueVersionProvider.notifier).state++;
|
||||
ref.invalidate(allProductsProvider);
|
||||
ref.invalidate(visibleProductsProvider);
|
||||
ref.invalidate(categoryCountsProvider);
|
||||
ref.invalidate(lowStockProductsProvider);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
context.go(AppRoutes.login);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user