pos changes
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user