pos changes

This commit is contained in:
2026-08-06 19:26:53 +05:30
parent cb065a0f69
commit eebd10da6d
42 changed files with 3451 additions and 2531 deletions

View File

@@ -5,18 +5,16 @@ 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';
import '../widgets/module_widgets.dart';
/// End-of-day sync.
/// What this terminal has traded, and what has reached the server.
///
/// Shows what the terminal produced today and uploads every bill still at
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
/// and is retried on the next tap.
/// Read-only. Uploading is the sync engine's job — it pushes after every sale
/// and retries on its own — so this page reports rather than drives. The
/// warning banner at the top is the exception: a bill the engine has given up
/// on is the one thing here that needs a person to notice it.
class EventsView extends ConsumerWidget {
const EventsView({super.key});
@@ -25,8 +23,6 @@ class EventsView extends ConsumerWidget {
final report = ref.watch(todayReportProvider);
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
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
@@ -83,118 +79,30 @@ class EventsView extends ConsumerWidget {
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Upload bills to server',
subtitle: r == null
? 'Reading today\u2019s trading from SQLite\u2026'
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
'\u00b7 ${r.terminalId}',
action: TagChip(
pending > 0 ? '$pending pending' : 'All synced',
color: pending > 0 ? AppColors.warning : AppColors.success,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (r != null && !r.isEmpty) ...[
_row('Bills', '${r.billCount}'),
_row('Items sold', r.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(r.grossSales)),
_row('GST collected', Formatters.money(r.taxCollected)),
_row('Discount given', Formatters.money(r.discountGiven)),
_row('Average basket', Formatters.money(r.averageBasket)),
if (r.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
for (final e in r.paymentBreakdown.entries)
ProgressRow(
label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value),
fraction:
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
color: _methodColor(e.key),
),
],
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncRunning) ...[
Text(
syncState.stage,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.brPill,
child: LinearProgressIndicator(
value: syncState.progress,
minHeight: 8,
backgroundColor: AppColors.divider,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.primary),
),
),
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncFinished)
_outcomeBanner(syncState.outcome),
PrimaryButton(
label: pending > 0
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
: 'Nothing to sync',
icon: Icons.cloud_upload_rounded,
large: true,
busy: syncState is SyncRunning,
onPressed: pending == 0
? null
: () => ref.read(orderSyncProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.shield_outlined,
size: 15, color: AppColors.textTertiary,),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Bills are written to SQLite the moment a sale '
'completes. A failed upload changes nothing on disk — '
'every bill stays until the server confirms it.',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
height: 1.5,
),
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Orders',
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
child: ResponsiveTable(
columns: const [
TableCol('Invoice', flex: 3),
TableCol('Time', flex: 2, priority: 1),
TableCol('Date & time', flex: 3, priority: 1),
TableCol('Items', flex: 1, numeric: true, priority: 2),
TableCol('Total', flex: 2, numeric: true),
TableCol('Sync', flex: 2, numeric: true),
],
rows: rows
.map((o) => [
Cell(o.invoiceNumber, bold: true, mono: true),
Cell(Formatters.time(o.createdAt),
color: AppColors.textTertiary,),
// Date sits with the time because this table outlives the
// day it was rung on — bills stay on the terminal until
// they are purged, so a bare clock time is ambiguous the
// moment the shop opens again.
Cell(
'${Formatters.date(o.createdAt)} \u00b7 '
'${Formatters.time(o.createdAt)}',
color: AppColors.textTertiary,
),
Cell(_units(o.itemCount), mono: true),
Cell(Formatters.money(o.total), mono: true, bold: true),
TagChip(
o.isSynced ? 'Synced' : 'Pending',
@@ -205,127 +113,17 @@ class EventsView extends ConsumerWidget {
.toList(),
),
),
if (events.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Sync history',
subtitle: 'This session',
child: ResponsiveTable(
columns: const [
TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true),
],
rows: events
.map((e) => [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
e.type.isInbound
? Icons.cloud_download_rounded
: Icons.cloud_upload_rounded,
size: 15,
color: AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(child: Cell(e.type.label, bold: true)),
],
),
Cell(
e.error ?? e.summary,
color: e.error != null
? AppColors.danger
: AppColors.textSecondary,
),
Cell(Formatters.time(e.createdAt),
color: AppColors.textTertiary,),
],)
.toList(),
),
),
],
],
);
}
Widget _outcomeBanner(SyncOutcome outcome) {
final ok = outcome.isSuccess;
final uploaded = outcome.uploaded;
final attempted = outcome.attempted;
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
size: 18,
color: ok ? AppColors.success : AppColors.danger,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
ok
? '$uploaded of $attempted bills uploaded and marked synced.'
: '${outcome.error}',
style: TextStyle(
fontSize: 13,
height: 1.45,
color: ok ? AppColors.success : AppColors.danger,
),
),
),
],
),
);
}
static Color _methodColor(PaymentMethod m) => switch (m) {
PaymentMethod.cash => AppColors.success,
PaymentMethod.card => AppColors.info,
PaymentMethod.upi => AppColors.primary,
PaymentMethod.wallet => AppColors.warning,
PaymentMethod.giftCard => AppColors.tierGold,
PaymentMethod.loyalty => AppColors.tierSilver,
};
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(
children: [
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
),
),
),
const SizedBox(width: AppSpacing.md),
Text(
value,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
);
/// Units on a bill. Whole where they are whole — loose goods are sold by
/// weight, so "2.5" is a real answer here and rounding it would be a lie.
static String _units(double count) =>
count % 1 == 0 ? count.toStringAsFixed(0) : count.toStringAsFixed(2);
}
/// Flags a bill that could not reach the server on its own — the automatic
/// push right after checkout, not the manual "Sync" button below.
/// Flags a bill that could not reach the server on its own.
///
/// 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
@@ -343,12 +141,10 @@ class _EngineWarningBanner extends StatelessWidget {
final retry = engine.nextAttemptAt;
final retryNote = halted
? 'Retrying will not help until this is fixed — press Sync below '
'once it is sorted.'
? 'Retrying will not help until this is fixed.'
: 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.';
? 'It will retry automatically.'
: 'It will retry automatically at ${Formatters.time(retry)}.';
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),

