pos changes
This commit is contained in:
@@ -17,9 +17,7 @@ import '../data/remote/simulated_order_transport.dart';
|
||||
import '../data/repositories/store_repository_impl.dart';
|
||||
import '../data/repositories/sync_repository_impl.dart';
|
||||
import '../data/repositories/transaction_repository_impl.dart';
|
||||
import '../data/local/session_store.dart';
|
||||
import '../data/local/terminal_identity.dart';
|
||||
import '../data/remote/pos_auth_api.dart';
|
||||
import '../data/sync/sync_engine.dart';
|
||||
import '../domain/repositories/customer_repository.dart';
|
||||
import '../domain/repositories/product_repository.dart';
|
||||
@@ -163,28 +161,10 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
|
||||
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
|
||||
);
|
||||
|
||||
/// Signs a terminal in against the back office.
|
||||
///
|
||||
/// Points at the same base URL the uplinks use, so re-pointing a terminal in
|
||||
/// Settings moves its sign-in with it rather than leaving it authenticating
|
||||
/// against the endpoint it used to belong to.
|
||||
final posAuthApiProvider = Provider<PosAuthApi>((ref) {
|
||||
final api = PosAuthApi(baseUrl: ref.watch(syncConfigProvider).httpBaseUrl);
|
||||
ref.onDispose(api.dispose);
|
||||
return api;
|
||||
});
|
||||
|
||||
/// Where the signed session survives a restart.
|
||||
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore());
|
||||
|
||||
/// The outlet, refreshed whenever staff or details change.
|
||||
///
|
||||
/// The email is the signed-in account's, not a constant. It used to be
|
||||
/// `DemoCredentials.email` — the same address on every install of a build,
|
||||
/// which is what made the store login decorative.
|
||||
final storeAccountProvider = FutureProvider<StoreAccount>(
|
||||
(ref) => ref.watch(storeRepositoryProvider).load(
|
||||
email: ref.watch(authControllerProvider.notifier).session?.email ?? '',
|
||||
email: DemoCredentials.email,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ class SyncConfig {
|
||||
this.password,
|
||||
this.httpBaseUrl = '',
|
||||
this.apiKey,
|
||||
this.sessionToken,
|
||||
this.ackTimeout = const Duration(seconds: 20),
|
||||
this.batchSize = 50,
|
||||
});
|
||||
@@ -53,38 +52,8 @@ class SyncConfig {
|
||||
final String? password;
|
||||
|
||||
final String httpBaseUrl;
|
||||
|
||||
/// A static key shared by every terminal at a deployment, if one is set.
|
||||
///
|
||||
/// Predates sign-in and says nothing about *who* is at the till, so it cannot
|
||||
/// scope a request to an outlet. Kept for deployments that put one in front
|
||||
/// of the endpoint.
|
||||
final String? apiKey;
|
||||
|
||||
/// The signed session from `POST /login`, held for the trading day.
|
||||
///
|
||||
/// Distinct from [apiKey] because the two answer different questions. The key
|
||||
/// says "this request came from our fleet"; the session says "this request
|
||||
/// came from Selvapuram, signed in as Ragul, and may touch that outlet and no
|
||||
/// other". Only the second can stop a till reaching another tenant's books,
|
||||
/// which is why it takes precedence when both are present.
|
||||
final String? sessionToken;
|
||||
|
||||
/// What goes in the Authorization header.
|
||||
///
|
||||
/// One accessor rather than the same `??` repeated at each call site, because
|
||||
/// the request that forgot it would be the one silently sending no
|
||||
/// credentials at all.
|
||||
String? get bearerToken {
|
||||
final session = sessionToken?.trim();
|
||||
if (session != null && session.isNotEmpty) return session;
|
||||
|
||||
final key = apiKey?.trim();
|
||||
if (key != null && key.isNotEmpty) return key;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// How long to wait for the back office to confirm a batch before treating
|
||||
/// the outcome as unknown and leaving every row pending.
|
||||
///
|
||||
@@ -179,7 +148,6 @@ class SyncConfig {
|
||||
String? password,
|
||||
String? httpBaseUrl,
|
||||
String? apiKey,
|
||||
String? sessionToken,
|
||||
Duration? ackTimeout,
|
||||
int? batchSize,
|
||||
}) =>
|
||||
@@ -194,7 +162,6 @@ class SyncConfig {
|
||||
password: password ?? this.password,
|
||||
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
sessionToken: sessionToken ?? this.sessionToken,
|
||||
ackTimeout: ackTimeout ?? this.ackTimeout,
|
||||
batchSize: batchSize ?? this.batchSize,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/// Typed references to bundled assets.
|
||||
///
|
||||
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome
|
||||
/// artwork is painted in code, so there are no raster or SVG assets to ship.
|
||||
/// Product imagery uses emoji glyphs and the welcome artwork is painted in
|
||||
/// code, so the only raster asset shipped is the mark itself.
|
||||
class AssetPaths {
|
||||
const AssetPaths._();
|
||||
|
||||
static const String _snd = 'assets/sounds';
|
||||
static const String _img = 'assets/images';
|
||||
|
||||
/// The Nearle mark. Every place that used to draw a letter "N" in a
|
||||
/// gradient box now renders this instead, so the brand cannot drift between
|
||||
/// the login screen, the sidebar and the cashier header.
|
||||
static const String logo = '$_img/logo.png';
|
||||
|
||||
static const String beepSuccess = '$_snd/beep_success.wav';
|
||||
static const String beepError = '$_snd/beep_error.wav';
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../presentation/auth/screens/login_screen.dart';
|
||||
import '../../presentation/payment/screens/payment_screen.dart';
|
||||
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
||||
import '../../presentation/receipt/screens/receipt_screen.dart';
|
||||
import '../../presentation/shift/screens/end_shift_screen.dart';
|
||||
|
||||
class AppRoutes {
|
||||
const AppRoutes._();
|
||||
@@ -19,6 +20,10 @@ class AppRoutes {
|
||||
static const String pos = '/';
|
||||
static const String payment = '/payment';
|
||||
static const String receipt = '/receipt';
|
||||
|
||||
/// Drawer count and hand-over. Reached from the session-end chooser, never
|
||||
/// linked to directly, and guarded like every other signed-in route.
|
||||
static const String endShift = '/end-shift';
|
||||
}
|
||||
|
||||
/// Router with an authentication guard.
|
||||
@@ -60,6 +65,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
pageBuilder: (context, state) =>
|
||||
_fade(state, const PosDashboardScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.endShift,
|
||||
name: 'endShift',
|
||||
pageBuilder: (context, state) => _slide(state, const EndShiftScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.payment,
|
||||
name: 'payment',
|
||||
|
||||
66
lib/core/widgets/brand_mark.dart
Normal file
66
lib/core/widgets/brand_mark.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants/asset_paths.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// The Nearle mark on its tile.
|
||||
///
|
||||
/// Every surface that shows the brand — login, sidebar, cashier header —
|
||||
/// renders this, so the mark cannot drift between them. It replaces the
|
||||
/// hand-drawn letter "N" in a gradient box that each of those screens used to
|
||||
/// build for itself.
|
||||
class BrandMark extends StatelessWidget {
|
||||
const BrandMark({
|
||||
super.key,
|
||||
this.size = 36,
|
||||
this.radius,
|
||||
this.onDark = false,
|
||||
});
|
||||
|
||||
final double size;
|
||||
final double? radius;
|
||||
|
||||
/// Set on a coloured background, where the tile needs no border to separate
|
||||
/// it from what is behind.
|
||||
final bool onDark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final corner = BorderRadius.circular(radius ?? size * 0.28);
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
// Clipped, not padded: the mark fills the tile edge to edge and the
|
||||
// rounded corner does the trimming, so nothing can spill past the box
|
||||
// whatever aspect ratio the file happens to have.
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: corner,
|
||||
border: onDark ? null : Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Image.asset(
|
||||
AssetPaths.logo,
|
||||
fit: BoxFit.fill,
|
||||
width: size,
|
||||
height: size,
|
||||
filterQuality: FilterQuality.medium,
|
||||
// A missing or undeclared asset would otherwise blank the brand out
|
||||
// of the sidebar entirely; the letterform is a poor substitute but a
|
||||
// better failure than nothing.
|
||||
errorBuilder: (context, _, __) => FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: size,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../local/staff_dao.dart';
|
||||
import '../local/sync_config_store.dart';
|
||||
import '../local/sync_log_dao.dart';
|
||||
import '../local/terminal_identity.dart';
|
||||
import '../local/void_pin_store.dart';
|
||||
|
||||
/// Terminal-side storage facade.
|
||||
///
|
||||
@@ -28,6 +29,7 @@ class LocalStore {
|
||||
late PromoDao promos;
|
||||
late SyncConfigStore syncConfig;
|
||||
late TerminalIdentityStore identityStore;
|
||||
late VoidPinStore voidPin;
|
||||
|
||||
/// Who this till is. Minted on first run, then stable forever.
|
||||
late TerminalIdentity terminal;
|
||||
@@ -60,6 +62,7 @@ class LocalStore {
|
||||
promos = PromoDao(AppDatabase.instance.db);
|
||||
syncConfig = SyncConfigStore(catalogue);
|
||||
identityStore = TerminalIdentityStore(catalogue);
|
||||
voidPin = VoidPinStore(catalogue, staff);
|
||||
|
||||
// A terminal with no staff cannot be signed into at all, so this runs
|
||||
// before anything else can ask who is on shift.
|
||||
@@ -148,9 +151,13 @@ class LocalStore {
|
||||
|
||||
/// Drops the imported catalogue from disk and from the in-memory cache.
|
||||
///
|
||||
/// Called at sign-out so the next shift never bills against a copy left
|
||||
/// over from this one — [hasCatalogue] goes back to false, and the only way
|
||||
/// to sell again is a fresh pull from the back office.
|
||||
/// Called when a *cashier* signs out, so the next shift never bills against
|
||||
/// a copy left over from this one — [hasCatalogue] goes back to false, and
|
||||
/// the only way to sell again is a fresh pull from the back office.
|
||||
///
|
||||
/// Not called on an admin sign-out. An admin's whole job at this terminal is
|
||||
/// to pull the catalogue and hand the till over, so wiping it on the way out
|
||||
/// would undo the thing they just did.
|
||||
Future<void> clearCatalogue() async {
|
||||
await catalogue.clearCatalogue();
|
||||
_products.clear();
|
||||
|
||||
@@ -524,4 +524,14 @@ class MetaKeys {
|
||||
static const String printerName = 'printer_name';
|
||||
static const String autoPrint = 'auto_print';
|
||||
static const String openDrawer = 'open_cash_drawer';
|
||||
|
||||
/// PIN that authorises taking a rung item back off a bill.
|
||||
///
|
||||
/// Stored hashed, like a staff PIN — the terminal only ever holds the hash
|
||||
/// and its salt, so lifting the database file does not hand over the ability
|
||||
/// to void. Deliberately separate from staff PINs: an admin sets it once and
|
||||
/// gives it to whoever is on the counter, so a removal can be authorised
|
||||
/// without an admin walking over to the till.
|
||||
static const String voidPinHash = 'void_pin_hash';
|
||||
static const String voidPinSalt = 'void_pin_salt';
|
||||
}
|
||||
|
||||
@@ -303,20 +303,27 @@ class OrderDao {
|
||||
return rows.isEmpty ? null : rows.first;
|
||||
}
|
||||
|
||||
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query(
|
||||
Tables.orders,
|
||||
columns: [
|
||||
'id',
|
||||
'invoice_number',
|
||||
'total',
|
||||
'created_at',
|
||||
'sync_status',
|
||||
'synced_at',
|
||||
'sync_attempts',
|
||||
'sync_error',
|
||||
],
|
||||
orderBy: 'created_at DESC',
|
||||
limit: limit,
|
||||
/// Rows for the sync log, with each bill's unit count folded in.
|
||||
///
|
||||
/// The count comes from a correlated sum over [Tables.orderItems] rather
|
||||
/// than a column on the order: quantity can be fractional (loose weight), so
|
||||
/// there is no line count that answers "how many units were on this bill".
|
||||
/// A single aggregate keeps this to one query rather than one per row.
|
||||
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) =>
|
||||
_db.rawQuery(
|
||||
'''
|
||||
SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status,
|
||||
o.synced_at, o.sync_attempts, o.sync_error,
|
||||
COALESCE(
|
||||
(SELECT SUM(i.quantity) FROM ${Tables.orderItems} i
|
||||
WHERE i.order_id = o.id),
|
||||
0
|
||||
) AS item_count
|
||||
FROM ${Tables.orders} o
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?
|
||||
''',
|
||||
[limit],
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- Sync
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../domain/entities/pos_session.dart';
|
||||
|
||||
/// Keeps a terminal signed in across restarts.
|
||||
///
|
||||
/// A till is not a browser. It signs in when a shop opens and bills for the
|
||||
/// whole trading day, often on a connection that comes and goes, and it must
|
||||
/// survive being rebooted mid-shift without a queue of customers waiting while
|
||||
/// somebody finds the manager's password.
|
||||
///
|
||||
/// The whole session goes to the platform keystore rather than to SQLite. The
|
||||
/// token is a bearer credential — anything holding it can bill as this shop —
|
||||
/// and SQLite here is a file on a machine behind a shop counter, readable by
|
||||
/// anything that can open it. The rest of the session travels with the token
|
||||
/// because splitting them invites the two halves to disagree about which outlet
|
||||
/// this terminal is.
|
||||
class SessionStore {
|
||||
SessionStore({FlutterSecureStorage? secureStorage})
|
||||
: _secure = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _secure;
|
||||
|
||||
static const _key = 'pos.session';
|
||||
|
||||
/// Reads the saved session, or null when there is none worth using.
|
||||
///
|
||||
/// An expired session is treated as absent rather than returned for the
|
||||
/// caller to check. Every caller would have to make the same check, and the
|
||||
/// one that forgot would send a dead token all day and read the resulting
|
||||
/// 401s as a server fault.
|
||||
Future<PosSession?> read({DateTime? now}) async {
|
||||
final String? raw;
|
||||
try {
|
||||
raw = await _secure.read(key: _key);
|
||||
} on Object catch (e) {
|
||||
// No keystore — a headless test host, or a Linux box with no secret
|
||||
// service. Signing in again is the safe way to fail.
|
||||
debugPrint('Secure storage unavailable, session not loaded: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final session =
|
||||
PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
|
||||
if (!session.isValidAt(now ?? DateTime.now())) return null;
|
||||
if (session.token.isEmpty || session.locationId <= 0) return null;
|
||||
return session;
|
||||
} on Object catch (e) {
|
||||
// A stored session this build cannot parse — most likely written by an
|
||||
// older one. Dropped rather than repaired: a half-understood session is
|
||||
// worse than none, and re-authenticating costs one screen.
|
||||
debugPrint('Stored session could not be read, discarding: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(PosSession session) async {
|
||||
try {
|
||||
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
|
||||
} on Object catch (e) {
|
||||
// The terminal keeps working on the session it holds in memory; it just
|
||||
// will not survive a restart. Failing the sign-in over this would close a
|
||||
// shop for a keystore problem.
|
||||
debugPrint('Could not persist the session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
await _secure.delete(key: _key);
|
||||
} on Object catch (e) {
|
||||
debugPrint('Could not clear the session: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,6 @@ class StaffDao {
|
||||
|
||||
final Database _db;
|
||||
|
||||
/// Exposed for [StaffImport], which lives in this file and is part of this
|
||||
/// type in everything but syntax — an extension cannot see a private field.
|
||||
Database get db => _db;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// The accounts a shop starts with.
|
||||
@@ -281,93 +277,3 @@ class StaffDao {
|
||||
isActive: (row['is_active'] as int? ?? 1) == 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces the terminal's staff with what the back office says.
|
||||
///
|
||||
/// The back office is the source of truth for who works at a shop, and this is
|
||||
/// where that becomes true rather than aspirational. It exists because the
|
||||
/// alternative — three names and three PINs compiled into the app — meant every
|
||||
/// install of a build shared the same three logins, readable by anyone with the
|
||||
/// APK.
|
||||
///
|
||||
/// Three things happen, and the second is the one that matters:
|
||||
///
|
||||
/// 1. every person the back office named is written, keyed on their user id so
|
||||
/// a re-sync updates rather than duplicates;
|
||||
/// 2. **the seeded accounts are deactivated**, so the moment a shop has real
|
||||
/// staff the built-in PINs stop working — without this the hardcoded
|
||||
/// logins would survive alongside the real ones for ever; and
|
||||
/// 3. anyone previously imported who is no longer named is deactivated too,
|
||||
/// because a leaver removed in the back office must lose the till.
|
||||
///
|
||||
/// Deactivated, never deleted. Bills carry the cashier's name and shifts are
|
||||
/// settled against it, so a hard delete would orphan a day's takings.
|
||||
///
|
||||
/// Does nothing at all when [members] is empty. That is the common case today —
|
||||
/// most outlets have no staff recorded — and wiping a working till's logins
|
||||
/// because the back office has not been filled in yet would close a shop.
|
||||
extension StaffImport on StaffDao {
|
||||
Future<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
|
||||
if (members.isEmpty) return 0;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final imported = <String>{};
|
||||
|
||||
for (final member in members) {
|
||||
final pin = member.pin.trim();
|
||||
// A blank or malformed PIN cannot be signed in with. Skipped rather than
|
||||
// written, so the till does not show a name nobody can use.
|
||||
if (pin.length < 4 || int.tryParse(pin) == null) continue;
|
||||
|
||||
final salt = PinHasher.newSalt();
|
||||
imported.add(member.localId);
|
||||
|
||||
await db.insert(
|
||||
Tables.staff,
|
||||
{
|
||||
'id': member.localId,
|
||||
'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name,
|
||||
'role': member.role.name,
|
||||
'pin_hash': PinHasher.hash(pin, salt),
|
||||
'pin_salt': salt,
|
||||
// Not flagged for change: this PIN was set by the shop in the back
|
||||
// office, so it is already theirs. The flag is for the seeds.
|
||||
'must_change_pin': 0,
|
||||
'is_active': 1,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing usable came back — leave the till exactly as it was rather than
|
||||
// stranding it with no way to sign in.
|
||||
if (imported.isEmpty) return 0;
|
||||
|
||||
final placeholders = List.filled(imported.length, '?').join(',');
|
||||
await db.update(
|
||||
Tables.staff,
|
||||
{'is_active': 0, 'updated_at': now},
|
||||
where: 'id NOT IN ($placeholders)',
|
||||
whereArgs: imported.toList(),
|
||||
);
|
||||
|
||||
return imported.length;
|
||||
}
|
||||
}
|
||||
|
||||
/// One person to import, already mapped onto the till's own role vocabulary.
|
||||
class StaffImportRecord {
|
||||
const StaffImportRecord({
|
||||
required this.localId,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.pin,
|
||||
});
|
||||
|
||||
final String localId;
|
||||
final String name;
|
||||
final StaffRole role;
|
||||
final String pin;
|
||||
}
|
||||
|
||||
87
lib/data/local/void_pin_store.dart
Normal file
87
lib/data/local/void_pin_store.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import '../../core/security/pin_hasher.dart';
|
||||
import '../../domain/entities/store_account.dart';
|
||||
import 'app_database.dart';
|
||||
import 'catalogue_dao.dart';
|
||||
import 'staff_dao.dart';
|
||||
|
||||
/// The PIN that authorises removing a rung item from a bill.
|
||||
///
|
||||
/// Set once by an admin and handed to whoever is on the counter, so a cashier
|
||||
/// can void a line without an admin walking over. It is a *separate* secret
|
||||
/// from staff PINs on purpose: a staff PIN identifies a person and is what
|
||||
/// stamps a bill, and sharing one to allow voids would put every sale that
|
||||
/// shift under the wrong name.
|
||||
///
|
||||
/// Stored hashed with its own salt, never in the clear. Until an admin sets
|
||||
/// one, [verify] falls back to any admin's staff PIN — a terminal that cannot
|
||||
/// void at all is worse than one that needs the admin present.
|
||||
class VoidPinStore {
|
||||
const VoidPinStore(this._meta, this._staff);
|
||||
|
||||
final CatalogueDao _meta;
|
||||
final StaffDao _staff;
|
||||
|
||||
/// Whether an admin has set a dedicated removal PIN on this terminal.
|
||||
///
|
||||
/// Empty counts as absent: [clearPin] blanks the row rather than deleting
|
||||
/// it, so a null check alone would report a cleared PIN as still set.
|
||||
Future<bool> get isConfigured async {
|
||||
final hash = await _meta.meta(MetaKeys.voidPinHash);
|
||||
return hash != null && hash.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> setPin(String pin) async {
|
||||
_assertAcceptable(pin);
|
||||
final salt = PinHasher.newSalt();
|
||||
await _meta.setMeta(MetaKeys.voidPinHash, PinHasher.hash(pin, salt));
|
||||
await _meta.setMeta(MetaKeys.voidPinSalt, salt);
|
||||
}
|
||||
|
||||
/// Drops the dedicated PIN, returning the terminal to admin-PIN-only voids.
|
||||
Future<void> clearPin() async {
|
||||
await _meta.setMeta(MetaKeys.voidPinHash, '');
|
||||
await _meta.setMeta(MetaKeys.voidPinSalt, '');
|
||||
}
|
||||
|
||||
/// True when [pin] may authorise a removal.
|
||||
///
|
||||
/// Checks the dedicated PIN first, then admin staff PINs. An admin's own PIN
|
||||
/// always works, so setting a removal PIN never locks the owner out of their
|
||||
/// own till.
|
||||
Future<bool> verify(String pin) async {
|
||||
final hash = await _meta.meta(MetaKeys.voidPinHash);
|
||||
final salt = await _meta.meta(MetaKeys.voidPinSalt);
|
||||
|
||||
if (hash != null && hash.isNotEmpty && salt != null && salt.isNotEmpty) {
|
||||
if (PinHasher.verify(pin, salt: salt, hash: hash)) return true;
|
||||
}
|
||||
|
||||
final user = await _staff.authenticate(pin);
|
||||
return user != null && user.role == StaffRole.admin;
|
||||
}
|
||||
|
||||
/// Same rule the staff PINs use, for the same reason: these are typed on a
|
||||
/// keypad behind a counter, in front of a queue.
|
||||
static void _assertAcceptable(String pin) {
|
||||
if (pin.length < 4 || int.tryParse(pin) == null) {
|
||||
throw const VoidPinException('A PIN must be at least four digits.');
|
||||
}
|
||||
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
|
||||
'7777', '8888', '9999', '1234', '4321', '0123',};
|
||||
if (tooObvious.contains(pin)) {
|
||||
throw const VoidPinException(
|
||||
'That PIN is too easy to guess from across the counter. '
|
||||
'Choose another.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VoidPinException implements Exception {
|
||||
const VoidPinException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -156,8 +156,7 @@ class HttpCatalogueSource implements CatalogueSource {
|
||||
uri,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
if (config.bearerToken != null)
|
||||
'authorization': 'Bearer ${config.bearerToken}',
|
||||
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
|
||||
},
|
||||
).timeout(_timeout);
|
||||
} on Exception catch (e) {
|
||||
|
||||
@@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport {
|
||||
Uri.parse('${config.httpBaseUrl}/health'),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
if (config.bearerToken != null)
|
||||
'authorization': 'Bearer ${config.bearerToken}',
|
||||
if (config.apiKey != null)
|
||||
'authorization': 'Bearer ${config.apiKey}',
|
||||
},
|
||||
body: payload,
|
||||
)
|
||||
@@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport {
|
||||
uri,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
if (config.bearerToken != null)
|
||||
'authorization': 'Bearer ${config.bearerToken}',
|
||||
if (config.apiKey != null)
|
||||
'authorization': 'Bearer ${config.apiKey}',
|
||||
'idempotency-key': batchId,
|
||||
},
|
||||
body: jsonEncode({
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../domain/entities/pos_session.dart';
|
||||
|
||||
/// Raised when the back office refuses or cannot answer a sign-in.
|
||||
///
|
||||
/// Carries a message meant to be shown to whoever is standing at the till, so
|
||||
/// it is written for them rather than for a log: what happened, and what they
|
||||
/// can do about it.
|
||||
class PosAuthException implements Exception {
|
||||
const PosAuthException(this.message, {this.isCredentialFailure = false});
|
||||
|
||||
final String message;
|
||||
|
||||
/// Whether the details were wrong, as opposed to the back office being
|
||||
/// unreachable. The till reacts differently: a bad password is worth
|
||||
/// re-typing, an unreachable server is worth waiting for.
|
||||
final bool isCredentialFailure;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Signs a terminal in against the back office.
|
||||
///
|
||||
/// Talks to the same `app_users` accounts as the web console, so a manager who
|
||||
/// can open the back office can open the till with the same details — one
|
||||
/// account store means deactivating a leaver closes both doors at once.
|
||||
///
|
||||
/// ```
|
||||
/// POST {base}/login
|
||||
/// { "authname": "…", "password": "…", "terminal_id": "T5EDD" }
|
||||
/// ```
|
||||
///
|
||||
/// answered with `{ code, status, details: { token, store_id, locations, … } }`.
|
||||
class PosAuthApi {
|
||||
PosAuthApi({required this.baseUrl, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
final String baseUrl;
|
||||
final http.Client _client;
|
||||
|
||||
/// Generous, because this runs on a shop's connection while somebody watches.
|
||||
/// Short enough that a dead endpoint is reported rather than hung on.
|
||||
static const _timeout = Duration(seconds: 20);
|
||||
|
||||
/// Exchanges credentials for a session.
|
||||
///
|
||||
/// [locationId] is only meaningful for an account entitled to several
|
||||
/// outlets: it says which one this terminal is standing in. It is a request,
|
||||
/// not an assertion — the back office checks it against what the account may
|
||||
/// actually reach, and that check is the whole point of the endpoint.
|
||||
Future<PosSession> login({
|
||||
required String authname,
|
||||
required String password,
|
||||
String? terminalId,
|
||||
String? deviceId,
|
||||
int? locationId,
|
||||
int? configId,
|
||||
}) async {
|
||||
if (baseUrl.isEmpty) {
|
||||
throw const PosAuthException(
|
||||
'This terminal has no back office configured. Set the endpoint in '
|
||||
'Settings → Connectivity & sync.',
|
||||
);
|
||||
}
|
||||
|
||||
final body = <String, Object?>{
|
||||
'authname': authname.trim(),
|
||||
'password': password,
|
||||
if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId,
|
||||
if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId,
|
||||
if (locationId != null && locationId > 0) 'location_id': locationId,
|
||||
// Sent only when known. The backend infers it when absent, and a shop
|
||||
// has no way to find out what its configid is.
|
||||
if (configId != null && configId > 0) 'configid': configId,
|
||||
};
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _client
|
||||
.post(
|
||||
Uri.parse('$baseUrl/login'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
} on TimeoutException {
|
||||
throw const PosAuthException(
|
||||
'The back office did not answer in time. Check the connection and try '
|
||||
'again.',
|
||||
);
|
||||
} on Object {
|
||||
throw const PosAuthException(
|
||||
'Could not reach the back office. Check the connection and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body) as Map<String, Object?>;
|
||||
} on Object {
|
||||
throw PosAuthException(
|
||||
'The back office answered with something this terminal could not read '
|
||||
'(HTTP ${response.statusCode}).',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw PosAuthException(
|
||||
(decoded['message'] as String?) ??
|
||||
'Sign-in was refused (HTTP ${response.statusCode}).',
|
||||
// 401 is a wrong email or password; 403 is a real account that may not
|
||||
// open this till. Only the first is worth re-typing.
|
||||
isCredentialFailure: response.statusCode == 401,
|
||||
);
|
||||
}
|
||||
|
||||
final details = decoded['details'];
|
||||
if (details is! Map<String, Object?>) {
|
||||
throw const PosAuthException(
|
||||
'The back office accepted the sign-in but returned no session.',
|
||||
);
|
||||
}
|
||||
|
||||
final session = PosSession.fromJson(details);
|
||||
|
||||
// A session with no token cannot authenticate anything, and one with no
|
||||
// outlet cannot bill. Refused here rather than being saved and failing
|
||||
// later against every request, which would be much harder to diagnose.
|
||||
if (session.token.isEmpty) {
|
||||
throw const PosAuthException(
|
||||
'The back office returned a session with no token.',
|
||||
);
|
||||
}
|
||||
if (session.locationId <= 0) {
|
||||
throw const PosAuthException(
|
||||
'This account is not attached to an outlet, so it cannot open a till.',
|
||||
);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
@@ -178,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
createdAt:
|
||||
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
||||
isSynced: (r['sync_status']! as int) == OrderDao.synced,
|
||||
itemCount: (r['item_count'] as num?)?.toDouble() ?? 0,
|
||||
syncedAt: r['synced_at'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/// What the back office answers a sign-in with.
|
||||
///
|
||||
/// Replaces the arrangement where a till held a store id typed into Settings
|
||||
/// and a password compiled into the app. That made the store id a *claim*: any
|
||||
/// terminal could name any outlet and be believed, so one leaked build reached
|
||||
/// every tenant on the platform.
|
||||
///
|
||||
/// Now the outlet arrives *from* the back office as a consequence of who signed
|
||||
/// in, sealed inside a signed token the terminal cannot edit. The till stops
|
||||
/// deciding which shop it belongs to and starts being told.
|
||||
class PosSession {
|
||||
const PosSession({
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.userId,
|
||||
required this.fullName,
|
||||
required this.roleId,
|
||||
required this.tenantId,
|
||||
required this.tenantName,
|
||||
required this.storeId,
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
this.email = '',
|
||||
this.gstin = '',
|
||||
this.address = '',
|
||||
this.phone = '',
|
||||
this.outlets = const [],
|
||||
this.staff = const [],
|
||||
});
|
||||
|
||||
/// The bearer token, sent on every request from here on.
|
||||
///
|
||||
/// Opaque on purpose. The terminal must not parse it, reason about it, or
|
||||
/// trust anything it appears to say — its only correct use is to hand it back
|
||||
/// and let the server decide what it means.
|
||||
final String token;
|
||||
|
||||
final DateTime expiresAt;
|
||||
|
||||
final int userId;
|
||||
final String fullName;
|
||||
final String email;
|
||||
final int roleId;
|
||||
|
||||
final int tenantId;
|
||||
final String tenantName;
|
||||
|
||||
/// The outlet this terminal bills for, as a string because that is the shape
|
||||
/// the sync configuration and every uplink already use.
|
||||
final String storeId;
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
|
||||
/// Printed on the invoice, so a legal requirement rather than decoration.
|
||||
/// Arriving with the session means a shop that corrects its GSTIN in the back
|
||||
/// office sees it on the next receipt instead of at the next rebuild.
|
||||
final String gstin;
|
||||
final String address;
|
||||
final String phone;
|
||||
|
||||
/// Every outlet this account may open a till at.
|
||||
///
|
||||
/// A single-shop user gets a list of one, so the sign-in flow has no special
|
||||
/// case: it offers a choice when there is one and skips it when there is not.
|
||||
final List<PosOutlet> outlets;
|
||||
|
||||
bool get hasChoiceOfOutlet => outlets.length > 1;
|
||||
|
||||
/// The people the back office says may ring a bill here.
|
||||
///
|
||||
/// Very often empty. Only 116 of 596 accounts on the platform have a PIN set,
|
||||
/// and most outlets — including the one this terminal ships pointed at — have
|
||||
/// none at all. A till must treat that as an unfinished setup rather than as
|
||||
/// a failure, which is why the seeded accounts still exist as a last resort.
|
||||
final List<PosStaffMember> staff;
|
||||
|
||||
/// Whether the session is still worth sending.
|
||||
///
|
||||
/// Checked against the terminal's own clock, which is the only one available
|
||||
/// offline. A till whose clock is wrong will re-authenticate unnecessarily —
|
||||
/// annoying, and much better than billing a whole day against a session the
|
||||
/// server has already stopped accepting.
|
||||
bool isValidAt(DateTime now) => now.isBefore(expiresAt);
|
||||
|
||||
PosSession copyWith({
|
||||
String? storeId,
|
||||
int? locationId,
|
||||
String? locationName,
|
||||
String? address,
|
||||
}) =>
|
||||
PosSession(
|
||||
token: token,
|
||||
expiresAt: expiresAt,
|
||||
userId: userId,
|
||||
fullName: fullName,
|
||||
email: email,
|
||||
roleId: roleId,
|
||||
tenantId: tenantId,
|
||||
tenantName: tenantName,
|
||||
storeId: storeId ?? this.storeId,
|
||||
locationId: locationId ?? this.locationId,
|
||||
locationName: locationName ?? this.locationName,
|
||||
gstin: gstin,
|
||||
address: address ?? this.address,
|
||||
phone: phone,
|
||||
outlets: outlets,
|
||||
staff: staff,
|
||||
);
|
||||
|
||||
factory PosSession.fromJson(Map<String, Object?> json) {
|
||||
final outlets = (json['locations'] as List<Object?>? ?? const [])
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(PosOutlet.fromJson)
|
||||
.toList();
|
||||
|
||||
return PosSession(
|
||||
token: (json['token'] as String?) ?? '',
|
||||
// A session with no readable expiry is treated as already finished rather
|
||||
// than as never finishing. Guessing "valid" here would keep a till
|
||||
// sending a token the server stopped honouring hours ago.
|
||||
expiresAt: DateTime.tryParse((json['expires_at'] as String?) ?? '')
|
||||
?.toLocal() ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
userId: _int(json['user_id']),
|
||||
fullName: (json['full_name'] as String?)?.trim() ?? '',
|
||||
email: (json['email'] as String?) ?? '',
|
||||
roleId: _int(json['role_id']),
|
||||
tenantId: _int(json['tenant_id']),
|
||||
tenantName: (json['tenant_name'] as String?) ?? '',
|
||||
storeId: (json['store_id'] as String?) ?? '${_int(json['location_id'])}',
|
||||
locationId: _int(json['location_id']),
|
||||
locationName: (json['location_name'] as String?) ?? '',
|
||||
gstin: (json['gstin'] as String?) ?? '',
|
||||
address: (json['address'] as String?) ?? '',
|
||||
phone: (json['phone'] as String?) ?? '',
|
||||
outlets: outlets,
|
||||
staff: (json['staff'] as List<Object?>? ?? const [])
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(PosStaffMember.fromJson)
|
||||
.where((m) => m.pin.isNotEmpty)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'token': token,
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
'user_id': userId,
|
||||
'full_name': fullName,
|
||||
'email': email,
|
||||
'role_id': roleId,
|
||||
'tenant_id': tenantId,
|
||||
'tenant_name': tenantName,
|
||||
'store_id': storeId,
|
||||
'location_id': locationId,
|
||||
'location_name': locationName,
|
||||
'gstin': gstin,
|
||||
'address': address,
|
||||
'phone': phone,
|
||||
'locations': outlets.map((o) => o.toJson()).toList(),
|
||||
'staff': staff.map((m) => m.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// One outlet a signed-in account may bill for.
|
||||
class PosOutlet {
|
||||
const PosOutlet({
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
this.address = '',
|
||||
this.city = '',
|
||||
});
|
||||
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
final String address;
|
||||
final String city;
|
||||
|
||||
String get storeId => '$locationId';
|
||||
|
||||
factory PosOutlet.fromJson(Map<String, Object?> json) => PosOutlet(
|
||||
locationId: _int(json['location_id']),
|
||||
locationName: (json['location_name'] as String?) ?? '',
|
||||
address: (json['address'] as String?) ?? '',
|
||||
city: (json['city'] as String?) ?? '',
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'location_id': locationId,
|
||||
'location_name': locationName,
|
||||
'address': address,
|
||||
'city': city,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads an id that may arrive as a number or as a string.
|
||||
///
|
||||
/// The backend sends `location_id` as an int and `store_id` as a string for the
|
||||
/// same value, and a till that accepted only one shape would silently read zero
|
||||
/// for the other — which looks like "no outlet" rather than like a bug.
|
||||
int _int(Object? value) => switch (value) {
|
||||
final int v => v,
|
||||
final num v => v.toInt(),
|
||||
final String v => int.tryParse(v.trim()) ?? 0,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// One person the back office says may ring a bill at this outlet.
|
||||
///
|
||||
/// The PIN arrives in the clear over TLS and is hashed before it touches disk —
|
||||
/// see [PosSession] and the backend's `PosStaffMember` for why hashing it
|
||||
/// server-side would have bought the appearance of strength and not the
|
||||
/// substance. A four-digit PIN is brute-forceable in microseconds regardless;
|
||||
/// what it is, is *shift attribution* — which of the people already inside a
|
||||
/// shop gets credited with a sale. The security boundary is the session token.
|
||||
class PosStaffMember {
|
||||
const PosStaffMember({
|
||||
required this.userId,
|
||||
required this.fullName,
|
||||
required this.pin,
|
||||
this.role = '',
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String fullName;
|
||||
final String pin;
|
||||
|
||||
/// The back office's own role name — "Admin", "Manager", "Operations". Blank
|
||||
/// for the many accounts whose `roleid` is not in `app_roles` at all.
|
||||
final String role;
|
||||
|
||||
/// A stable local id for a row that came from the back office.
|
||||
///
|
||||
/// Prefixed so an imported account can be told apart from one seeded on this
|
||||
/// device. That distinction is what lets a sync retire the seeded logins
|
||||
/// without touching anything a shop created itself.
|
||||
String get localId => 'boffice-$userId';
|
||||
|
||||
factory PosStaffMember.fromJson(Map<String, Object?> json) =>
|
||||
PosStaffMember(
|
||||
userId: _int(json['user_id']),
|
||||
fullName: ((json['full_name'] as String?) ?? '').trim(),
|
||||
pin: ((json['pin'] as String?) ?? '').trim(),
|
||||
role: ((json['role'] as String?) ?? '').trim(),
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'user_id': userId,
|
||||
'full_name': fullName,
|
||||
'pin': pin,
|
||||
'role': role,
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class OrderSyncRow {
|
||||
required this.total,
|
||||
required this.createdAt,
|
||||
required this.isSynced,
|
||||
this.itemCount = 0,
|
||||
this.syncedAt,
|
||||
this.attempts = 0,
|
||||
this.error,
|
||||
@@ -49,6 +50,9 @@ class OrderSyncRow {
|
||||
final double total;
|
||||
final DateTime createdAt;
|
||||
final bool isSynced;
|
||||
|
||||
/// Units on the bill. Fractional because loose goods are sold by weight.
|
||||
final double itemCount;
|
||||
final DateTime? syncedAt;
|
||||
final int attempts;
|
||||
final String? error;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../data/local/staff_dao.dart';
|
||||
import '../../../data/remote/pos_auth_api.dart';
|
||||
import '../../../domain/entities/pos_session.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
|
||||
/// Sign-in state for the terminal.
|
||||
@@ -22,10 +19,23 @@ class Authenticating extends AuthState {
|
||||
}
|
||||
|
||||
class Authenticated extends AuthState {
|
||||
const Authenticated({required this.store, required this.user});
|
||||
const Authenticated({
|
||||
required this.store,
|
||||
required this.user,
|
||||
required this.login,
|
||||
});
|
||||
|
||||
final StoreAccount store;
|
||||
final StaffUser user;
|
||||
|
||||
/// Which credential opened this session. The authority on what the terminal
|
||||
/// is allowed to show — not [user], which can be swapped at the till.
|
||||
final TerminalLogin login;
|
||||
|
||||
StaffRole get role => login.role;
|
||||
|
||||
bool get isAdmin => login == TerminalLogin.admin;
|
||||
bool get isCashier => login == TerminalLogin.cashier;
|
||||
}
|
||||
|
||||
class AuthFailure extends AuthState {
|
||||
@@ -34,150 +44,110 @@ class AuthFailure extends AuthState {
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Signs the terminal in against the back office and holds the session.
|
||||
/// The two ways into this terminal.
|
||||
///
|
||||
/// This used to compare against two constants compiled into the app —
|
||||
/// `admin@nearle.in` / `nearle123` — with a 600ms delay standing in for a
|
||||
/// network call that was never made. Two things were wrong with that, and the
|
||||
/// second was the serious one:
|
||||
/// Store-level credentials, not a person's: they are replaced wholesale when
|
||||
/// the terminal is registered against a real back office. Staff PINs — the
|
||||
/// credential that actually opens a till drawer — are not here. They live
|
||||
/// hashed in the database.
|
||||
///
|
||||
/// 1. every install of a build shared one password, and changing it meant a
|
||||
/// rebuild; and
|
||||
/// 2. because nothing was checked with the back office, the *outlet* could not
|
||||
/// come from the sign-in. It came from a store id typed into Settings — so
|
||||
/// a till named its own shop and was believed, and one number changed on
|
||||
/// one screen moved a terminal into another tenant's books.
|
||||
/// The split is what the two roles are *for*, not decoration:
|
||||
///
|
||||
/// Now a person signs in with their own back-office account, and the outlet
|
||||
/// arrives as a consequence: sealed in a signed token, checked server-side on
|
||||
/// every request, and not editable from this device.
|
||||
/// * [admin] runs the whole shell and is the only login that can pull the
|
||||
/// catalogue. Signing out leaves the products on the terminal.
|
||||
/// * [cashier] gets the billing screen and nothing else, and signing out
|
||||
/// takes the catalogue with it.
|
||||
enum TerminalLogin {
|
||||
admin(
|
||||
label: 'Admin',
|
||||
email: 'admin@nearle.in',
|
||||
password: 'nearle123',
|
||||
role: StaffRole.admin,
|
||||
blurb: 'Full shell — import products, promos, settings.',
|
||||
),
|
||||
cashier(
|
||||
label: 'Cashier',
|
||||
email: 'cashier@nearle.in',
|
||||
password: 'cashier123',
|
||||
role: StaffRole.cashier,
|
||||
blurb: 'Billing only, on the products the admin imported.',
|
||||
);
|
||||
|
||||
const TerminalLogin({
|
||||
required this.label,
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.role,
|
||||
required this.blurb,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String email;
|
||||
final String password;
|
||||
final StaffRole role;
|
||||
final String blurb;
|
||||
|
||||
/// The catalogue is pulled once by an admin and billed against by whoever is
|
||||
/// on the counter, so only the cashier's sign-out drops it. An admin closing
|
||||
/// the shell is a handover, not the end of the day.
|
||||
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
|
||||
|
||||
static TerminalLogin? byEmail(String email) {
|
||||
final normalised = email.trim().toLowerCase();
|
||||
for (final login in TerminalLogin.values) {
|
||||
if (login.email == normalised) return login;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kept for the store record, which is keyed on the outlet's own address.
|
||||
class DemoCredentials {
|
||||
const DemoCredentials._();
|
||||
|
||||
static const String email = 'admin@nearle.in';
|
||||
static const String password = 'nearle123';
|
||||
|
||||
static const String cashierEmail = 'cashier@nearle.in';
|
||||
static const String cashierPassword = 'cashier123';
|
||||
}
|
||||
|
||||
/// Validates store credentials and holds the signed-in session.
|
||||
class AuthController extends StateNotifier<AuthState> {
|
||||
AuthController(this._ref) : super(const Unauthenticated());
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// The back office's answer to the last sign-in, if there is one.
|
||||
/// Whether signing out right now would wipe the products off this terminal.
|
||||
///
|
||||
/// Held so the outlet picker can offer a proprietor their other shops without
|
||||
/// asking for the password a second time.
|
||||
PosSession? _session;
|
||||
PosSession? get session => _session;
|
||||
|
||||
/// Restores a session saved on a previous run.
|
||||
///
|
||||
/// Called at start-up so a till that was rebooted mid-shift comes back
|
||||
/// trading rather than showing a login screen to a queue of customers.
|
||||
/// Returns false when there is nothing usable, which includes an expired
|
||||
/// session — [SessionStore] treats those as absent.
|
||||
Future<bool> restore() async {
|
||||
final saved = await _ref.read(sessionStoreProvider).read();
|
||||
if (saved == null) return false;
|
||||
|
||||
await _adopt(saved);
|
||||
return state is Authenticated;
|
||||
/// Read *before* [signOut] by anything that needs to warn the operator, since
|
||||
/// the session is gone by the time it returns.
|
||||
bool get clearsCatalogueOnSignOut {
|
||||
final current = state;
|
||||
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
|
||||
}
|
||||
|
||||
Future<bool> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
int? locationId,
|
||||
}) async {
|
||||
state = const Authenticating();
|
||||
|
||||
final terminal = _ref.read(terminalIdentityProvider);
|
||||
// Stand-in for the network round trip.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
|
||||
final PosSession session;
|
||||
try {
|
||||
session = await _ref.read(posAuthApiProvider).login(
|
||||
authname: email,
|
||||
password: password,
|
||||
terminalId: terminal.code,
|
||||
deviceId: terminal.deviceId,
|
||||
locationId: locationId,
|
||||
);
|
||||
} on PosAuthException catch (e) {
|
||||
state = AuthFailure(e.message);
|
||||
return false;
|
||||
} on Object {
|
||||
state = const AuthFailure(
|
||||
'Sign-in failed for an unexpected reason. Please try again.',
|
||||
);
|
||||
final login = TerminalLogin.byEmail(email);
|
||||
|
||||
if (login == null) {
|
||||
state = const AuthFailure('No account is registered against that email.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await _ref.read(sessionStoreProvider).write(session);
|
||||
await _adopt(session);
|
||||
if (password != login.password) {
|
||||
state = const AuthFailure('Incorrect password. Please try again.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return state is Authenticated;
|
||||
}
|
||||
|
||||
/// Moves this terminal to another of the signed-in account's outlets.
|
||||
///
|
||||
/// A fresh sign-in rather than a local switch, because the outlet is inside
|
||||
/// the signed token: the back office has to issue a new one, and re-checking
|
||||
/// entitlement at that moment is the point. Requires the password again,
|
||||
/// which is correct — moving a till between shops changes whose books it
|
||||
/// writes to.
|
||||
Future<bool> switchOutlet({
|
||||
required String password,
|
||||
required int locationId,
|
||||
}) async {
|
||||
final current = _session;
|
||||
if (current == null) return false;
|
||||
|
||||
return signIn(
|
||||
email: current.email.isNotEmpty ? current.email : current.fullName,
|
||||
password: password,
|
||||
locationId: locationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Adopts a session: points the terminal at its outlet, then opens it.
|
||||
///
|
||||
/// Order matters. The store id and token are written *before* the catalogue
|
||||
/// or any uplink can run, so a terminal can never spend even one request
|
||||
/// pointed at the outlet it had yesterday while claiming to be signed in as
|
||||
/// today's.
|
||||
Future<void> _adopt(PosSession session) async {
|
||||
_session = session;
|
||||
|
||||
await _ref.read(localStoreProvider).identityStore.rename(
|
||||
storeId: session.storeId,
|
||||
);
|
||||
_ref.invalidate(terminalIdentityProvider);
|
||||
|
||||
_ref.read(syncConfigProvider.notifier).state =
|
||||
_ref.read(syncConfigProvider).copyWith(
|
||||
storeId: session.storeId,
|
||||
sessionToken: session.token,
|
||||
);
|
||||
|
||||
// Store details for the receipt come from the back office now, not from
|
||||
// constants compiled into the build. A GSTIN is a legal requirement on a
|
||||
// tax invoice; it should not need a rebuild to correct.
|
||||
await _ref.read(storeRepositoryProvider).save(
|
||||
name: session.locationName.isNotEmpty
|
||||
? session.locationName
|
||||
: session.tenantName,
|
||||
address: session.address,
|
||||
gstin: session.gstin,
|
||||
phone: session.phone,
|
||||
);
|
||||
|
||||
// Who may ring a bill here, per the back office.
|
||||
//
|
||||
// This is what retires the seeded logins. The till ships with three names
|
||||
// and three PINs compiled into it — the same three on every install — and
|
||||
// they exist only so a shop whose back office has no staff recorded can
|
||||
// still trade on day one. The moment real staff arrive they are
|
||||
// deactivated, which is the whole point of importing rather than merging.
|
||||
//
|
||||
// Empty is the common case rather than an error: most outlets have nobody
|
||||
// recorded, including the one this build ships pointed at. The import
|
||||
// no-ops, the seeds survive, and the shop keeps selling.
|
||||
await _importStaff(session);
|
||||
|
||||
_ref.invalidate(storeAccountProvider);
|
||||
final store = await _ref.read(storeAccountProvider.future);
|
||||
final staff = store.staff;
|
||||
|
||||
@@ -185,61 +155,29 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
state = const AuthFailure(
|
||||
'This terminal has no staff accounts. Reinstall to seed them.',
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The first admin, or whoever is there. A person switches to their own
|
||||
// account at the till.
|
||||
// Whoever on this terminal matches the role that just signed in. Falls
|
||||
// back rather than failing: the session's permissions come from [login],
|
||||
// so a shop with no cashier row still gets a usable till — the bills are
|
||||
// just stamped with the account that is there.
|
||||
final opener = staff.firstWhere(
|
||||
(s) => s.role == StaffRole.admin,
|
||||
(s) => s.role == login.role,
|
||||
orElse: () => staff.first,
|
||||
);
|
||||
|
||||
state = Authenticated(store: store, user: opener);
|
||||
state = Authenticated(store: store, user: opener, login: login);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Writes the back office's staff over this terminal's.
|
||||
///
|
||||
/// Failures are swallowed. A shop must be able to open its till even when the
|
||||
/// staff import fails — the seeded or previously-synced accounts are still
|
||||
/// there, and refusing the sign-in would trade a working counter for a
|
||||
/// tidier database.
|
||||
Future<void> _importStaff(PosSession session) async {
|
||||
if (session.staff.isEmpty) return;
|
||||
|
||||
try {
|
||||
await _ref.read(localStoreProvider).staff.replaceFromBackOffice([
|
||||
for (final member in session.staff)
|
||||
StaffImportRecord(
|
||||
localId: member.localId,
|
||||
name: member.fullName,
|
||||
role: _roleFor(member.role),
|
||||
pin: member.pin,
|
||||
),
|
||||
]);
|
||||
} on Object {
|
||||
// Deliberately silent — see above.
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the back office's role names onto the till's three.
|
||||
///
|
||||
/// `app_roles` holds six rows for four distinct roles — Admin and Manager are
|
||||
/// each in there twice — and most accounts carry a `roleid` that is not in
|
||||
/// the table at all. So this matches on the name and falls back to the least
|
||||
/// privileged answer: an unrecognised role must not silently become an admin.
|
||||
StaffRole _roleFor(String backOfficeRole) =>
|
||||
switch (backOfficeRole.trim().toLowerCase()) {
|
||||
'super admin' || 'admin' => StaffRole.admin,
|
||||
'manager' || 'operations' => StaffRole.manager,
|
||||
_ => StaffRole.cashier,
|
||||
};
|
||||
|
||||
/// Switches the active operator, checking their PIN.
|
||||
///
|
||||
/// Every bill is stamped with whoever is active, so this is the boundary that
|
||||
/// decides who a sale is attributed to — it cannot be a bare selection from a
|
||||
/// list.
|
||||
/// list. It changes who the bill names, never what the session may open:
|
||||
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
|
||||
/// terminal.
|
||||
Future<bool> switchUser(String pin) async {
|
||||
final current = state;
|
||||
if (current is! Authenticated) return false;
|
||||
@@ -248,7 +186,11 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
final user = await store.staff.authenticate(pin);
|
||||
if (user == null) return false;
|
||||
|
||||
state = Authenticated(store: current.store, user: user);
|
||||
state = Authenticated(
|
||||
store: current.store,
|
||||
user: user,
|
||||
login: current.login,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -266,25 +208,25 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
// Signed out if the active operator was just deactivated — carrying on
|
||||
// would keep stamping bills with an account the shop has revoked.
|
||||
user: me.isEmpty ? store.staff.first : me.first,
|
||||
login: current.login,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Ends the session, and — for a cashier only — the catalogue with it.
|
||||
///
|
||||
/// The token goes with it, from the keystore and from the live configuration
|
||||
/// both. Leaving it in place would let a signed-out terminal keep uploading
|
||||
/// as the shop that signed in this morning.
|
||||
/// Every way out of a cashier session clears the products: ending a shift,
|
||||
/// and a temporary logout alike. There is no exception for stepping away for
|
||||
/// ten minutes, because the terminal is left unattended either way and the
|
||||
/// next session should bill against what the back office answers with rather
|
||||
/// than a catalogue carried over.
|
||||
///
|
||||
/// An admin signing out is the opposite case. They have just pulled the
|
||||
/// products *so that* a cashier can pick the terminal up, so dropping the
|
||||
/// table here would make the import pointless.
|
||||
Future<void> signOut() async {
|
||||
await _ref.read(localStoreProvider).clearCatalogue();
|
||||
await _ref.read(sessionStoreProvider).clear();
|
||||
|
||||
_ref.read(syncConfigProvider.notifier).state =
|
||||
_ref.read(syncConfigProvider).copyWith(sessionToken: '');
|
||||
|
||||
_session = null;
|
||||
if (clearsCatalogueOnSignOut) {
|
||||
await _ref.read(localStoreProvider).clearCatalogue();
|
||||
}
|
||||
state = const Unauthenticated();
|
||||
}
|
||||
|
||||
@@ -309,6 +251,24 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
|
||||
return s is Authenticated ? s.user : null;
|
||||
});
|
||||
|
||||
/// Which credential is holding this session open, or null before sign-in.
|
||||
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
|
||||
final s = ref.watch(authControllerProvider);
|
||||
return s is Authenticated ? s.login : null;
|
||||
});
|
||||
|
||||
/// True when the terminal is locked down to the billing screen.
|
||||
///
|
||||
/// The one flag the shell reads: no sidebar, no back-office modules, sign-out
|
||||
/// and events promoted to the header.
|
||||
final isCashierModeProvider = Provider<bool>(
|
||||
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier,
|
||||
);
|
||||
|
||||
final isAdminModeProvider = Provider<bool>(
|
||||
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin,
|
||||
);
|
||||
|
||||
/// True while anyone is still on a seeded or admin-reset PIN.
|
||||
final mustChangePinProvider = Provider<bool>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,22 @@ import '../../../app/providers.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/widgets/numeric_keypad.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../providers/customer_providers.dart';
|
||||
|
||||
/// Attaches a customer to the current bill using nothing but a mobile number.
|
||||
/// Attaches a customer to the current bill: a mobile number, a name, or
|
||||
/// neither.
|
||||
///
|
||||
/// Registration is deliberately minimal: an unknown number can be saved with
|
||||
/// just a name, or the whole step skipped. Nothing here blocks the sale.
|
||||
/// Deliberately not a lookup screen. There is no search against the customers
|
||||
/// already on the terminal, no recent list, and no membership tier — a cashier
|
||||
/// at a queue keys the number, keys the name if the shopper gives one, and
|
||||
/// carries on. A number that turns out to be registered already is reused
|
||||
/// silently rather than being turned into a decision the counter has to make.
|
||||
///
|
||||
/// Nothing here blocks the sale: Skip closes it with a walk-in bill.
|
||||
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -35,13 +39,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget {
|
||||
_CustomerCaptureSheetState();
|
||||
}
|
||||
|
||||
class _CustomerCaptureSheetState
|
||||
extends ConsumerState<_CustomerCaptureSheet> {
|
||||
class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
|
||||
String _digits = '';
|
||||
final _name = TextEditingController();
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
@@ -54,15 +59,14 @@ class _CustomerCaptureSheetState
|
||||
_digits += d;
|
||||
_error = null;
|
||||
});
|
||||
if (_digits.length == AppConstants.mobileNumberLength) {
|
||||
ref.read(customerLookupProvider.notifier).search(_digits);
|
||||
}
|
||||
}
|
||||
|
||||
void _backspace() {
|
||||
if (_digits.isEmpty) return;
|
||||
setState(() => _digits = _digits.substring(0, _digits.length - 1));
|
||||
ref.read(customerLookupProvider.notifier).reset();
|
||||
setState(() {
|
||||
_digits = _digits.substring(0, _digits.length - 1);
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
@@ -70,7 +74,6 @@ class _CustomerCaptureSheetState
|
||||
_digits = '';
|
||||
_error = null;
|
||||
});
|
||||
ref.read(customerLookupProvider.notifier).reset();
|
||||
}
|
||||
|
||||
void _attachAndClose(Customer? customer) {
|
||||
@@ -78,9 +81,23 @@ class _CustomerCaptureSheetState
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// Saves with whatever was given. Both fields are optional: a bare number
|
||||
/// is still worth keeping, because it is what the WhatsApp bill is sent to.
|
||||
Future<void> _quickRegister() async {
|
||||
/// Saves the number under the name given, then puts it on the bill.
|
||||
///
|
||||
/// The name is optional — a bare number is still worth keeping, because it
|
||||
/// is what the WhatsApp bill is sent to.
|
||||
///
|
||||
/// A number already on file is the ordinary case, not an error: the existing
|
||||
/// record is picked up and attached, and a freshly typed name is written
|
||||
/// over the stored one so a correction at the counter sticks. The cashier
|
||||
/// sees the same thing either way, which is the point of not having a lookup
|
||||
/// step.
|
||||
Future<void> _save() async {
|
||||
if (!_complete) {
|
||||
setState(() => _error = 'Enter all '
|
||||
'${AppConstants.mobileNumberLength} digits of the mobile number.');
|
||||
return;
|
||||
}
|
||||
|
||||
final typed = _name.text.trim();
|
||||
final name = typed.isEmpty
|
||||
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
||||
@@ -91,24 +108,43 @@ class _CustomerCaptureSheetState
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final repo = ref.read(customerRepositoryProvider);
|
||||
|
||||
try {
|
||||
final created = await ref.read(customerRepositoryProvider).create(
|
||||
Customer(id: '', name: name, mobile: _digits),
|
||||
);
|
||||
ref.invalidate(recentCustomersProvider);
|
||||
final created = await repo.create(
|
||||
Customer(id: '', name: name, mobile: _digits),
|
||||
);
|
||||
if (mounted) _attachAndClose(created);
|
||||
} catch (e) {
|
||||
} on StateError {
|
||||
// Already registered. Reuse the row rather than making the counter
|
||||
// reconcile it.
|
||||
try {
|
||||
final existing = await repo.findByMobile(_digits);
|
||||
if (existing == null) throw StateError('lookup failed');
|
||||
|
||||
final updated = typed.isEmpty || typed == existing.name
|
||||
? existing
|
||||
: await repo.update(existing.copyWith(name: typed));
|
||||
|
||||
if (mounted) _attachAndClose(updated);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = 'Could not save customer. Try again.';
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = e is StateError ? e.message : 'Could not save customer.';
|
||||
_error = 'Could not save customer. Try again.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lookup = ref.watch(customerLookupProvider);
|
||||
final attached = ref.watch(
|
||||
cartControllerProvider.select((c) => c.customer),
|
||||
);
|
||||
@@ -130,36 +166,57 @@ class _CustomerCaptureSheetState
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_grabber(),
|
||||
_header(attached),
|
||||
const Divider(height: 1),
|
||||
// Everything inside is capped and centred. A modal sheet on a
|
||||
// 27-inch till used to run the full width of the screen, which
|
||||
// put the keypad and the fields at opposite ends of the desk.
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Side by side once there is room for both columns.
|
||||
final wide = constraints.maxWidth >= 720;
|
||||
final entry = _entryColumn();
|
||||
final result = _resultColumn(lookup);
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_header(attached),
|
||||
const Divider(height: 1),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xxl,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
// Side by side once there is room for both.
|
||||
final wide = box.maxWidth >= 660;
|
||||
final entry = _entryColumn();
|
||||
final details = _detailsColumn();
|
||||
|
||||
if (!wide) {
|
||||
return Column(
|
||||
children: [
|
||||
entry,
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
result,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: entry),
|
||||
const SizedBox(width: AppSpacing.xxl),
|
||||
Expanded(child: result),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (!wide) {
|
||||
return Column(
|
||||
children: [
|
||||
entry,
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
details,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 300, child: entry),
|
||||
const SizedBox(width: AppSpacing.xxl),
|
||||
Expanded(child: details),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -184,11 +241,23 @@ class _CustomerCaptureSheetState
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
0,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
border: Border.all(color: AppColors.primaryBorder),
|
||||
),
|
||||
child: const Icon(Icons.person_add_alt_1_outlined,
|
||||
size: 20, color: AppColors.primary,),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -197,10 +266,16 @@ class _CustomerCaptureSheetState
|
||||
Text(
|
||||
attached == null ? 'Add customer' : 'Change customer',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Optional — for loyalty points and tier discounts',
|
||||
'Optional — number and name, or skip',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
@@ -212,30 +287,45 @@ class _CustomerCaptureSheetState
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
TextButton(
|
||||
onPressed: () => _attachAndClose(null),
|
||||
onPressed: _saving ? null : () => _attachAndClose(null),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
minimumSize: const Size(0, 38),
|
||||
),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
color: AppColors.textTertiary,
|
||||
tooltip: 'Close',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- Entry
|
||||
Widget _entryColumn() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_display(),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
_complete
|
||||
? 'Number complete'
|
||||
: '${AppConstants.mobileNumberLength - _digits.length} more '
|
||||
'digit(s)',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
maxWidth: 340,
|
||||
maxWidth: 300,
|
||||
onKey: _append,
|
||||
onBackspace: _backspace,
|
||||
onClear: _clear,
|
||||
@@ -244,237 +334,96 @@ class _CustomerCaptureSheetState
|
||||
],
|
||||
);
|
||||
|
||||
Widget _display() => Container(
|
||||
height: 68,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.primaryBorder),
|
||||
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
|
||||
Widget _display() {
|
||||
final filled = _digits.isNotEmpty;
|
||||
final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5);
|
||||
final tail = _digits.length <= 5 ? '' : _digits.substring(5);
|
||||
|
||||
return Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: filled ? AppColors.surface : AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(
|
||||
color: filled ? AppColors.primary : AppColors.border,
|
||||
width: filled ? 1.4 : 1,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brXs,
|
||||
),
|
||||
child: const Text(
|
||||
'+91',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
// FittedBox guarantees ten digits fit at any sheet width.
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_digits.isEmpty
|
||||
? '– – – – – – – – – –'
|
||||
: _digits.split('').join(' '),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: _digits.isEmpty
|
||||
? AppColors.textTertiary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_digits.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: _clear,
|
||||
icon: const Icon(Icons.close_rounded, size: 20),
|
||||
color: AppColors.textTertiary,
|
||||
tooltip: 'Clear',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _resultColumn(CustomerLookupState state) => switch (state) {
|
||||
LookupIdle() => _idle(),
|
||||
LookupSearching() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
LookupFound(:final customer) => _found(customer),
|
||||
LookupNotFound() => _notFound(),
|
||||
LookupError(:final message) => _message(
|
||||
Icons.error_outline_rounded,
|
||||
AppColors.danger,
|
||||
message,
|
||||
),
|
||||
};
|
||||
|
||||
Widget _idle() {
|
||||
final recent = ref.watch(recentCustomersProvider).value ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_message(
|
||||
Icons.dialpad_rounded,
|
||||
AppColors.textTertiary,
|
||||
'Key in a 10-digit mobile number — the lookup runs automatically. '
|
||||
'Both fields are optional.',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_nameField(),
|
||||
if (recent.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Recent',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
for (final c in recent.take(4))
|
||||
ActionChip(
|
||||
avatar: CircleAvatar(
|
||||
radius: 11,
|
||||
backgroundColor: AppColors.primarySurface,
|
||||
child: Text(
|
||||
Formatters.initials(c.name),
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: filled
|
||||
? Text(
|
||||
tail.isEmpty ? head : '$head $tail',
|
||||
style: AppTypography.money(23).copyWith(
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Mobile number',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textTertiary.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
c.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12.5),
|
||||
),
|
||||
onPressed: () => _attachAndClose(c),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (filled)
|
||||
IconButton(
|
||||
onPressed: _saving ? null : _clear,
|
||||
icon: const Icon(Icons.backspace_outlined, size: 18),
|
||||
color: AppColors.textTertiary,
|
||||
tooltip: 'Clear',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _found(Customer c) => Column(
|
||||
// --------------------------------------------------------------- Details
|
||||
Widget _detailsColumn() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: AppColors.surface,
|
||||
child: Text(
|
||||
Formatters.initials(c.name),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
c.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Formatters.mobile(c.mobile),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
StatusPill.tier(c.tier, dense: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
'${c.loyaltyPoints}', 'points held',),
|
||||
),
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
Formatters.money(c.redeemableValue),
|
||||
'redeemable',
|
||||
),
|
||||
),
|
||||
if (c.tier.discountRate > 0)
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
Formatters.percent(c.tier.discountRate),
|
||||
'auto discount',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
_hint(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
TextField(
|
||||
controller: _name,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
enabled: !_saving,
|
||||
onSubmitted: (_) => _save(),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Customer name',
|
||||
hintText: 'Optional',
|
||||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PrimaryButton(
|
||||
label: 'Use this customer',
|
||||
icon: Icons.check_rounded,
|
||||
large: true,
|
||||
onPressed: () => _attachAndClose(c),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _notFound() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_message(
|
||||
Icons.person_search_rounded,
|
||||
AppColors.warning,
|
||||
'New number. Add a name if you have it — the bill can be sent to '
|
||||
'this number on WhatsApp either way.',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_nameField(),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
@@ -488,73 +437,37 @@ class _CustomerCaptureSheetState
|
||||
icon: Icons.person_add_alt_1_rounded,
|
||||
large: true,
|
||||
busy: _saving,
|
||||
onPressed: _quickRegister,
|
||||
onPressed: _complete && !_saving ? _save : null,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
PrimaryButton(
|
||||
label: 'Continue without customer',
|
||||
label: 'Skip — no customer',
|
||||
tone: ButtonTone.neutral,
|
||||
onPressed: _saving ? null : () => _attachAndClose(null),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _nameField() => TextField(
|
||||
controller: _name,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
enabled: !_saving,
|
||||
onSubmitted: (_) => _quickRegister(),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Customer name',
|
||||
hintText: 'Optional',
|
||||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _miniStat(String value, String label) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _message(IconData icon, Color color, String text) => Container(
|
||||
Widget _hint() => Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 19, color: color),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Icon(Icons.dialpad_rounded,
|
||||
size: 19, color: AppColors.textTertiary,),
|
||||
SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
'Key in the mobile number, add a name if the shopper gives '
|
||||
'one, then save. The name is optional and the whole step can '
|
||||
'be skipped — the sale is never held up by it.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
@@ -12,6 +13,7 @@ import '../../../core/widgets/glass_card.dart';
|
||||
import '../../../core/widgets/numeric_keypad.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/promo.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
@@ -97,7 +99,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_customerCard(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Only builds anything when a campaign is on the bill or one is
|
||||
// within reach, so a shop running none sees no empty card.
|
||||
_offersCard(),
|
||||
_methodsCard(controller, state),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Carries the rest of the column's height, and answers the
|
||||
// question a customer asks at the counter — what am I paying
|
||||
// for — without going back to the bill.
|
||||
_summaryCard(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -112,15 +122,36 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
// One scroll view over both columns, equal flex, centred and capped.
|
||||
//
|
||||
// Two independent scrollers with a 4:5 split were what made this read
|
||||
// as lopsided: the columns started at different widths, ended at
|
||||
// different heights, and the whole thing sat against the top-left of
|
||||
// a much larger window. The minimum height fills the viewport so the
|
||||
// pair sits in the middle of the screen instead of clinging to the
|
||||
// top edge, and the cap stops the cards stretching into bands on a
|
||||
// wide till display.
|
||||
final minHeight = constraints.maxHeight.isFinite
|
||||
? (constraints.maxHeight - pad * 2).clamp(0.0, double.infinity)
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(pad),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
|
||||
],
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1340),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(child: right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -289,57 +320,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.splits.isNotEmpty) ...[
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Split tenders',
|
||||
style:
|
||||
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.clearSplits,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final e in state.splits.asMap().entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(e.value.method.emoji,
|
||||
style: const TextStyle(fontSize: 15),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value.method.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13.5),
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value.amount),
|
||||
style: AppTypography.money(13.5),),
|
||||
IconButton(
|
||||
onPressed: () => controller.removeSplit(e.key),
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
color: AppColors.textTertiary,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove tender',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -354,118 +334,191 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Amount entry for whichever method is active.
|
||||
///
|
||||
/// Every method works the same way — 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 shortcuts and a change-due row, since only cash can be
|
||||
/// over-tendered; a method that captures a reference (card, UPI, gift card)
|
||||
/// gets that field below the keypad.
|
||||
Widget _amountTender(PaymentController controller, PaymentState state) {
|
||||
final cash = state.activeMethod.needsChange;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
// Wide enough to stand the shortcuts beside the keypad instead of
|
||||
// above it. A centred 330px keypad in a 560px column was the other
|
||||
// half of the lopsided look — the space beside it did nothing.
|
||||
final sideBySide = cash && box.maxWidth >= 500;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(methodIcon(state.activeMethod),
|
||||
size: 20, color: AppColors.primary,),
|
||||
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),
|
||||
_amountField(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
if (!sideBySide) ...[
|
||||
_shortcutWrap(controller, cash),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
|
||||
if (cash) ...[
|
||||
_changeRow(controller.changeDue),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
|
||||
if (sideBySide)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _shortcutColumn(controller)),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
SizedBox(width: 296, child: _keypad()),
|
||||
],
|
||||
)
|
||||
else
|
||||
Center(child: _keypad()),
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
_partPaymentAction(controller, state),
|
||||
_receivedSoFar(controller, state),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _keypad() => NumericKeypad(
|
||||
allowDecimal: true,
|
||||
maxWidth: 330,
|
||||
onKey: _appendCash,
|
||||
onBackspace: _backspaceCash,
|
||||
);
|
||||
|
||||
/// The typed amount.
|
||||
///
|
||||
/// The symbol sits in the same run as the digits and in the same style. It
|
||||
/// used to be a separate, smaller, grey glyph, which rendered as a mismatched
|
||||
/// mark floating beside the number rather than part of it.
|
||||
Widget _amountField() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'₹${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
|
||||
style: AppTypography.money(30),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const List<int> _notes = [50, 100, 200, 500, 2000];
|
||||
|
||||
/// Shortcuts above the keypad, for the narrow layout.
|
||||
Widget _shortcutWrap(PaymentController controller, bool cash) {
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.done_all_rounded, size: 15),
|
||||
label: const Text('Exact'),
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
),
|
||||
// Denomination shortcuts only make sense for physical notes.
|
||||
if (cash)
|
||||
for (final note in _notes)
|
||||
ActionChip(
|
||||
label: Text('₹$note'),
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Shortcuts beside the keypad, for the wide layout.
|
||||
Widget _shortcutColumn(PaymentController controller) {
|
||||
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(
|
||||
cash
|
||||
? 'Cash received'
|
||||
: '${state.activeMethod.label} amount received',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
icon: const Icon(Icons.done_all_rounded, size: 17),
|
||||
label: Text('Exact ${Formatters.money(controller.balanceDue)}'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
backgroundColor: AppColors.primarySurface,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
for (final note in _notes) ...[
|
||||
OutlinedButton(
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
child: Text('+ ₹$note'),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('₹',
|
||||
style:
|
||||
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
||||
style: AppTypography.money(28),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.done_all_rounded, size: 15),
|
||||
label: const Text('Exact'),
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
),
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (cash) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_changeRow(controller.changeDue),
|
||||
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
allowDecimal: true,
|
||||
maxWidth: 330,
|
||||
onKey: _appendCash,
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -513,22 +566,380 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _splitButton(PaymentController controller, {double? amount}) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: controller.balanceDue > 0
|
||||
? () {
|
||||
controller.addSplit(
|
||||
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
|
||||
);
|
||||
/// Takes part of the bill on the current method.
|
||||
///
|
||||
/// This is the old "Add as split payment" button, and it stages exactly the
|
||||
/// same tender — but it no longer asks the cashier to know what a split is,
|
||||
/// or to press it for a payment that is not one. It appears only when the
|
||||
/// typed amount is genuinely short of the balance, and says what it will do
|
||||
/// in the customer's terms: take this much now, leave that much to pay.
|
||||
Widget _partPaymentAction(
|
||||
PaymentController controller,
|
||||
PaymentState state,
|
||||
) {
|
||||
final entered = double.tryParse(_cashBuffer) ?? 0;
|
||||
final due = controller.balanceDue;
|
||||
final short = entered > 0.009 && entered < due - 0.009;
|
||||
|
||||
if (!short) return const SizedBox.shrink();
|
||||
|
||||
final rest = due - entered;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
controller.addSplit(amount: entered);
|
||||
setState(() => _cashBuffer = '');
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
||||
label: const Text('Add as split payment'),
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
},
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: Text(
|
||||
'Take ${Formatters.money(entered)} by '
|
||||
'${state.activeMethod.label}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 48)),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'${Formatters.money(rest)} left to pay — pick another method for '
|
||||
'the rest.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tenders already staged against this bill, and what is still outstanding.
|
||||
Widget _receivedSoFar(PaymentController controller, PaymentState state) {
|
||||
if (state.splits.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Received so far',
|
||||
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.clearSplits,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final e in state.splits.asMap().entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(methodIcon(e.value.method),
|
||||
size: 17, color: AppColors.textSecondary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value.method.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13.5),
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value.amount),
|
||||
style: AppTypography.money(13.5),),
|
||||
IconButton(
|
||||
onPressed: () => controller.removeSplit(e.key),
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
color: AppColors.textTertiary,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove tender',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Still to pay',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Formatters.money(controller.balanceDue),
|
||||
style: AppTypography.money(
|
||||
15,
|
||||
color: controller.balanceDue > 0
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Offers
|
||||
/// Campaigns on this bill, and the nearest one that is not on it yet.
|
||||
///
|
||||
/// The engine already applies everything that qualifies, silently — the
|
||||
/// shopper only ever saw a discount line. Two things were missing at the
|
||||
/// counter: a cashier could not answer "did the weekend offer come off?"
|
||||
/// without opening the promo module, and nobody could see that a bill was a
|
||||
/// few rupees short of one. The near-miss rows are the point of this card:
|
||||
/// a minimum-bill campaign is worth nothing if the person paying is never
|
||||
/// told they are close to it.
|
||||
///
|
||||
/// Read-only. Nothing here applies or removes a campaign — that stays with
|
||||
/// [PromoEngine], so the till cannot be talked into a discount by hand.
|
||||
Widget _offersCard() {
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final promos = ref.watch(activePromosProvider).value ?? const <Promo>[];
|
||||
|
||||
final applied = cart.appliedPromos;
|
||||
final appliedIds = applied.map((a) => a.promo.id).toSet();
|
||||
final now = DateTime.now();
|
||||
|
||||
// Live today, not already firing, and gated only by a bill minimum this
|
||||
// cart has not reached. A campaign that fails for any other reason —
|
||||
// wrong category, wrong product, wrong day — is not "nearly earned" and
|
||||
// saying so would be a false promise.
|
||||
final withinReach = promos
|
||||
.where((p) =>
|
||||
p.isLiveAt(now) &&
|
||||
!appliedIds.contains(p.id) &&
|
||||
p.minBillValue > 0 &&
|
||||
cart.subtotal < p.minBillValue,)
|
||||
.toList()
|
||||
..sort((a, b) => a.minBillValue.compareTo(b.minBillValue));
|
||||
|
||||
if (applied.isEmpty && withinReach.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.sell_outlined,
|
||||
size: 18, color: AppColors.primary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Offers',
|
||||
style:
|
||||
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (applied.isNotEmpty)
|
||||
Text(
|
||||
'− ${Formatters.money(
|
||||
applied.fold<double>(0, (sum, a) => sum + a.amount),
|
||||
)}',
|
||||
style: AppTypography.money(14, color: AppColors.success),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
for (final a in applied)
|
||||
_offerRow(
|
||||
icon: Icons.check_circle_rounded,
|
||||
tone: AppColors.success,
|
||||
title: a.promo.name,
|
||||
subtitle: a.promo.summary,
|
||||
trailing: '− ${Formatters.money(a.amount)}',
|
||||
),
|
||||
|
||||
// Two is the useful number: the next one to reach and the one
|
||||
// after it. A full list turns a payment screen into a catalogue
|
||||
// of things the shopper is not getting.
|
||||
for (final p in withinReach.take(2))
|
||||
_offerRow(
|
||||
icon: Icons.lock_open_rounded,
|
||||
tone: AppColors.warning,
|
||||
title: p.name,
|
||||
subtitle: '${p.summary} \u00b7 add '
|
||||
'${Formatters.money(p.minBillValue - cart.subtotal)} more '
|
||||
'to reach ${Formatters.money(p.minBillValue)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _offerRow({
|
||||
required IconData icon,
|
||||
required Color tone,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
String? trailing,
|
||||
}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 17, color: tone),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(trailing, style: AppTypography.money(13.5, color: tone)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// --------------------------------------------------------- Bill summary
|
||||
/// What the amount due is made of.
|
||||
Widget _summaryCard() {
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final discounts = cart.lineDiscountTotal + cart.billDiscountTotal;
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Bill summary',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_summaryRow('Subtotal', Formatters.money(cart.subtotal)),
|
||||
if (discounts > 0)
|
||||
_summaryRow(
|
||||
'Discounts',
|
||||
'− ${Formatters.money(discounts)}',
|
||||
tone: AppColors.success,
|
||||
),
|
||||
if (cart.loyaltyRedemptionValue > 0)
|
||||
_summaryRow(
|
||||
'Points redeemed',
|
||||
'− ${Formatters.money(cart.loyaltyRedemptionValue)}',
|
||||
tone: AppColors.success,
|
||||
),
|
||||
if (cart.roundOff != 0)
|
||||
_summaryRow('Round off', Formatters.money(cart.roundOff)),
|
||||
const Divider(height: AppSpacing.xl),
|
||||
_summaryRow(
|
||||
'Total',
|
||||
Formatters.money(cart.grandTotal),
|
||||
strong: true,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
// Prices are GST-inclusive, so this is a breakdown of the total
|
||||
// rather than another line added to it — said plainly, because a
|
||||
// customer reading a tax figure will otherwise try to add it on.
|
||||
Text(
|
||||
'Includes GST ${Formatters.money(cart.taxAmount)} '
|
||||
'(CGST ${Formatters.money(cart.cgst)} + '
|
||||
'SGST ${Formatters.money(cart.sgst)})',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryRow(
|
||||
String label,
|
||||
String value, {
|
||||
Color? tone,
|
||||
bool strong = false,
|
||||
}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: strong ? 14.5 : 13.5,
|
||||
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
|
||||
color: strong
|
||||
? AppColors.textPrimary
|
||||
: (tone ?? AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.money(
|
||||
strong ? 17 : 13.5,
|
||||
color: tone ?? (strong ? AppColors.primary : null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------ Bottom bar
|
||||
Widget _bottomBar(
|
||||
PaymentController controller,
|
||||
@@ -679,7 +1090,11 @@ class _MethodTile extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(method.emoji, style: const TextStyle(fontSize: 22)),
|
||||
Icon(
|
||||
methodIcon(method),
|
||||
size: 22,
|
||||
color: selected ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
method.label,
|
||||
@@ -699,3 +1114,19 @@ class _MethodTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat icon per tender type.
|
||||
///
|
||||
/// These were emoji — 💵 for cash, 💳 for card — which render as small
|
||||
/// photographic pictures on most platforms and as a fallback box on some. Next
|
||||
/// to Material iconography everywhere else on the screen they read as clip
|
||||
/// art pasted into the UI rather than part of it, and the cash one in
|
||||
/// particular looked like a picture of American banknotes on a rupee till.
|
||||
IconData methodIcon(PaymentMethod method) => switch (method) {
|
||||
PaymentMethod.cash => Icons.payments_outlined,
|
||||
PaymentMethod.card => Icons.credit_card_rounded,
|
||||
PaymentMethod.upi => Icons.qr_code_2_rounded,
|
||||
PaymentMethod.wallet => Icons.account_balance_wallet_outlined,
|
||||
PaymentMethod.giftCard => Icons.card_giftcard_rounded,
|
||||
PaymentMethod.loyalty => Icons.stars_rounded,
|
||||
};
|
||||
|
||||
@@ -208,10 +208,19 @@ class CartController extends StateNotifier<Cart> {
|
||||
setQuantity(productId, line.quantity + by);
|
||||
}
|
||||
|
||||
/// Steps a line down, never off the bill.
|
||||
///
|
||||
/// Floors at one deliberately. Taking the last unit away is a removal, and
|
||||
/// removals go through the PIN gate on the close button — a stepper that
|
||||
/// quietly reached zero was a way around it.
|
||||
void decrement(String productId, {double by = 1}) {
|
||||
final line = state.lineFor(productId);
|
||||
if (line == null) return;
|
||||
setQuantity(productId, line.quantity - by);
|
||||
|
||||
final next = line.quantity - by;
|
||||
if (next < 1) return;
|
||||
|
||||
setQuantity(productId, next);
|
||||
}
|
||||
|
||||
void removeLine(String productId) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
|
||||
/// The modules a cashier needs. Deliberately excludes analytics — this
|
||||
/// terminal is for billing, not back-office reporting.
|
||||
enum PosModule {
|
||||
@@ -41,4 +43,37 @@ enum NavSection {
|
||||
PosModule.values.where((m) => m.section == this).toList();
|
||||
}
|
||||
|
||||
/// What a cashier session may open.
|
||||
///
|
||||
/// Billing, and nothing else — not the catalogue, the promos, the sync log or
|
||||
/// the terminal's configuration. The sidebar is hidden in cashier mode anyway,
|
||||
/// so this is the belt to that braces: a module reached some other way (a scan
|
||||
/// handler, a deep link, a stale value left in [activeModuleProvider] from the
|
||||
/// admin's session) still cannot render.
|
||||
const cashierModules = <PosModule>[PosModule.pos];
|
||||
|
||||
final activeModuleProvider = StateProvider<PosModule>((ref) => PosModule.pos);
|
||||
|
||||
/// The modules the current session is allowed to reach.
|
||||
final visibleModulesProvider = Provider<List<PosModule>>((ref) {
|
||||
return ref.watch(isCashierModeProvider) ? cashierModules : PosModule.values;
|
||||
});
|
||||
|
||||
/// [activeModuleProvider], clamped to what this session may open.
|
||||
///
|
||||
/// Read this rather than the raw value anywhere a module decides what gets
|
||||
/// built. An admin who leaves the shell on Settings and hands the till to a
|
||||
/// cashier would otherwise reopen it on Settings.
|
||||
final resolvedModuleProvider = Provider<PosModule>((ref) {
|
||||
final active = ref.watch(activeModuleProvider);
|
||||
final visible = ref.watch(visibleModulesProvider);
|
||||
return visible.contains(active) ? active : PosModule.pos;
|
||||
});
|
||||
|
||||
/// Sections that still have at least one module this session may open.
|
||||
final visibleSectionsProvider = Provider<List<NavSection>>((ref) {
|
||||
final visible = ref.watch(visibleModulesProvider);
|
||||
return NavSection.values
|
||||
.where((s) => s.modules.any(visible.contains))
|
||||
.toList();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,11 @@ import 'pos_view.dart';
|
||||
/// * `1120–1300` sidebar as an icon rail, docked bill
|
||||
/// * `920–1120` icon rail, bill becomes a bottom sheet
|
||||
/// * `< 920` sidebar goes off-canvas behind a menu button
|
||||
///
|
||||
/// In cashier mode the sidebar is not rendered at any width. That session has
|
||||
/// exactly two destinations, and both are reachable from the header — a rail
|
||||
/// holding one live tile is chrome for its own sake. Sign-out and the sync log
|
||||
/// move up with it, since the sidebar was the only place they lived.
|
||||
class PosDashboardScreen extends ConsumerStatefulWidget {
|
||||
const PosDashboardScreen({super.key});
|
||||
|
||||
@@ -119,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final layout = PosLayout.of(context);
|
||||
final module = ref.watch(activeModuleProvider);
|
||||
// Clamped, not raw: a module the admin left active must not carry into a
|
||||
// cashier session.
|
||||
final module = ref.watch(resolvedModuleProvider);
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final isPos = module == PosModule.pos && ready;
|
||||
|
||||
final cashierMode = ref.watch(isCashierModeProvider);
|
||||
final showSidebar = !cashierMode;
|
||||
|
||||
// Only the terminal itself needs the bill docked beside it.
|
||||
final showDockedBill = isPos && !layout.billingIsSheet;
|
||||
final showCartFab = isPos && layout.billingIsSheet;
|
||||
@@ -130,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
backgroundColor: AppColors.background,
|
||||
drawer: layout.sidebarIsDrawer
|
||||
drawer: showSidebar && layout.sidebarIsDrawer
|
||||
? Drawer(
|
||||
width: PosLayout.expandedWidth,
|
||||
backgroundColor: AppColors.surface,
|
||||
@@ -157,13 +167,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
|
||||
if (showSidebar && !layout.sidebarIsDrawer)
|
||||
AppSidebar(mode: layout.sidebar),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
PageHeader(
|
||||
layout: layout,
|
||||
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
|
||||
// Nothing to open in cashier mode, so the button is not
|
||||
// offered rather than opening an empty drawer.
|
||||
onMenuTap: showSidebar
|
||||
? () => _scaffoldKey.currentState?.openDrawer()
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../widgets/category_chips.dart';
|
||||
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
final state = ref.watch(catalogueImportProvider);
|
||||
final running = state is ImportRunning;
|
||||
|
||||
// Pulling the catalogue is an admin job, and a cashier has no Product
|
||||
// Import module to be sent to. Offering them a button that opens a screen
|
||||
// they cannot reach is worse than telling them who to ask.
|
||||
final cashier = ref.watch(isCashierModeProvider);
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Text(
|
||||
'Import products to start billing',
|
||||
cashier
|
||||
? 'No products on this terminal'
|
||||
: 'Import products to start billing',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Text(
|
||||
'This terminal has no catalogue yet. Pull the current products '
|
||||
'once at the start of your shift — after that everything runs '
|
||||
'offline.',
|
||||
Text(
|
||||
cashier
|
||||
? 'Nothing has been imported for this shift yet. Ask an '
|
||||
'admin to sign in and pull the catalogue — once they '
|
||||
'have, sign in again and everything runs offline.'
|
||||
: 'This terminal has no catalogue yet. Pull the current '
|
||||
'products once at the start of your shift — after that '
|
||||
'everything runs offline.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.6,
|
||||
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
|
||||
if (running) ...[
|
||||
if (!cashier && state is ImportRunning) ...[
|
||||
Text(
|
||||
state.stage,
|
||||
style: const TextStyle(
|
||||
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
if (state is ImportFailed) ...[
|
||||
if (!cashier && state is ImportFailed) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
@@ -171,23 +183,52 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
|
||||
PrimaryButton(
|
||||
label: 'Import catalogue now',
|
||||
icon: Icons.cloud_download_rounded,
|
||||
large: true,
|
||||
busy: running,
|
||||
onPressed: running
|
||||
? null
|
||||
: () => ref.read(catalogueImportProvider.notifier).run(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextButton.icon(
|
||||
onPressed: () => ref
|
||||
.read(activeModuleProvider.notifier)
|
||||
.state = PosModule.productImport,
|
||||
icon: const Icon(Icons.open_in_new_rounded, size: 16),
|
||||
label: const Text('Open Product Import'),
|
||||
),
|
||||
if (cashier)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.infoSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.admin_panel_settings_outlined,
|
||||
size: 18, color: AppColors.info,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Importing the catalogue is an admin job. Nothing '
|
||||
'can be billed here until it has been done.',
|
||||
style: TextStyle(
|
||||
color: AppColors.info,
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
PrimaryButton(
|
||||
label: 'Import catalogue now',
|
||||
icon: Icons.cloud_download_rounded,
|
||||
large: true,
|
||||
busy: running,
|
||||
onPressed: running
|
||||
? null
|
||||
: () => ref.read(catalogueImportProvider.notifier).run(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextButton.icon(
|
||||
onPressed: () => ref
|
||||
.read(activeModuleProvider.notifier)
|
||||
.state = PosModule.productImport,
|
||||
icon: const Icon(Icons.open_in_new_rounded, size: 16),
|
||||
label: const Text('Open Product Import'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,16 +5,18 @@ 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.
|
||||
/// Prompts for the removal PIN before taking a rung item back off a bill, and
|
||||
/// resolves `true` only once it verifies.
|
||||
///
|
||||
/// The PIN is the one an admin sets in Settings, and an admin's own staff PIN
|
||||
/// always works too — so a cashier can void a line at the counter without an
|
||||
/// admin walking over, and the owner is never locked out of their own till.
|
||||
///
|
||||
/// 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(
|
||||
Future<bool> requireVoidPin(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String reason,
|
||||
@@ -67,11 +69,11 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
||||
});
|
||||
|
||||
final store = ref.read(localStoreProvider);
|
||||
final user = await store.staff.authenticate(_pin);
|
||||
final ok = await store.voidPin.verify(_pin);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (user == null) {
|
||||
if (!ok) {
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_error = 'Incorrect PIN.';
|
||||
@@ -80,16 +82,6 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -112,7 +104,7 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Admin PIN required',
|
||||
'Removal PIN required',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
||||
@@ -6,9 +6,9 @@ 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/widgets/brand_mark.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../sync/widgets/sign_out_dialog.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
@@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final section in NavSection.values)
|
||||
// Sections with nothing this session may open are not
|
||||
// rendered as empty headings.
|
||||
for (final section in ref.watch(visibleSectionsProvider))
|
||||
_Section(
|
||||
section: section,
|
||||
expanded: expanded,
|
||||
@@ -69,8 +71,6 @@ class AppSidebar extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_LogoutTile(expanded: expanded),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -94,23 +94,7 @@ class _Brand extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const BrandMark(size: 36),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Flexible(
|
||||
@@ -228,7 +212,8 @@ class _Section extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final active = ref.watch(activeModuleProvider);
|
||||
final active = ref.watch(resolvedModuleProvider);
|
||||
final visible = ref.watch(visibleModulesProvider);
|
||||
final cartCount = ref.watch(cartItemCountProvider);
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
@@ -255,7 +240,7 @@ class _Section extends ConsumerWidget {
|
||||
),
|
||||
child: Divider(height: 1),
|
||||
),
|
||||
for (final module in section.modules)
|
||||
for (final module in section.modules.where(visible.contains))
|
||||
_NavTile(
|
||||
module: module,
|
||||
expanded: expanded,
|
||||
@@ -434,48 +419,3 @@ class _Badge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogoutTile extends ConsumerWidget {
|
||||
const _LogoutTile({required this.expanded});
|
||||
|
||||
final bool expanded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => showSignOutDialog(context, ref),
|
||||
borderRadius: AppRadius.brSm,
|
||||
child: Container(
|
||||
height: AppSizes.navItemHeight,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: expanded ? AppSpacing.md : 0,
|
||||
),
|
||||
alignment: expanded ? Alignment.centerLeft : Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.logout_rounded,
|
||||
size: 19, color: AppColors.danger,),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Text(
|
||||
'Logout',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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';
|
||||
|
||||
/// Always-visible bill on the right of the dashboard.
|
||||
class BillingPanel extends ConsumerWidget {
|
||||
@@ -66,8 +65,6 @@ class BillingPanel extends ConsumerWidget {
|
||||
controller,
|
||||
line.product.id,
|
||||
),
|
||||
onDiscount: () =>
|
||||
showLineDiscountSheet(context, ref, line),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -79,37 +76,24 @@ 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.
|
||||
/// Once an item is on the bill, taking it back off needs the removal PIN an
|
||||
/// admin sets in Settings — a cashier can always start an entirely new sale
|
||||
/// instead. This is the one gate every removal path goes through, so the
|
||||
/// close button and the swipe can never drift apart.
|
||||
Future<void> _removeLine(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
CartController controller,
|
||||
String productId,
|
||||
) async {
|
||||
final ok = await requireAdminPin(
|
||||
final ok = await requireVoidPin(
|
||||
context,
|
||||
ref,
|
||||
reason: 'Removing a scanned item from the bill needs admin approval.',
|
||||
reason: 'Removing a scanned item from the bill needs the removal PIN.',
|
||||
);
|
||||
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});
|
||||
|
||||
@@ -118,7 +102,6 @@ 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;
|
||||
@@ -127,8 +110,9 @@ class _Header extends ConsumerWidget {
|
||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||
padding: EdgeInsets.symmetric(horizontal: contentPadding),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Title and count read as one label, so they sit together rather
|
||||
// than being pushed to opposite ends by a space-between row.
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Cart',
|
||||
@@ -137,9 +121,12 @@ class _Header extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (cart.isNotEmpty) ...[
|
||||
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brPill,
|
||||
@@ -154,52 +141,21 @@ class _Header extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(),
|
||||
SizedBox(),
|
||||
|
||||
Spacer(),
|
||||
const Spacer(),
|
||||
|
||||
|
||||
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
||||
// 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Undo, Park and Clear used to sit here as three icon buttons. They
|
||||
// are the least-pressed controls on the panel and they were the
|
||||
// first thing the eye landed on, above the bill itself. Undo is on
|
||||
// F8; Park and Clear moved down beside the total, next to the button
|
||||
// a cashier is already reaching for.
|
||||
if (inSheet)
|
||||
_IconAction(
|
||||
icon: Icons.close_rounded,
|
||||
tooltip: 'Close',
|
||||
color: AppColors.textSecondary,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -313,21 +269,6 @@ class _Summary extends ConsumerWidget {
|
||||
.join(', '),
|
||||
),
|
||||
|
||||
InkWell(
|
||||
onTap: () => showBillDiscountSheet(context, ref),
|
||||
borderRadius: AppRadius.brXs,
|
||||
child: _Row(
|
||||
label: 'Discount',
|
||||
value: cart.manualBillDiscountAmount > 0
|
||||
? '-${Formatters.money(cart.manualBillDiscountAmount)}'
|
||||
: '-${Formatters.money(0)}',
|
||||
valueColor: cart.manualBillDiscountAmount > 0
|
||||
? AppColors.success
|
||||
: null,
|
||||
trailingIcon: Icons.edit_outlined,
|
||||
),
|
||||
),
|
||||
|
||||
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
|
||||
InkWell(
|
||||
onTap: () => cart.pointsRedeemed > 0
|
||||
@@ -466,26 +407,33 @@ class _Actions extends ConsumerWidget {
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xl,
|
||||
),
|
||||
child: PrimaryButton(
|
||||
label: 'CHARGE',
|
||||
large: true,
|
||||
onPressed: enabled
|
||||
? () async {
|
||||
// Ask once per bill, before payment. Skipping is one tap and
|
||||
// leaves the sale as walk-in.
|
||||
if (ref.read(cartControllerProvider).customer == null) {
|
||||
await showCustomerCaptureSheet(context);
|
||||
}
|
||||
// Navigation result is not needed here.
|
||||
if (context.mounted) unawaited(context.push(AppRoutes.payment));
|
||||
}
|
||||
: null,
|
||||
trailing: enabled
|
||||
? Text(
|
||||
Formatters.money(cart.grandTotal),
|
||||
style: AppTypography.money(21, color: Colors.white),
|
||||
)
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
label: 'CHARGE',
|
||||
large: true,
|
||||
onPressed: enabled
|
||||
? () async {
|
||||
// Ask once per bill, before payment. Skipping is one tap
|
||||
// and leaves the sale as walk-in.
|
||||
if (ref.read(cartControllerProvider).customer == null) {
|
||||
await showCustomerCaptureSheet(context);
|
||||
}
|
||||
// Navigation result is not needed here.
|
||||
if (context.mounted) {
|
||||
unawaited(context.push(AppRoutes.payment));
|
||||
}
|
||||
}
|
||||
: null,
|
||||
trailing: enabled
|
||||
? Text(
|
||||
Formatters.money(cart.grandTotal),
|
||||
style: AppTypography.money(21, color: Colors.white),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget {
|
||||
required this.onIncrement,
|
||||
required this.onDecrement,
|
||||
required this.onRemove,
|
||||
this.onDiscount,
|
||||
});
|
||||
|
||||
final CartLine line;
|
||||
final VoidCallback onIncrement;
|
||||
final VoidCallback onDecrement;
|
||||
final VoidCallback onRemove;
|
||||
final VoidCallback? onDiscount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget {
|
||||
return Dismissible(
|
||||
key: ValueKey('dismiss_${p.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
onDismissed: (_) => onRemove(),
|
||||
// Confirm rather than dismiss: [onRemove] opens the PIN dialog, and a
|
||||
// refused PIN must leave the line exactly where it was. Dismissing first
|
||||
// and asking after left the row gone from the screen but still in the
|
||||
// cart — and Flutter asserting about a dismissed widget still in the
|
||||
// tree. Returning false always is correct: when the PIN is accepted the
|
||||
// line disappears because the cart changed, not because of the swipe.
|
||||
confirmDismiss: (_) async {
|
||||
onRemove();
|
||||
return false;
|
||||
},
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: AppSpacing.xl),
|
||||
@@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget {
|
||||
color: AppColors.textTertiary,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove',
|
||||
tooltip: 'Remove from bill (needs the removal PIN)',
|
||||
),
|
||||
],),
|
||||
|
||||
@@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget {
|
||||
onIncrement: onIncrement,
|
||||
onDecrement: onDecrement,
|
||||
),
|
||||
if (onDiscount != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
IconButton(
|
||||
onPressed: onDiscount,
|
||||
icon: const Icon(Icons.local_offer_outlined, size: 17),
|
||||
color: AppColors.textSecondary,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Line discount',
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget {
|
||||
borderRadius: AppRadius.brSm,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
// Minus stops at one rather than emptying the line. Dropping to zero
|
||||
// was a silent removal that skipped the PIN the close button asks for —
|
||||
// two taps of a stepper should not be a way around the till's only
|
||||
// theft control.
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
_btn(Icons.remove_rounded, onDecrement),
|
||||
_btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 42),
|
||||
alignment: Alignment.center,
|
||||
@@ -226,7 +226,7 @@ class _Stepper extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback onTap) => Material(
|
||||
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Icon(icon, size: 17, color: AppColors.primary),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 17,
|
||||
color: onTap == null
|
||||
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../domain/entities/cart.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
|
||||
Future<void> showLineDiscountSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
CartLine line,
|
||||
) {
|
||||
return _show(
|
||||
context: context,
|
||||
title: line.product.name,
|
||||
subtitle: 'Line value ${Formatters.money(line.grossAmount)}',
|
||||
current: line.discount,
|
||||
onApply: (d) => ref
|
||||
.read(cartControllerProvider.notifier)
|
||||
.applyLineDiscount(line.product.id, d),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showBillDiscountSheet(BuildContext context, WidgetRef ref) {
|
||||
final cart = ref.read(cartControllerProvider);
|
||||
return _show(
|
||||
context: context,
|
||||
title: 'Bill discount',
|
||||
subtitle: 'Subtotal ${Formatters.money(cart.subtotal)}',
|
||||
current: cart.billDiscount,
|
||||
onApply: (d) =>
|
||||
ref.read(cartControllerProvider.notifier).applyBillDiscount(d),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _show({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required Discount current,
|
||||
required ValueChanged<Discount> onApply,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _DiscountSheet(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
current: current,
|
||||
onApply: onApply,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DiscountSheet extends StatefulWidget {
|
||||
const _DiscountSheet({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.current,
|
||||
required this.onApply,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Discount current;
|
||||
final ValueChanged<Discount> onApply;
|
||||
|
||||
@override
|
||||
State<_DiscountSheet> createState() => _DiscountSheetState();
|
||||
}
|
||||
|
||||
class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
late DiscountType _type =
|
||||
widget.current.type == DiscountType.none
|
||||
? DiscountType.percentage
|
||||
: widget.current.type;
|
||||
late final TextEditingController _value = TextEditingController(
|
||||
text: widget.current.isActive
|
||||
? widget.current.value.toStringAsFixed(0)
|
||||
: '',
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_value.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _apply() {
|
||||
final v = double.tryParse(_value.text.trim()) ?? 0;
|
||||
widget.onApply(
|
||||
v <= 0 ? Discount.none : Discount(type: _type, value: v),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
Text(widget.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),),
|
||||
const SizedBox(height: 2),
|
||||
Text(widget.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
|
||||
SegmentedButton<DiscountType>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: DiscountType.percentage,
|
||||
label: Text('Percent'),
|
||||
icon: Icon(Icons.percent_rounded, size: 17),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: DiscountType.flat,
|
||||
label: Text('Flat'),
|
||||
icon: Icon(Icons.currency_rupee_rounded, size: 17),
|
||||
),
|
||||
],
|
||||
selected: {_type},
|
||||
onSelectionChanged: (s) => setState(() => _type = s.first),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
TextField(
|
||||
controller: _value,
|
||||
autofocus: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
|
||||
],
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
prefixText: _type == DiscountType.flat ? '₹ ' : null,
|
||||
suffixText: _type == DiscountType.percentage ? '%' : null,
|
||||
),
|
||||
onSubmitted: (_) => _apply(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
children: (_type == DiscountType.percentage
|
||||
? const [5, 10, 15, 20, 25]
|
||||
: const [10, 20, 50, 100, 200])
|
||||
.map((v) => ActionChip(
|
||||
label: Text(_type == DiscountType.percentage
|
||||
? '$v%'
|
||||
: '₹$v',),
|
||||
onPressed: () =>
|
||||
setState(() => _value.text = v.toString()),
|
||||
),)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label: 'Remove',
|
||||
tone: ButtonTone.neutral,
|
||||
onPressed: () {
|
||||
widget.onApply(Discount.none);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PrimaryButton(
|
||||
label: 'Apply discount',
|
||||
icon: Icons.check_rounded,
|
||||
onPressed: _apply,
|
||||
),
|
||||
),
|
||||
],),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/brand_mark.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../shift/widgets/session_end_sheet.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
|
||||
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final showStatus = box.maxWidth >= 720;
|
||||
return _bar(context, ref, compact, showStatus);
|
||||
// The brand block only earns its space once the bar is genuinely wide;
|
||||
// below that it would push the actions off the end.
|
||||
final showBrand = box.maxWidth >= 1000;
|
||||
return _bar(context, ref, compact, showStatus, showBrand);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -42,10 +48,14 @@ class PageHeader extends ConsumerWidget {
|
||||
WidgetRef ref,
|
||||
bool compact,
|
||||
bool showStatus,
|
||||
bool showBrand,
|
||||
) {
|
||||
final module = ref.watch(activeModuleProvider);
|
||||
final now = ref.watch(clockProvider).value ?? DateTime.now();
|
||||
|
||||
// With no sidebar there is nothing else on screen carrying the brand, the
|
||||
// sync log or the way out — so all three are promoted into this bar.
|
||||
final cashierMode = ref.watch(isCashierModeProvider);
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||
padding: EdgeInsets.symmetric(
|
||||
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (compact) ...[
|
||||
if (compact && onMenuTap != null) ...[
|
||||
IconButton(
|
||||
onPressed: onMenuTap,
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
@@ -68,7 +78,12 @@ class PageHeader extends ConsumerWidget {
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
|
||||
|
||||
// Dropped first when the bar gets tight: the actions are what the
|
||||
// counter actually presses.
|
||||
if (cashierMode && showBrand) ...[
|
||||
const _CashierBrand(),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
],
|
||||
|
||||
if (showStatus) ...[
|
||||
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
||||
@@ -82,14 +97,26 @@ class PageHeader extends ConsumerWidget {
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Container(width: 1, height: 26, color: AppColors.border),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
],
|
||||
|
||||
// The bar is always the same shape: status on the left, actions
|
||||
// pinned to the right, for admin and cashier alike. Packed left with
|
||||
// a divider between them, the actions landed in a different place on
|
||||
// every screen — mid-bar on a wide admin window, hard left on a
|
||||
// narrow one — and the two roles never agreed with each other.
|
||||
const Spacer(),
|
||||
|
||||
_ParkedBillsButton(compact: compact),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_NewSaleButton(compact: compact),
|
||||
|
||||
// Every role signs out from here. It used to sit at the foot of the
|
||||
// sidebar for admins and up here for cashiers, which meant the same
|
||||
// action lived in two places depending on who was holding the till.
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Container(width: 1, height: 26, color: AppColors.border),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const _LogoutButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -366,3 +393,77 @@ class _NewSaleButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Brand and outlet name, shown only in cashier mode.
|
||||
///
|
||||
/// The sidebar normally carries these; without it the bar reads as a fragment
|
||||
/// of an app rather than the top of one.
|
||||
class _CashierBrand extends ConsumerWidget {
|
||||
const _CashierBrand();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final store = ref.watch(currentStoreProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const BrandMark(size: 32),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 170),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
store?.name ?? 'Nearle POS',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
user == null ? 'Cashier' : '${user.name} · Cashier',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Signing out, for both roles.
|
||||
///
|
||||
/// Opens the session-end chooser rather than signing out directly: a cashier
|
||||
/// stepping away for ten minutes and a cashier finishing for the day want two
|
||||
/// very different things to happen to the drawer and the catalogue.
|
||||
class _LogoutButton extends ConsumerWidget {
|
||||
const _LogoutButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
tooltip: 'Sign out',
|
||||
onPressed: () => showSessionEndSheet(context, ref),
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
backgroundColor: AppColors.dangerSurface,
|
||||
),
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
823
lib/presentation/shift/screens/end_shift_screen.dart
Normal file
823
lib/presentation/shift/screens/end_shift_screen.dart
Normal file
@@ -0,0 +1,823 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
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_typography.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/shift_report.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../payment/screens/payment_screen.dart' show methodIcon;
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../pos/providers/navigation_provider.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
|
||||
/// Closing the till.
|
||||
///
|
||||
/// Three things have to happen at the end of a shift and they have to happen
|
||||
/// in this order: count what is physically in the drawer, compare it against
|
||||
/// what the terminal says was taken in cash, then push the day up and hand the
|
||||
/// terminal back. Doing it as a dialog meant the count was a single guessed
|
||||
/// number typed into a box; a shift is worth its own screen.
|
||||
///
|
||||
/// The variance is the whole point. A till that is short is worth knowing
|
||||
/// about while the person who worked it is still standing there.
|
||||
class EndShiftScreen extends ConsumerStatefulWidget {
|
||||
const EndShiftScreen({super.key});
|
||||
|
||||
/// Below this the two columns stack.
|
||||
static const double twoColumnAbove = 1000;
|
||||
|
||||
@override
|
||||
ConsumerState<EndShiftScreen> createState() => _EndShiftScreenState();
|
||||
}
|
||||
|
||||
/// What a till drawer actually holds, largest first.
|
||||
const _denominations = <int>[2000, 500, 200, 100, 50, 20, 10, 5, 2, 1];
|
||||
|
||||
class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
|
||||
/// Note or coin value → how many were counted.
|
||||
final Map<int, int> _counted = {};
|
||||
|
||||
final _openingFloat = TextEditingController(text: '0');
|
||||
bool _pushing = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_openingFloat.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _countedTotal => _counted.entries
|
||||
.fold(0.0, (sum, e) => sum + e.key * e.value);
|
||||
|
||||
double get _float => double.tryParse(_openingFloat.text.trim()) ?? 0;
|
||||
|
||||
int get _noteCount => _counted.values.fold(0, (sum, n) => sum + n);
|
||||
|
||||
void _set(int denomination, int count) {
|
||||
setState(() {
|
||||
if (count <= 0) {
|
||||
_counted.remove(denomination);
|
||||
} else {
|
||||
_counted[denomination] = count;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Cash the terminal believes was taken, from the tender records — not from
|
||||
/// the bill totals, which include card and UPI.
|
||||
double _cashTaken(ShiftReport? report) =>
|
||||
report?.paymentBreakdown[PaymentMethod.cash] ?? 0;
|
||||
|
||||
/// Pushes what is still held, then ends the session and clears the terminal.
|
||||
Future<void> _finish({required bool sync}) async {
|
||||
setState(() {
|
||||
_pushing = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
if (sync) {
|
||||
final outcome = await ref.read(orderSyncProvider.notifier).run();
|
||||
if (!mounted) return;
|
||||
if (!outcome.isSuccess) {
|
||||
setState(() {
|
||||
_pushing = false;
|
||||
_error = outcome.error ??
|
||||
'Upload failed. Every bill is still stored on this terminal.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
|
||||
// The real end of shift: the catalogue goes with it, so the next person
|
||||
// bills against a fresh import rather than this morning's prices.
|
||||
await ref.read(authControllerProvider.notifier).signOut();
|
||||
|
||||
ref.read(catalogueVersionProvider.notifier).state++;
|
||||
ref.invalidate(allProductsProvider);
|
||||
ref.invalidate(visibleProductsProvider);
|
||||
ref.invalidate(categoryCountsProvider);
|
||||
ref.invalidate(lowStockProductsProvider);
|
||||
|
||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
ref.read(selectedCategoryProvider.notifier).state = null;
|
||||
|
||||
if (!mounted) return;
|
||||
context.go(AppRoutes.login);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = ref.watch(myShiftReportProvider).value;
|
||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final cashTaken = _cashTaken(report);
|
||||
final expected = _float + cashTaken;
|
||||
final variance = _countedTotal - expected;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('End shift'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: _pushing ? null : () => context.pop(),
|
||||
tooltip: 'Back to the till',
|
||||
),
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final twoColumn = box.maxWidth >= EndShiftScreen.twoColumnAbove;
|
||||
final pad = box.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
|
||||
|
||||
final count = _CountPanel(
|
||||
counted: _counted,
|
||||
openingFloat: _openingFloat,
|
||||
enabled: !_pushing,
|
||||
total: _countedTotal,
|
||||
noteCount: _noteCount,
|
||||
onChanged: _set,
|
||||
onFloatChanged: () => setState(() {}),
|
||||
);
|
||||
|
||||
final review = _ReviewPanel(
|
||||
report: report,
|
||||
user: user?.name,
|
||||
openingFloat: _float,
|
||||
cashTaken: cashTaken,
|
||||
expected: expected,
|
||||
counted: _countedTotal,
|
||||
variance: variance,
|
||||
pending: pending,
|
||||
error: _error,
|
||||
);
|
||||
|
||||
final minHeight = box.maxHeight.isFinite
|
||||
? (box.maxHeight - pad * 2).clamp(0.0, double.infinity)
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(pad),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1240),
|
||||
child: twoColumn
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: count),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(child: review),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
count,
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
review,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: _bottomBar(pending, variance),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar(int pending, double variance) {
|
||||
final counted = _noteCount > 0;
|
||||
final short = variance < -0.5;
|
||||
final over = variance > 0.5;
|
||||
|
||||
// The gate. A shift closes on a drawer that reconciles and on nothing
|
||||
// else — an uncounted or mismatched till is settled at the counter, with
|
||||
// the person who worked it still there, not discovered by the back office
|
||||
// the next morning.
|
||||
final balanced = counted && !short && !over;
|
||||
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.md,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
counted
|
||||
? (short || over
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.check_circle_outline_rounded)
|
||||
: Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: !counted
|
||||
? AppColors.textTertiary
|
||||
: (short
|
||||
? AppColors.danger
|
||||
: (over ? AppColors.warning : AppColors.success)),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
!counted
|
||||
? 'Count the drawer. The shift cannot be ended until '
|
||||
'it matches the expected amount.'
|
||||
: short
|
||||
? 'The drawer is '
|
||||
'${Formatters.money(variance.abs())} short. '
|
||||
'Recount, or settle the difference — the '
|
||||
'shift cannot be ended while it is off.'
|
||||
: over
|
||||
? 'The drawer is '
|
||||
'${Formatters.money(variance)} over. '
|
||||
'Recount — the shift cannot be ended '
|
||||
'while it is off.'
|
||||
: 'The drawer matches what was rung. Ready to '
|
||||
'end the shift.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
PrimaryButton(
|
||||
label: !balanced
|
||||
? 'Drawer must match to end shift'
|
||||
: pending > 0
|
||||
? 'Upload $pending bill(s) & end shift'
|
||||
: 'End shift',
|
||||
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
|
||||
large: true,
|
||||
busy: _pushing,
|
||||
onPressed: (_pushing || !balanced)
|
||||
? null
|
||||
: () => _finish(sync: pending > 0),
|
||||
),
|
||||
if (pending > 0) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
TextButton(
|
||||
// Skipping the upload is still allowed; skipping the count is
|
||||
// not, so this is gated on the same condition.
|
||||
onPressed:
|
||||
(_pushing || !balanced) ? null : () => _finish(sync: false),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
),
|
||||
child: const Text('End shift without uploading'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Count
|
||||
class _CountPanel extends StatelessWidget {
|
||||
const _CountPanel({
|
||||
required this.counted,
|
||||
required this.openingFloat,
|
||||
required this.enabled,
|
||||
required this.total,
|
||||
required this.noteCount,
|
||||
required this.onChanged,
|
||||
required this.onFloatChanged,
|
||||
});
|
||||
|
||||
final Map<int, int> counted;
|
||||
final TextEditingController openingFloat;
|
||||
final bool enabled;
|
||||
final double total;
|
||||
final int noteCount;
|
||||
final void Function(int denomination, int count) onChanged;
|
||||
final VoidCallback onFloatChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Panel(
|
||||
title: 'Count the drawer',
|
||||
subtitle: 'Tap the notes and coins you are holding. Nothing is '
|
||||
'submitted until you end the shift.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: openingFloat,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => onFloatChanged(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Opening float',
|
||||
helperText: 'What was in the drawer before trading started',
|
||||
prefixText: '₹ ',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
for (final value in _denominations)
|
||||
_DenominationRow(
|
||||
value: value,
|
||||
count: counted[value] ?? 0,
|
||||
enabled: enabled,
|
||||
onChanged: (n) => onChanged(value, n),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
noteCount == 0
|
||||
? 'Counted so far'
|
||||
: 'Counted so far · $noteCount piece(s)',
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Formatters.money(total),
|
||||
style: AppTypography.money(20, color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DenominationRow extends StatelessWidget {
|
||||
const _DenominationRow({
|
||||
required this.value,
|
||||
required this.count,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final int value;
|
||||
final int count;
|
||||
final bool enabled;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtotal = value * count;
|
||||
final active = count > 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 74,
|
||||
child: Text(
|
||||
'₹$value',
|
||||
style: AppTypography.money(
|
||||
15,
|
||||
color: active ? AppColors.textPrimary : AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'×',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_Stepper(
|
||||
count: count,
|
||||
enabled: enabled,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
active ? Formatters.money(subtotal.toDouble()) : '—',
|
||||
style: AppTypography.money(
|
||||
14,
|
||||
color: active ? AppColors.textPrimary : AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Stepper extends StatelessWidget {
|
||||
const _Stepper({
|
||||
required this.count,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final bool enabled;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_btn(
|
||||
Icons.remove_rounded,
|
||||
enabled && count > 0 ? () => onChanged(count - 1) : null,
|
||||
),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 38),
|
||||
alignment: Alignment.center,
|
||||
child: Text('$count', style: AppTypography.money(14.5)),
|
||||
),
|
||||
_btn(
|
||||
Icons.add_rounded,
|
||||
enabled ? () => onChanged(count + 1) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.brSm,
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: onTap == null
|
||||
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Review
|
||||
class _ReviewPanel extends StatelessWidget {
|
||||
const _ReviewPanel({
|
||||
required this.report,
|
||||
required this.user,
|
||||
required this.openingFloat,
|
||||
required this.cashTaken,
|
||||
required this.expected,
|
||||
required this.counted,
|
||||
required this.variance,
|
||||
required this.pending,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
final ShiftReport? report;
|
||||
final String? user;
|
||||
final double openingFloat;
|
||||
final double cashTaken;
|
||||
final double expected;
|
||||
final double counted;
|
||||
final double variance;
|
||||
final int pending;
|
||||
final String? error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final short = variance < -0.5;
|
||||
final over = variance > 0.5;
|
||||
final tone = counted == 0
|
||||
? AppColors.textTertiary
|
||||
: (short ? AppColors.danger : (over ? AppColors.warning
|
||||
: AppColors.success));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Panel(
|
||||
title: 'Cash drawer',
|
||||
subtitle: 'What the terminal expects, against what you counted.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Opening float', Formatters.money(openingFloat)),
|
||||
_row('Cash sales today', Formatters.money(cashTaken)),
|
||||
const Divider(height: AppSpacing.xl),
|
||||
_row(
|
||||
'Expected in drawer',
|
||||
Formatters.money(expected),
|
||||
strong: true,
|
||||
),
|
||||
_row('You counted', Formatters.money(counted)),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: counted == 0
|
||||
? AppColors.surfaceAlt
|
||||
: (short
|
||||
? AppColors.dangerSurface
|
||||
: (over
|
||||
? AppColors.warningSurface
|
||||
: AppColors.successSurface)),
|
||||
borderRadius: AppRadius.brMd,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
counted == 0
|
||||
? 'Not counted yet'
|
||||
: (short
|
||||
? 'Short'
|
||||
: (over ? 'Over' : 'Balanced')),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: tone,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
counted == 0
|
||||
? '—'
|
||||
: '${variance >= 0 ? '+' : '−'}'
|
||||
'${Formatters.money(variance.abs())}',
|
||||
style: AppTypography.money(20, color: tone),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
_Panel(
|
||||
title: 'Today at this till',
|
||||
subtitle: user == null ? null : 'Rung by $user',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Bills', '${report?.billCount ?? 0}'),
|
||||
_row('Items sold',
|
||||
(report?.itemCount ?? 0).toStringAsFixed(0),),
|
||||
_row('Gross sales',
|
||||
Formatters.money(report?.grossSales ?? 0),),
|
||||
_row('GST collected',
|
||||
Formatters.money(report?.taxCollected ?? 0),),
|
||||
if ((report?.paymentBreakdown ?? const {}).isNotEmpty) ...[
|
||||
const Divider(height: AppSpacing.xl),
|
||||
for (final e in report!.paymentBreakdown.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(methodIcon(e.key),
|
||||
size: 16, color: AppColors.textSecondary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.key.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value),
|
||||
style: AppTypography.money(13.5),),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
_Panel(
|
||||
title: 'Before you hand it over',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_checkRow(
|
||||
pending == 0,
|
||||
pending == 0
|
||||
? 'Every bill has been uploaded.'
|
||||
: '$pending bill(s) still on this terminal — they upload '
|
||||
'when you end the shift.',
|
||||
),
|
||||
_checkRow(
|
||||
!short && !over && counted > 0,
|
||||
counted == 0
|
||||
? 'Drawer not counted yet — required before the shift can '
|
||||
'be ended.'
|
||||
: (short || over)
|
||||
? 'Drawer does not match the expected amount. The '
|
||||
'shift stays open until it does.'
|
||||
: 'Drawer matches the expected amount.',
|
||||
),
|
||||
_checkRow(
|
||||
false,
|
||||
'Products are removed from this terminal at the end of a '
|
||||
'shift. An admin imports them again tomorrow.',
|
||||
neutral: true,
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded,
|
||||
size: 18, color: AppColors.danger,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
error!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.danger,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value, {bool strong = false}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: strong ? 14 : 13.5,
|
||||
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
|
||||
color: strong
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.money(
|
||||
strong ? 16 : 13.5,
|
||||
color: strong ? AppColors.primary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _checkRow(bool done, String text, {bool neutral = false}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
neutral
|
||||
? Icons.info_outline_rounded
|
||||
: (done
|
||||
? Icons.check_circle_outline_rounded
|
||||
: Icons.radio_button_unchecked_rounded),
|
||||
size: 16,
|
||||
color: neutral
|
||||
? AppColors.textTertiary
|
||||
: (done ? AppColors.success : AppColors.textTertiary),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One card shape for every block on this screen, so the two columns line up
|
||||
/// row for row instead of each panel inventing its own padding.
|
||||
class _Panel extends StatelessWidget {
|
||||
const _Panel({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
283
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
283
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
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 '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../pos/providers/navigation_provider.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../../sync/widgets/sign_out_dialog.dart';
|
||||
|
||||
/// Asks what "signing out" means before doing it.
|
||||
///
|
||||
/// A cashier stepping off the counter for a few minutes and a cashier
|
||||
/// finishing for the day want different things from the *shift*, not from the
|
||||
/// terminal. A temporary logout leaves the shift open — bills stay queued and
|
||||
/// today's totals keep accumulating — and simply locks the screen. Ending the
|
||||
/// shift means counting the drawer, reconciling it, and handing the till back.
|
||||
///
|
||||
/// What both do identically: the products come off this terminal. A cashier
|
||||
/// session never leaves a catalogue sitting on an unattended screen, so an
|
||||
/// admin re-imports it before the counter is worked again.
|
||||
///
|
||||
/// Admins see the plain sign-out dialog: they have no drawer to settle.
|
||||
Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
|
||||
if (!ref.read(isCashierModeProvider)) {
|
||||
return showSignOutDialog(context, ref);
|
||||
}
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => const _SessionEndDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class _SessionEndDialog extends ConsumerWidget {
|
||||
const _SessionEndDialog();
|
||||
|
||||
/// Closes the session without closing the shift.
|
||||
///
|
||||
/// Explicitly *not* a shift end: unsynced bills stay queued and today's
|
||||
/// totals keep accumulating against the same day, so the drawer is still
|
||||
/// settled once, at the end. What it does not leave behind is the
|
||||
/// catalogue — every cashier sign-out takes the products with it, this one
|
||||
/// included, so an admin imports them again before billing resumes.
|
||||
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
await ref.read(authControllerProvider.notifier).signOut();
|
||||
|
||||
// Bump the version so catalogueReadyProvider re-reads hasCatalogue, and
|
||||
// drop the cached lists so the next session's grid does not flash this
|
||||
// session's products before it re-checks what is on disk.
|
||||
ref.read(catalogueVersionProvider.notifier).state++;
|
||||
ref.invalidate(allProductsProvider);
|
||||
ref.invalidate(visibleProductsProvider);
|
||||
ref.invalidate(categoryCountsProvider);
|
||||
ref.invalidate(lowStockProductsProvider);
|
||||
|
||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
ref.read(selectedCategoryProvider.notifier).state = null;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Resolved while this context is still mounted — the messenger lives above
|
||||
// the router, so the bar survives the route change below.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
context.go(AppRoutes.login);
|
||||
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'Logged out. The shift is still open, and the product catalogue has '
|
||||
'been removed from this terminal.',
|
||||
),
|
||||
),);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Leaving the till'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.sm,
|
||||
),
|
||||
content: SizedBox(
|
||||
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 460.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (cart.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.warningSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded,
|
||||
size: 18, color: AppColors.warning,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The current bill has ${cart.lineCount} item(s) and '
|
||||
'will be cleared either way.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.warning,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// The one thing both choices do, said once here rather than
|
||||
// repeated in each card and discovered at the next login.
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.infoSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.delete_sweep_outlined,
|
||||
size: 18, color: AppColors.info,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Either way, the product catalogue is removed from '
|
||||
'this terminal. An admin imports it again before the '
|
||||
'counter is worked.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.info,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
_Choice(
|
||||
icon: Icons.lock_clock_outlined,
|
||||
tone: AppColors.info,
|
||||
title: 'Temporary logout',
|
||||
body: 'Locks the terminal without closing the shift. Bills '
|
||||
'stay queued and today’s totals keep running, so the '
|
||||
'drawer is still counted once at the end.',
|
||||
onTap: () => _temporaryLogout(context, ref),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_Choice(
|
||||
icon: Icons.point_of_sale_rounded,
|
||||
tone: AppColors.primary,
|
||||
title: 'End shift',
|
||||
body: pending == 0
|
||||
? 'Count the drawer, check it against what was rung, then '
|
||||
'hand the till over.'
|
||||
: 'Count the drawer, check it against what was rung, then '
|
||||
'upload the $pending bill(s) still held here.',
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.push(AppRoutes.endShift);
|
||||
},
|
||||
emphasised: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
0,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Stay signed in'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Choice extends StatelessWidget {
|
||||
const _Choice({
|
||||
required this.icon,
|
||||
required this.tone,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.onTap,
|
||||
this.emphasised = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color tone;
|
||||
final String title;
|
||||
final String body;
|
||||
final VoidCallback onTap;
|
||||
final bool emphasised;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: emphasised ? AppColors.primarySurface : AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.brLg,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(
|
||||
color: emphasised ? AppColors.primaryBorder : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: tone),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
body,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
size: 20, color: AppColors.textTertiary,),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -222,18 +222,6 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
||||
await store.syncConfig.load(ref.read(syncConfigProvider));
|
||||
}
|
||||
|
||||
// Bring back the session this terminal was signed in under.
|
||||
//
|
||||
// Runs before the engine starts, and that ordering is load-bearing: the
|
||||
// session carries both the bearer token and the outlet, so a drain that began
|
||||
// first would upload the day's bills unauthenticated — and, once the backend
|
||||
// is enforcing, have them refused.
|
||||
//
|
||||
// A till signs in when a shop opens and trades all day. Without this a reboot
|
||||
// mid-shift would put a login screen in front of a queue of customers, which
|
||||
// is a worse outage than the one it protects against.
|
||||
await ref.read(authControllerProvider.notifier).restore();
|
||||
|
||||
await ref.read(connectivityServiceProvider).start();
|
||||
|
||||
final engine = ref.read(syncEngineProvider);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 '../../pos/providers/navigation_provider.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
import '../providers/sync_controller.dart';
|
||||
|
||||
@@ -38,21 +39,52 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
|
||||
Future<void> _finish() async {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
|
||||
// Read before signing out — the session that decides this is gone by the
|
||||
// time signOut returns.
|
||||
final cleared =
|
||||
ref.read(authControllerProvider.notifier).clearsCatalogueOnSignOut;
|
||||
|
||||
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.
|
||||
// Mirrors what a successful import does on the way in: bump the version so
|
||||
// catalogueReadyProvider re-reads hasCatalogue, and drop the cached product
|
||||
// lists so the next session's grid doesn't flash this session's data before
|
||||
// it re-fetches.
|
||||
//
|
||||
// Run either way. After an admin sign-out the catalogue is still there and
|
||||
// these simply re-read it — which is the point: the next session must see
|
||||
// what is on disk now, not what this one had in memory.
|
||||
ref.read(catalogueVersionProvider.notifier).state++;
|
||||
ref.invalidate(allProductsProvider);
|
||||
ref.invalidate(visibleProductsProvider);
|
||||
ref.invalidate(categoryCountsProvider);
|
||||
ref.invalidate(lowStockProductsProvider);
|
||||
|
||||
// The next session starts on the till, never wherever this one left off.
|
||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
ref.read(selectedCategoryProvider.notifier).state = null;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Resolved while this context is still mounted. The messenger itself lives
|
||||
// above the router, so the bar survives the route change below.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
context.go(AppRoutes.login);
|
||||
|
||||
if (cleared) {
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'Signed out. The product catalogue has been removed from this '
|
||||
'terminal.',
|
||||
),
|
||||
),);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pushThenFinish() async {
|
||||
@@ -76,8 +108,10 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
||||
final failed = _result != null && !_result!.isSuccess;
|
||||
|
||||
final cashier = ref.watch(isCashierModeProvider);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('End shift'),
|
||||
title: Text(cashier ? 'End shift' : 'Sign out'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
@@ -92,6 +126,26 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// What happens to the products is the difference between the two
|
||||
// sign-outs, so it is said plainly rather than left to be
|
||||
// discovered at the next login.
|
||||
_Banner(
|
||||
icon: cashier
|
||||
? Icons.delete_sweep_outlined
|
||||
: Icons.inventory_2_outlined,
|
||||
color: cashier ? AppColors.warning : AppColors.info,
|
||||
background: cashier
|
||||
? AppColors.warningSurface
|
||||
: AppColors.infoSurface,
|
||||
message: cashier
|
||||
? 'The product catalogue will be removed from this '
|
||||
'terminal. An admin imports it again for the next '
|
||||
'shift.'
|
||||
: 'The imported products stay on this terminal, so a '
|
||||
'cashier can sign in and start billing without a '
|
||||
'connection.',
|
||||
),
|
||||
|
||||
if (cart.isNotEmpty)
|
||||
_Banner(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
|
||||
Reference in New Issue
Block a user