View File

@@ -1,7 +1,6 @@
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';
@@ -25,14 +24,18 @@ class ProductImportView extends ConsumerWidget {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
final revision = ref.watch(syncRepositoryProvider).catalogueRevision;
return ModulePage(
children: [
if (!ready) _NotImportedBanner(state: state),
if (ready) ...[
// Start-aligned, so the tiles begin at the same left edge as the
// panels below them rather than drifting with the run's width.
Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
children: [
@@ -43,13 +46,6 @@ class ProductImportView extends ConsumerWidget {
color: AppColors.success,
caption: 'available offline',
),
StatTile(
label: 'Catalogue Revision',
value: revision ?? '',
icon: Icons.tag_rounded,
color: AppColors.info,
caption: 'server version',
),
StatTile(
label: 'Last Imported',
value: lastImport == null
@@ -91,7 +87,7 @@ class ProductImportView extends ConsumerWidget {
child: ResponsiveTable(
columns: const [
TableCol('Product', flex: 4),
TableCol('SKU', flex: 3, priority: 1),
TableCol('Barcode', flex: 3, priority: 1),
TableCol('Category', flex: 2, priority: 1),
TableCol('Price', flex: 2, numeric: true),
TableCol('Stock', flex: 2, numeric: true),
@@ -107,7 +103,8 @@ class ProductImportView extends ConsumerWidget {
Flexible(child: Cell(p.name, bold: true)),
],
),
Cell(p.sku, color: AppColors.textTertiary),
Cell(p.barcode,
color: AppColors.textTertiary, mono: true,),
TagChip(p.category.label,
color: AppColors.textSecondary,),
Cell(Formatters.money(p.price), mono: true, bold: true),

View File

@@ -33,7 +33,11 @@ class PromosView extends ConsumerWidget {
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Text('Could not load campaigns: $e'),
// Stretch, not the Column default of centre. Centred, every card
// shrank to its own intrinsic width and floated in the middle of the
// page instead of starting at the left edge like every other module.
data: (promos) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_summary(promos),
const SizedBox(height: AppSpacing.lg),
@@ -55,6 +59,7 @@ class PromosView extends ConsumerWidget {
.length;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StatTile(
@@ -119,6 +124,7 @@ class PromosView extends ConsumerWidget {
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final promo in promos)
_PromoRow(promo: promo, isAdmin: isAdmin),

View File

@@ -9,6 +9,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../data/local/order_dao.dart';
import '../../../data/local/void_pin_store.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart';
@@ -17,6 +18,7 @@ import '../widgets/staff_dialogs.dart';
import '../widgets/store_details_dialog.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
import '../../../core/widgets/numeric_keypad.dart';
/// Terminal and store configuration.
class SettingsView extends ConsumerStatefulWidget {
@@ -32,6 +34,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
bool _testingDrawer = false;
bool _loadedDrawerFields = false;
/// Null until the first read comes back from the meta table.
bool? _hasRemovalPin;
bool _loadedRemovalPin = false;
bool _scannerSound = true;
bool _roundOff = true;
bool _autoLoyalty = true;
@@ -50,6 +56,16 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
// Seeded once, from whatever was persisted. Assigning on every build would
// fight the cashier for the cursor while they type.
if (!_loadedRemovalPin) {
_loadedRemovalPin = true;
final store = ref.read(localStoreProvider);
if (store.isReady) {
store.voidPin.isConfigured.then((has) {
if (mounted) setState(() => _hasRemovalPin = has);
});
}
}
final printer = ref.watch(printerSettingsProvider);
if (!_loadedDrawerFields && printer.hasDrawer) {
_loadedDrawerFields = true;
@@ -80,6 +96,8 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
const SizedBox(height: AppSpacing.lg),
_staffCard(store, user),
const SizedBox(height: AppSpacing.lg),
_removalPinCard(user),
const SizedBox(height: AppSpacing.lg),
_aboutCard(),
],
);
@@ -430,6 +448,94 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
);
// ------------------------------------------------------- Removal PIN
/// The PIN a cashier types to take a rung item back off a bill.
///
/// Admin-only, and deliberately not a staff PIN. A staff PIN identifies the
/// person a bill is stamped with; handing one out so the counter can void a
/// line would put the whole shift under the wrong name. This is a shared
/// secret for one specific action, and the admin's own staff PIN keeps
/// working whether or not it is set.
Widget _removalPinCard(StaffUser? user) {
final isAdmin = user?.role == StaffRole.admin;
return PanelCard(
title: 'Item removal PIN',
subtitle: 'Asked for when an item is taken off a bill',
action: isAdmin
? TextButton(
onPressed: () => _setRemovalPin(),
child: Text(_hasRemovalPin == true ? 'Change' : 'Set PIN'),
)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_row(
'Status',
_hasRemovalPin == null
? 'Checking…'
: (_hasRemovalPin! ? 'Set' : 'Not set'),
),
const SizedBox(height: AppSpacing.sm),
Text(
_hasRemovalPin == true
? 'A cashier can remove an item using this PIN. An admin PIN '
'still works too.'
: 'No PIN is set, so a removal currently needs an admin PIN. '
'Set one and a cashier can void a line without an admin '
'walking over.',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
if (!isAdmin) ...[
const SizedBox(height: AppSpacing.sm),
const Text(
'Only an admin can change it.',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
],
],
),
);
}
Future<void> _setRemovalPin() async {
final pin = await showDialog<String>(
context: context,
builder: (_) => const _RemovalPinDialog(),
);
if (pin == null) return;
final store = ref.read(localStoreProvider);
try {
if (pin.isEmpty) {
await store.voidPin.clearPin();
} else {
await store.voidPin.setPin(pin);
}
if (!mounted) return;
setState(() => _hasRemovalPin = pin.isNotEmpty);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(pin.isEmpty
? 'Removal PIN cleared. Removals now need an admin PIN.'
: 'Removal PIN saved.',),
),);
} on VoidPinException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(e.message)));
}
}
Widget _connectivityCard() {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
@@ -609,3 +715,96 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
);
}
/// Four-to-eight digits, keyed on the same pad the till uses everywhere else.
class _RemovalPinDialog extends StatefulWidget {
const _RemovalPinDialog();
@override
State<_RemovalPinDialog> createState() => _RemovalPinDialogState();
}
class _RemovalPinDialogState extends State<_RemovalPinDialog> {
String _pin = '';
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Set removal PIN'),
content: SizedBox(
width: (MediaQuery.sizeOf(context).width - 96).clamp(260.0, 340.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Four digits or more. Give it to whoever is on the counter — it '
'authorises removing an item from a bill and nothing else.',
style: TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
height: 22,
child: Center(
child: _pin.isEmpty
? const Text(
'Enter PIN',
style: TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
),
)
: Wrap(
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,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.lg),
NumericKeypad(
onKey: (d) {
if (_pin.length >= 8) return;
setState(() => _pin += d);
},
onBackspace: () {
if (_pin.isEmpty) return;
setState(() => _pin = _pin.substring(0, _pin.length - 1));
},
onClear: () => setState(() => _pin = ''),
onSubmit: _pin.length >= 4
? () => Navigator.of(context).pop(_pin)
: null,
submitLabel: 'Save PIN',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(''),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Remove PIN'),
),
],
);
}
}