pos changes

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

BIN
assets/images/bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -17,9 +17,7 @@ import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart'; import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/session_store.dart';
import '../data/local/terminal_identity.dart'; import '../data/local/terminal_identity.dart';
import '../data/remote/pos_auth_api.dart';
import '../data/sync/sync_engine.dart'; import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart'; import '../domain/repositories/product_repository.dart';
@@ -163,28 +161,10 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), (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 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>( final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load( (ref) => ref.watch(storeRepositoryProvider).load(
email: ref.watch(authControllerProvider.notifier).session?.email ?? '', email: DemoCredentials.email,
), ),
); );

View File

@@ -35,7 +35,6 @@ class SyncConfig {
this.password, this.password,
this.httpBaseUrl = '', this.httpBaseUrl = '',
this.apiKey, this.apiKey,
this.sessionToken,
this.ackTimeout = const Duration(seconds: 20), this.ackTimeout = const Duration(seconds: 20),
this.batchSize = 50, this.batchSize = 50,
}); });
@@ -53,38 +52,8 @@ class SyncConfig {
final String? password; final String? password;
final String httpBaseUrl; 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; 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 /// How long to wait for the back office to confirm a batch before treating
/// the outcome as unknown and leaving every row pending. /// the outcome as unknown and leaving every row pending.
/// ///
@@ -179,7 +148,6 @@ class SyncConfig {
String? password, String? password,
String? httpBaseUrl, String? httpBaseUrl,
String? apiKey, String? apiKey,
String? sessionToken,
Duration? ackTimeout, Duration? ackTimeout,
int? batchSize, int? batchSize,
}) => }) =>
@@ -194,7 +162,6 @@ class SyncConfig {
password: password ?? this.password, password: password ?? this.password,
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl, httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
apiKey: apiKey ?? this.apiKey, apiKey: apiKey ?? this.apiKey,
sessionToken: sessionToken ?? this.sessionToken,
ackTimeout: ackTimeout ?? this.ackTimeout, ackTimeout: ackTimeout ?? this.ackTimeout,
batchSize: batchSize ?? this.batchSize, batchSize: batchSize ?? this.batchSize,
); );

View File

@@ -1,11 +1,17 @@
/// Typed references to bundled assets. /// Typed references to bundled assets.
/// ///
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome /// Product imagery uses emoji glyphs and the welcome artwork is painted in
/// artwork is painted in code, so there are no raster or SVG assets to ship. /// code, so the only raster asset shipped is the mark itself.
class AssetPaths { class AssetPaths {
const AssetPaths._(); const AssetPaths._();
static const String _snd = 'assets/sounds'; 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 beepSuccess = '$_snd/beep_success.wav';
static const String beepError = '$_snd/beep_error.wav'; static const String beepError = '$_snd/beep_error.wav';

View File

@@ -8,6 +8,7 @@ import '../../presentation/auth/screens/login_screen.dart';
import '../../presentation/payment/screens/payment_screen.dart'; import '../../presentation/payment/screens/payment_screen.dart';
import '../../presentation/pos/screens/pos_dashboard_screen.dart'; import '../../presentation/pos/screens/pos_dashboard_screen.dart';
import '../../presentation/receipt/screens/receipt_screen.dart'; import '../../presentation/receipt/screens/receipt_screen.dart';
import '../../presentation/shift/screens/end_shift_screen.dart';
class AppRoutes { class AppRoutes {
const AppRoutes._(); const AppRoutes._();
@@ -19,6 +20,10 @@ class AppRoutes {
static const String pos = '/'; static const String pos = '/';
static const String payment = '/payment'; static const String payment = '/payment';
static const String receipt = '/receipt'; 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. /// Router with an authentication guard.
@@ -60,6 +65,11 @@ final routerProvider = Provider<GoRouter>((ref) {
pageBuilder: (context, state) => pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()), _fade(state, const PosDashboardScreen()),
), ),
GoRoute(
path: AppRoutes.endShift,
name: 'endShift',
pageBuilder: (context, state) => _slide(state, const EndShiftScreen()),
),
GoRoute( GoRoute(
path: AppRoutes.payment, path: AppRoutes.payment,
name: 'payment', name: 'payment',

View 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,
),
),
),
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../local/staff_dao.dart';
import '../local/sync_config_store.dart'; import '../local/sync_config_store.dart';
import '../local/sync_log_dao.dart'; import '../local/sync_log_dao.dart';
import '../local/terminal_identity.dart'; import '../local/terminal_identity.dart';
import '../local/void_pin_store.dart';
/// Terminal-side storage facade. /// Terminal-side storage facade.
/// ///
@@ -28,6 +29,7 @@ class LocalStore {
late PromoDao promos; late PromoDao promos;
late SyncConfigStore syncConfig; late SyncConfigStore syncConfig;
late TerminalIdentityStore identityStore; late TerminalIdentityStore identityStore;
late VoidPinStore voidPin;
/// Who this till is. Minted on first run, then stable forever. /// Who this till is. Minted on first run, then stable forever.
late TerminalIdentity terminal; late TerminalIdentity terminal;
@@ -60,6 +62,7 @@ class LocalStore {
promos = PromoDao(AppDatabase.instance.db); promos = PromoDao(AppDatabase.instance.db);
syncConfig = SyncConfigStore(catalogue); syncConfig = SyncConfigStore(catalogue);
identityStore = TerminalIdentityStore(catalogue); identityStore = TerminalIdentityStore(catalogue);
voidPin = VoidPinStore(catalogue, staff);
// A terminal with no staff cannot be signed into at all, so this runs // A terminal with no staff cannot be signed into at all, so this runs
// before anything else can ask who is on shift. // 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. /// 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 /// Called when a *cashier* signs out, so the next shift never bills against
/// over from this one — [hasCatalogue] goes back to false, and the only way /// a copy left over from this one — [hasCatalogue] goes back to false, and
/// to sell again is a fresh pull from the back office. /// 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 { Future<void> clearCatalogue() async {
await catalogue.clearCatalogue(); await catalogue.clearCatalogue();
_products.clear(); _products.clear();

View File

@@ -524,4 +524,14 @@ class MetaKeys {
static const String printerName = 'printer_name'; static const String printerName = 'printer_name';
static const String autoPrint = 'auto_print'; static const String autoPrint = 'auto_print';
static const String openDrawer = 'open_cash_drawer'; 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';
} }

View File

@@ -303,20 +303,27 @@ class OrderDao {
return rows.isEmpty ? null : rows.first; return rows.isEmpty ? null : rows.first;
} }
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query( /// Rows for the sync log, with each bill's unit count folded in.
Tables.orders, ///
columns: [ /// The count comes from a correlated sum over [Tables.orderItems] rather
'id', /// than a column on the order: quantity can be fractional (loose weight), so
'invoice_number', /// there is no line count that answers "how many units were on this bill".
'total', /// A single aggregate keeps this to one query rather than one per row.
'created_at', Future<List<Map<String, Object?>>> syncRows({int limit = 200}) =>
'sync_status', _db.rawQuery(
'synced_at', '''
'sync_attempts', SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status,
'sync_error', o.synced_at, o.sync_attempts, o.sync_error,
], COALESCE(
orderBy: 'created_at DESC', (SELECT SUM(i.quantity) FROM ${Tables.orderItems} i
limit: limit, WHERE i.order_id = o.id),
0
) AS item_count
FROM ${Tables.orders} o
ORDER BY o.created_at DESC
LIMIT ?
''',
[limit],
); );
// ----------------------------------------------------------------- Sync // ----------------------------------------------------------------- Sync

View File

@@ -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');
}
}
}

View File

@@ -25,10 +25,6 @@ class StaffDao {
final Database _db; 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(); static const _uuid = Uuid();
/// The accounts a shop starts with. /// The accounts a shop starts with.
@@ -281,93 +277,3 @@ class StaffDao {
isActive: (row['is_active'] as int? ?? 1) == 1, 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;
}

View 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;
}

View File

@@ -156,8 +156,7 @@ class HttpCatalogueSource implements CatalogueSource {
uri, uri,
headers: { headers: {
'accept': 'application/json', 'accept': 'application/json',
if (config.bearerToken != null) if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
'authorization': 'Bearer ${config.bearerToken}',
}, },
).timeout(_timeout); ).timeout(_timeout);
} on Exception catch (e) { } on Exception catch (e) {

View File

@@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport {
Uri.parse('${config.httpBaseUrl}/health'), Uri.parse('${config.httpBaseUrl}/health'),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
if (config.bearerToken != null) if (config.apiKey != null)
'authorization': 'Bearer ${config.bearerToken}', 'authorization': 'Bearer ${config.apiKey}',
}, },
body: payload, body: payload,
) )
@@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport {
uri, uri,
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
if (config.bearerToken != null) if (config.apiKey != null)
'authorization': 'Bearer ${config.bearerToken}', 'authorization': 'Bearer ${config.apiKey}',
'idempotency-key': batchId, 'idempotency-key': batchId,
}, },
body: jsonEncode({ body: jsonEncode({

View File

@@ -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();
}

View File

@@ -178,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository {
createdAt: createdAt:
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int), DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
isSynced: (r['sync_status']! as int) == OrderDao.synced, isSynced: (r['sync_status']! as int) == OrderDao.synced,
itemCount: (r['item_count'] as num?)?.toDouble() ?? 0,
syncedAt: r['synced_at'] == null syncedAt: r['synced_at'] == null
? null ? null
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int), : DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),

View File

@@ -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,
};
}

View File

@@ -39,6 +39,7 @@ class OrderSyncRow {
required this.total, required this.total,
required this.createdAt, required this.createdAt,
required this.isSynced, required this.isSynced,
this.itemCount = 0,
this.syncedAt, this.syncedAt,
this.attempts = 0, this.attempts = 0,
this.error, this.error,
@@ -49,6 +50,9 @@ class OrderSyncRow {
final double total; final double total;
final DateTime createdAt; final DateTime createdAt;
final bool isSynced; final bool isSynced;
/// Units on the bill. Fractional because loose goods are sold by weight.
final double itemCount;
final DateTime? syncedAt; final DateTime? syncedAt;
final int attempts; final int attempts;
final String? error; final String? error;

View File

@@ -1,9 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.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'; import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal. /// Sign-in state for the terminal.
@@ -22,10 +19,23 @@ class Authenticating extends AuthState {
} }
class Authenticated 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 StoreAccount store;
final StaffUser user; 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 { class AuthFailure extends AuthState {
@@ -34,150 +44,110 @@ class AuthFailure extends AuthState {
final String message; 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 — /// Store-level credentials, not a person's: they are replaced wholesale when
/// `admin@nearle.in` / `nearle123` — with a 600ms delay standing in for a /// the terminal is registered against a real back office. Staff PINs — the
/// network call that was never made. Two things were wrong with that, and the /// credential that actually opens a till drawer — are not here. They live
/// second was the serious one: /// hashed in the database.
/// ///
/// 1. every install of a build shared one password, and changing it meant a /// The split is what the two roles are *for*, not decoration:
/// 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.
/// ///
/// Now a person signs in with their own back-office account, and the outlet /// * [admin] runs the whole shell and is the only login that can pull the
/// arrives as a consequence: sealed in a signed token, checked server-side on /// catalogue. Signing out leaves the products on the terminal.
/// every request, and not editable from this device. /// * [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> { class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated()); AuthController(this._ref) : super(const Unauthenticated());
final Ref _ref; 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 /// Read *before* [signOut] by anything that needs to warn the operator, since
/// asking for the password a second time. /// the session is gone by the time it returns.
PosSession? _session; bool get clearsCatalogueOnSignOut {
PosSession? get session => _session; final current = state;
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
/// 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;
} }
Future<bool> signIn({ Future<bool> signIn({
required String email, required String email,
required String password, required String password,
int? locationId,
}) async { }) async {
state = const Authenticating(); 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; final login = TerminalLogin.byEmail(email);
try {
session = await _ref.read(posAuthApiProvider).login( if (login == null) {
authname: email, state = const AuthFailure('No account is registered against that 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.',
);
return false; return false;
} }
await _ref.read(sessionStoreProvider).write(session); if (password != login.password) {
await _adopt(session); 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 store = await _ref.read(storeAccountProvider.future);
final staff = store.staff; final staff = store.staff;
@@ -185,61 +155,29 @@ class AuthController extends StateNotifier<AuthState> {
state = const AuthFailure( state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.', '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 // Whoever on this terminal matches the role that just signed in. Falls
// account at the till. // 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( final opener = staff.firstWhere(
(s) => s.role == StaffRole.admin, (s) => s.role == login.role,
orElse: () => staff.first, 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. /// Switches the active operator, checking their PIN.
/// ///
/// Every bill is stamped with whoever is active, so this is the boundary that /// 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 /// 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 { Future<bool> switchUser(String pin) async {
final current = state; final current = state;
if (current is! Authenticated) return false; if (current is! Authenticated) return false;
@@ -248,7 +186,11 @@ class AuthController extends StateNotifier<AuthState> {
final user = await store.staff.authenticate(pin); final user = await store.staff.authenticate(pin);
if (user == null) return false; if (user == null) return false;
state = Authenticated(store: current.store, user: user); state = Authenticated(
store: current.store,
user: user,
login: current.login,
);
return true; return true;
} }
@@ -266,25 +208,25 @@ class AuthController extends StateNotifier<AuthState> {
// Signed out if the active operator was just deactivated — carrying on // Signed out if the active operator was just deactivated — carrying on
// would keep stamping bills with an account the shop has revoked. // would keep stamping bills with an account the shop has revoked.
user: me.isEmpty ? store.staff.first : me.first, user: me.isEmpty ? store.staff.first : me.first,
login: current.login,
); );
} }
/// The next shift should only ever bill against what the back office /// Ends the session, and — for a cashier only — the catalogue with it.
/// answers with, never a catalogue instance carried over from this
/// session — so the local product table is dropped before the session
/// itself is.
/// ///
/// The token goes with it, from the keystore and from the live configuration /// Every way out of a cashier session clears the products: ending a shift,
/// both. Leaving it in place would let a signed-out terminal keep uploading /// and a temporary logout alike. There is no exception for stepping away for
/// as the shop that signed in this morning. /// 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 { Future<void> signOut() async {
if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue(); await _ref.read(localStoreProvider).clearCatalogue();
await _ref.read(sessionStoreProvider).clear(); }
_ref.read(syncConfigProvider.notifier).state =
_ref.read(syncConfigProvider).copyWith(sessionToken: '');
_session = null;
state = const Unauthenticated(); state = const Unauthenticated();
} }
@@ -309,6 +251,24 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
return s is Authenticated ? s.user : null; 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. /// True while anyone is still on a seeded or admin-reset PIN.
final mustChangePinProvider = Provider<bool>((ref) { final mustChangePinProvider = Provider<bool>((ref) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);

View File

@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -8,11 +10,18 @@ import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/validators.dart'; import '../../../core/utils/validators.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/pos_session.dart';
import '../providers/auth_controller.dart'; import '../providers/auth_controller.dart';
/// Store sign-in. The terminal shows this until a valid account is entered. /// Store sign-in. The terminal shows this until a valid account is entered.
///
/// One centred card on a plain background, at every width. The split-screen
/// version put a marketing panel beside the form, which meant the thing the
/// person came here to use was never in the middle of the screen, was a
/// different width on every monitor, and collapsed into a different layout
/// below 1000px. A till is signed into at the start of a shift by someone who
/// already bought the product; the pitch was costing the form its position.
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@@ -22,12 +31,28 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _email = TextEditingController();
final _password = TextEditingController(); /// Which of the two default accounts the tabs are pointing at. Only a
/// convenience for filling the fields — [AuthController.signIn] decides the
/// role from the email that is actually submitted, so typing a different
/// address over the top still signs in as that account.
TerminalLogin _login = TerminalLogin.admin;
late final _email = TextEditingController(text: _login.email);
late final _password = TextEditingController(text: _login.password);
bool _obscure = true; bool _obscure = true;
bool _rememberTerminal = true; bool _rememberTerminal = true;
void _selectLogin(TerminalLogin login) {
setState(() {
_login = login;
_email.text = login.email;
_password.text = login.password;
});
ref.read(authControllerProvider.notifier).clearError();
}
@override @override
void dispose() { void dispose() {
_email.dispose(); _email.dispose();
@@ -39,233 +64,149 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
final auth = ref.read(authControllerProvider.notifier); final ok = await ref.read(authControllerProvider.notifier).signIn(
var ok = await auth.signIn(
email: _email.text, email: _email.text,
password: _password.text, password: _password.text,
); );
if (!ok || !mounted) return;
// A proprietor with several shops signs in once and then says which counter if (ok && mounted) context.go(AppRoutes.pos);
// this terminal is standing at. The back office has already decided which
// outlets they may reach, so this is a choice among those — never a free
// text field, which is what the old Settings store id amounted to.
final session = auth.session;
if (session != null && session.hasChoiceOfOutlet) {
final chosen = await showDialog<PosOutlet>(
context: context,
// Not dismissable: a terminal has to be standing somewhere, and
// defaulting silently to the first outlet is how a day's takings end up
// filed against the wrong shop.
barrierDismissible: false,
builder: (_) => _OutletPicker(session: session),
);
if (chosen == null || !mounted) return;
if (chosen.locationId != session.locationId) {
ok = await auth.signIn(
email: _email.text,
password: _password.text,
locationId: chosen.locationId,
);
if (!ok || !mounted) return;
}
}
if (mounted) context.go(AppRoutes.pos);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final auth = ref.watch(authControllerProvider);
backgroundColor: AppColors.background, final session = ref.watch(cashierSessionProvider);
body: LayoutBuilder( final busy = auth is Authenticating;
builder: (context, constraints) {
// Below this there isn't room for the brand panel beside the form.
final showBrandPanel = constraints.maxWidth >= 1000;
return Row( return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [ children: [
if (showBrandPanel) // Background image
const Expanded(flex: 5, child: _BrandPanel()), Image.asset(
Expanded( 'assets/images/bg.webp',
flex: 4, fit: BoxFit.cover,
child: _FormPanel( ),
// Light blur over the image
SafeArea(
child: LayoutBuilder(
builder: (context, box) {
final tight = box.maxHeight < 620;
return SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
),
child: ConstrainedBox(
// Fills the viewport so the card is centred vertically, and
// scrolls the moment it cannot be.
constraints: BoxConstraints(
minHeight: (box.maxHeight - (tight ? 40 : 64))
.clamp(0.0, double.infinity),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Masthead(tight: tight),
SizedBox(
height: tight ? AppSpacing.lg : AppSpacing.xl,),
_Card(
formKey: _formKey, formKey: _formKey,
email: _email, email: _email,
password: _password, password: _password,
obscure: _obscure, obscure: _obscure,
rememberTerminal: _rememberTerminal, rememberTerminal: _rememberTerminal,
showCompactLogo: !showBrandPanel, login: _login,
onToggleObscure: () => setState(() => _obscure = !_obscure), busy: busy,
failure: auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () =>
setState(() => _obscure = !_obscure),
onToggleRemember: (v) => onToggleRemember: (v) =>
setState(() => _rememberTerminal = v ?? true), setState(() => _rememberTerminal = v ?? true),
onSubmit: _submit, onSubmit: _submit,
), ),
const SizedBox(height: AppSpacing.lg),
Center(
child: Text(
'Terminal ${session.terminalId}',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
), ),
], ],
),
),
),
),
); );
}, },
), ),
),
],
),
); );
} }
} }
class _BrandPanel extends StatelessWidget { /// Mark and product name, directly above the card.
const _BrandPanel(); class _Masthead extends StatelessWidget {
const _Masthead({required this.tight});
final bool tight;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Column(
decoration: const BoxDecoration(gradient: AppColors.primaryGradient),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.giant),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( // BrandMark(size: tight ? 48 : 60),
children: [ const SizedBox(height: AppSpacing.md),
Container( const Text(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(11),
),
alignment: Alignment.center,
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
),
const SizedBox(width: AppSpacing.md),
const Flexible(
child: Text(
'Nearle POS', 'Nearle POS',
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: Colors.white, fontSize: 20,
fontSize: 22,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.4, letterSpacing: -0.4,
),
),
),
],
),
const SizedBox(height: AppSpacing.giant),
const Text(
'Billing that keeps up\nwith your counter.',
style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 34, height: 1.2,
height: 1.25,
fontWeight: FontWeight.w700,
letterSpacing: -1,
),
),
const SizedBox(height: AppSpacing.lg),
Text(
'Scanner-first billing, GST-ready invoices and loyalty '
'built in — for supermarkets, pharmacies and retail.',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.78),
fontSize: 15,
height: 1.6,
),
),
const SizedBox(height: AppSpacing.giant),
const _Feature(
icon: Icons.qr_code_scanner_rounded,
title: 'Scan and go',
body: 'No dialogs between items. Barcode to bill instantly.',
),
const _Feature(
icon: Icons.receipt_long_rounded,
title: 'GST compliant',
body: 'Per-slab tax split into CGST and SGST on every bill.',
),
const _Feature(
icon: Icons.stars_rounded,
title: 'Loyalty that runs itself',
body: 'Tiers and points applied without cashier input.',
),
],
),
),
),
),
).animate().fadeIn(duration: 300.ms);
}
}
class _Feature extends StatelessWidget {
const _Feature({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.16),
borderRadius: AppRadius.brSm,
),
child: Icon(icon, color: Colors.white, size: 19),
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
), ),
), ),
if (!tight) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( const Text(
body, 'Scanner-first billing for Indian retail',
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 13, fontSize: 13,
height: 1.5, color: AppColors.textSecondary,
), ),
), ),
], ],
),
),
], ],
), ).animate().fadeIn(duration: 260.ms);
);
} }
} }
class _FormPanel extends ConsumerWidget { class _Card extends StatelessWidget {
const _FormPanel({ const _Card({
required this.formKey, required this.formKey,
required this.email, required this.email,
required this.password, required this.password,
required this.obscure, required this.obscure,
required this.rememberTerminal, required this.rememberTerminal,
required this.showCompactLogo, required this.login,
required this.busy,
required this.failure,
required this.onSelectLogin,
required this.onToggleObscure, required this.onToggleObscure,
required this.onToggleRemember, required this.onToggleRemember,
required this.onSubmit, required this.onSubmit,
@@ -276,23 +217,30 @@ class _FormPanel extends ConsumerWidget {
final TextEditingController password; final TextEditingController password;
final bool obscure; final bool obscure;
final bool rememberTerminal; final bool rememberTerminal;
final bool showCompactLogo; final TerminalLogin login;
final bool busy;
final String? failure;
final ValueChanged<TerminalLogin> onSelectLogin;
final VoidCallback onToggleObscure; final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember; final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit; final VoidCallback onSubmit;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final auth = ref.watch(authControllerProvider); return Container(
final session = ref.watch(cashierSessionProvider);
final busy = auth is Authenticating;
return SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl), padding: const EdgeInsets.all(AppSpacing.xxl),
child: ConstrainedBox( decoration: BoxDecoration(
constraints: const BoxConstraints(maxWidth: 420), color: AppColors.surface,
borderRadius: AppRadius.brXl,
border: Border.all(color: AppColors.border),
boxShadow: const [
BoxShadow(
color: Color(0x0F101828),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),
child: Form( child: Form(
key: formKey, key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
@@ -300,44 +248,41 @@ class _FormPanel extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (showCompactLogo) ...[
Center(
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.center,
child: const Text(
'N',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(height: AppSpacing.xxl),
],
Text(
'Sign in to your store',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: AppSpacing.xs),
const Text( const Text(
'Use the credentials issued when your outlet was ' 'Sign in to your store',
'registered.',
style: TextStyle( style: TextStyle(
fontSize: 13.5, fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
const Text(
'Use the credentials issued when your outlet was registered.',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
height: 1.5, height: 1.5,
), ),
), ),
const SizedBox(height: AppSpacing.xxxl), const SizedBox(height: AppSpacing.xl),
_RoleSwitch(
selected: login,
enabled: !busy,
onSelect: onSelectLogin,
),
const SizedBox(height: AppSpacing.sm),
Text(
login.blurb,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
const SizedBox(height: AppSpacing.xl),
const _Label('Store email'), const _Label('Store email'),
TextFormField( TextFormField(
@@ -364,9 +309,7 @@ class _FormPanel extends ConsumerWidget {
onFieldSubmitted: (_) => onSubmit(), onFieldSubmitted: (_) => onSubmit(),
validator: (v) => (v ?? '').isEmpty validator: (v) => (v ?? '').isEmpty
? 'Password is required' ? 'Password is required'
: ((v ?? '').length < 6 : ((v ?? '').length < 6 ? 'Password looks too short' : null),
? 'Password looks too short'
: null),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Enter your password', hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded), prefixIcon: const Icon(Icons.lock_outline_rounded),
@@ -390,9 +333,7 @@ class _FormPanel extends ConsumerWidget {
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
InkWell( InkWell(
onTap: busy onTap: busy ? null : () => onToggleRemember(!rememberTerminal),
? null
: () => onToggleRemember(!rememberTerminal),
borderRadius: AppRadius.brXs, borderRadius: AppRadius.brXs,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
@@ -427,7 +368,7 @@ class _FormPanel extends ConsumerWidget {
], ],
), ),
if (auth is AuthFailure) ...[ if (failure != null) ...[
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
Container( Container(
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md),
@@ -442,7 +383,7 @@ class _FormPanel extends ConsumerWidget {
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
auth.message, failure!,
style: const TextStyle( style: const TextStyle(
color: AppColors.danger, color: AppColors.danger,
fontSize: 13, fontSize: 13,
@@ -454,7 +395,7 @@ class _FormPanel extends ConsumerWidget {
).animate().shake(duration: 320.ms, hz: 3), ).animate().shake(duration: 320.ms, hz: 3),
], ],
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.lg),
PrimaryButton( PrimaryButton(
label: 'Sign in', label: 'Sign in',
icon: Icons.login_rounded, icon: Icons.login_rounded,
@@ -463,24 +404,15 @@ class _FormPanel extends ConsumerWidget {
onPressed: onSubmit, onPressed: onSubmit,
), ),
const SizedBox(height: AppSpacing.lg),
const SizedBox(height: AppSpacing.xxl), _DemoHint(
Center( login: login,
child: Text( onFill: busy ? null : () => onSelectLogin(login),
'Terminal ${session.terminalId}',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
), ),
], ],
), ),
), ),
), ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.02, end: 0);
),
),
);
} }
} }
@@ -505,100 +437,167 @@ class _Label extends StatelessWidget {
} }
} }
/// Asks which of the signed-in account's outlets this terminal is standing in. /// Which of the two default accounts is being signed into.
/// ///
/// Only shown when there is genuinely a choice. A shop manager pinned to one /// The roles are not cosmetic — they decide whether the terminal opens the
/// location never sees it, which is the common case — this is for a proprietor /// full shell or the billing screen alone, and whether signing out leaves the
/// whose account reaches several shops. /// products behind — so the choice is made before the credentials rather than
/// /// inferred from them afterwards.
/// The list comes from the back office and cannot be typed into. That is the class _RoleSwitch extends StatelessWidget {
/// whole difference from what this replaced: the outlet used to be a store id const _RoleSwitch({
/// entered in Settings, so a till asserted which shop it belonged to. Here it required this.selected,
/// picks from what the account is already entitled to, and the choice is required this.enabled,
/// re-checked server-side when the new session is issued. required this.onSelect,
class _OutletPicker extends StatelessWidget { });
const _OutletPicker({required this.session});
final PosSession session; final TerminalLogin selected;
final bool enabled;
final ValueChanged<TerminalLogin> onSelect;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return Container(
backgroundColor: AppColors.surface, padding: const EdgeInsets.all(4),
shape: RoundedRectangleBorder(borderRadius: AppRadius.brMd), decoration: BoxDecoration(
title: const Text( color: AppColors.surfaceAlt,
'Which outlet is this terminal at?',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: Text(
'Signed in as ${session.fullName.isNotEmpty ? session.fullName : session.email}'
' · ${session.tenantName}',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: session.outlets.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.xs),
itemBuilder: (context, i) {
final outlet = session.outlets[i];
final isCurrent = outlet.locationId == session.locationId;
return ListTile(
dense: true,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.brSm, borderRadius: AppRadius.brSm,
side: BorderSide( border: Border.all(color: AppColors.border),
color: isCurrent ),
? AppColors.primaryBorder child: Row(
: AppColors.border, children: [
for (final login in TerminalLogin.values)
Expanded(
child: _RoleTab(
login: login,
selected: login == selected,
enabled: enabled,
onTap: () => onSelect(login),
), ),
), ),
tileColor: ],
isCurrent ? AppColors.primarySurface : AppColors.surface,
title: Text(
outlet.locationName.isNotEmpty
? outlet.locationName
: 'Outlet ${outlet.locationId}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
), ),
),
// The numeric id is shown deliberately. It is what appears
// in the back office, on a support call and in every log
// line, so a person can match what they are looking at.
subtitle: Text(
[
'ID ${outlet.locationId}',
if (outlet.city.isNotEmpty) outlet.city,
].join(' · '),
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
onTap: () => Navigator.of(context).pop(outlet),
); );
}, }
}
class _RoleTab extends StatelessWidget {
const _RoleTab({
required this.login,
required this.selected,
required this.enabled,
required this.onTap,
});
final TerminalLogin login;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final icon = login == TerminalLogin.admin
? Icons.admin_panel_settings_outlined
: Icons.point_of_sale_rounded;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: AppRadius.brXs,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.surface : Colors.transparent,
borderRadius: AppRadius.brXs,
border: Border.all(
color: selected ? AppColors.primaryBorder : Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 17,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
login.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color:
selected ? AppColors.primary : AppColors.textSecondary,
),
), ),
), ),
], ],
), ),
), ),
),
);
}
}
class _DemoHint extends StatelessWidget {
const _DemoHint({required this.login, this.onFill});
final TerminalLogin login;
final VoidCallback? onFill;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default ${login.label.toLowerCase()} account',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 2),
SelectableText(
'${login.email} \u00b7 ${login.password}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
TextButton(
onPressed: onFill,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
),
],
),
); );
} }
} }

View File

@@ -6,18 +6,22 @@ import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.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/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart'; import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.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 /// Deliberately not a lookup screen. There is no search against the customers
/// just a name, or the whole step skipped. Nothing here blocks the sale. /// 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) { Future<void> showCustomerCaptureSheet(BuildContext context) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
@@ -35,13 +39,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget {
_CustomerCaptureSheetState(); _CustomerCaptureSheetState();
} }
class _CustomerCaptureSheetState class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
extends ConsumerState<_CustomerCaptureSheet> {
String _digits = ''; String _digits = '';
final _name = TextEditingController(); final _name = TextEditingController();
bool _saving = false; bool _saving = false;
String? _error; String? _error;
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
@override @override
void dispose() { void dispose() {
_name.dispose(); _name.dispose();
@@ -54,15 +59,14 @@ class _CustomerCaptureSheetState
_digits += d; _digits += d;
_error = null; _error = null;
}); });
if (_digits.length == AppConstants.mobileNumberLength) {
ref.read(customerLookupProvider.notifier).search(_digits);
}
} }
void _backspace() { void _backspace() {
if (_digits.isEmpty) return; if (_digits.isEmpty) return;
setState(() => _digits = _digits.substring(0, _digits.length - 1)); setState(() {
ref.read(customerLookupProvider.notifier).reset(); _digits = _digits.substring(0, _digits.length - 1);
_error = null;
});
} }
void _clear() { void _clear() {
@@ -70,7 +74,6 @@ class _CustomerCaptureSheetState
_digits = ''; _digits = '';
_error = null; _error = null;
}); });
ref.read(customerLookupProvider.notifier).reset();
} }
void _attachAndClose(Customer? customer) { void _attachAndClose(Customer? customer) {
@@ -78,9 +81,23 @@ class _CustomerCaptureSheetState
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
/// Saves with whatever was given. Both fields are optional: a bare number /// Saves the number under the name given, then puts it on the bill.
/// is still worth keeping, because it is what the WhatsApp bill is sent to. ///
Future<void> _quickRegister() async { /// 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 typed = _name.text.trim();
final name = typed.isEmpty final name = typed.isEmpty
? 'Customer ${_digits.substring(_digits.length - 4)}' ? 'Customer ${_digits.substring(_digits.length - 4)}'
@@ -91,24 +108,43 @@ class _CustomerCaptureSheetState
_error = null; _error = null;
}); });
final repo = ref.read(customerRepositoryProvider);
try { try {
final created = await ref.read(customerRepositoryProvider).create( final created = await repo.create(
Customer(id: '', name: name, mobile: _digits), Customer(id: '', name: name, mobile: _digits),
); );
ref.invalidate(recentCustomersProvider);
if (mounted) _attachAndClose(created); 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; if (!mounted) return;
setState(() { setState(() {
_saving = false; _saving = false;
_error = e is StateError ? e.message : 'Could not save customer.'; _error = 'Could not save customer. Try again.';
});
}
} catch (_) {
if (!mounted) return;
setState(() {
_saving = false;
_error = 'Could not save customer. Try again.';
}); });
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final lookup = ref.watch(customerLookupProvider);
final attached = ref.watch( final attached = ref.watch(
cartControllerProvider.select((c) => c.customer), cartControllerProvider.select((c) => c.customer),
); );
@@ -130,33 +166,49 @@ class _CustomerCaptureSheetState
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_grabber(), _grabber(),
// 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: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_header(attached), _header(attached),
const Divider(height: 1), const Divider(height: 1),
Flexible( Flexible(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl), padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.xl,
AppSpacing.xxl,
AppSpacing.xxl,
),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, box) {
// Side by side once there is room for both columns. // Side by side once there is room for both.
final wide = constraints.maxWidth >= 720; final wide = box.maxWidth >= 660;
final entry = _entryColumn(); final entry = _entryColumn();
final result = _resultColumn(lookup); final details = _detailsColumn();
if (!wide) { if (!wide) {
return Column( return Column(
children: [ children: [
entry, entry,
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
result, details,
], ],
); );
} }
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Expanded(child: entry), SizedBox(width: 300, child: entry),
const SizedBox(width: AppSpacing.xxl), const SizedBox(width: AppSpacing.xxl),
Expanded(child: result), Expanded(child: details),
], ],
); );
}, },
@@ -167,6 +219,11 @@ class _CustomerCaptureSheetState
), ),
), ),
), ),
),
],
),
),
),
); );
} }
@@ -184,11 +241,23 @@ class _CustomerCaptureSheetState
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl, AppSpacing.xxl,
0, 0,
AppSpacing.md, AppSpacing.lg,
AppSpacing.lg, AppSpacing.lg,
), ),
child: Row( child: Row(
children: [ 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( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -197,10 +266,16 @@ class _CustomerCaptureSheetState
Text( Text(
attached == null ? 'Add customer' : 'Change customer', attached == null ? 'Add customer' : 'Change customer',
overflow: TextOverflow.ellipsis, 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( const Text(
'Optional — for loyalty points and tier discounts', 'Optional — number and name, or skip',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 12.5, fontSize: 12.5,
@@ -212,30 +287,45 @@ class _CustomerCaptureSheetState
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
TextButton( TextButton(
onPressed: () => _attachAndClose(null), onPressed: _saving ? null : () => _attachAndClose(null),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary, foregroundColor: AppColors.textSecondary,
minimumSize: const Size(0, 38),
), ),
child: const Text('Skip'), child: const Text('Skip'),
), ),
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: _saving ? null : () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
color: AppColors.textTertiary,
tooltip: 'Close', tooltip: 'Close',
), ),
], ],
), ),
); );
// ----------------------------------------------------------------- Entry
Widget _entryColumn() => Column( Widget _entryColumn() => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_display(), _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( Center(
child: NumericKeypad( child: NumericKeypad(
maxWidth: 340, maxWidth: 300,
onKey: _append, onKey: _append,
onBackspace: _backspace, onBackspace: _backspace,
onClear: _clear, onClear: _clear,
@@ -244,237 +334,96 @@ class _CustomerCaptureSheetState
], ],
); );
Widget _display() => Container( /// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
height: 68, Widget _display() {
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), 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( decoration: BoxDecoration(
color: AppColors.primarySurface, color: filled ? AppColors.surface : AppColors.surfaceAlt,
borderRadius: AppRadius.brLg, borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.primaryBorder), border: Border.all(
color: filled ? AppColors.primary : AppColors.border,
width: filled ? 1.4 : 1,
),
), ),
child: Row( child: Row(
children: [ children: [
const Text( Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 3,
),
decoration: const BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brXs,
),
child: const Text(
'+91', '+91',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
),
const SizedBox(width: AppSpacing.md), const SizedBox(width: AppSpacing.md),
// FittedBox guarantees ten digits fit at any sheet width.
Expanded( Expanded(
child: FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: filled
_digits.isEmpty ? Text(
? ' ' tail.isEmpty ? head : '$head $tail',
: _digits.split('').join(' '), style: AppTypography.money(23).copyWith(
letterSpacing: 1.5,
color: AppColors.textPrimary,
),
)
: Text(
'Mobile number',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 16,
fontWeight: FontWeight.w700, color: AppColors.textTertiary.withValues(alpha: 0.9),
letterSpacing: 1,
color: _digits.isEmpty
? AppColors.textTertiary
: AppColors.textPrimary,
), ),
), ),
), ),
), ),
if (_digits.isNotEmpty) if (filled)
IconButton( IconButton(
onPressed: _clear, onPressed: _saving ? null : _clear,
icon: const Icon(Icons.close_rounded, size: 20), icon: const Icon(Icons.backspace_outlined, size: 18),
color: AppColors.textTertiary, color: AppColors.textTertiary,
tooltip: 'Clear', 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,
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,
),
),
),
label: Text(
c.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12.5),
),
onPressed: () => _attachAndClose(c),
),
],
),
],
],
);
} }
Widget _found(Customer c) => Column( // --------------------------------------------------------------- Details
Widget _detailsColumn() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( _hint(),
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',
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
PrimaryButton( TextField(
label: 'Use this customer', controller: _name,
icon: Icons.check_rounded, textCapitalization: TextCapitalization.words,
large: true, enabled: !_saving,
onPressed: () => _attachAndClose(c), onSubmitted: (_) => _save(),
inputFormatters: [LengthLimitingTextInputFormatter(60)],
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Optional',
prefixIcon: Icon(Icons.person_outline_rounded),
), ),
],
);
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) ...[ if (_error != null) ...[
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
Text( Text(
@@ -488,73 +437,37 @@ class _CustomerCaptureSheetState
icon: Icons.person_add_alt_1_rounded, icon: Icons.person_add_alt_1_rounded,
large: true, large: true,
busy: _saving, busy: _saving,
onPressed: _quickRegister, onPressed: _complete && !_saving ? _save : null,
), ),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
PrimaryButton( PrimaryButton(
label: 'Continue without customer', label: 'Skip — no customer',
tone: ButtonTone.neutral, tone: ButtonTone.neutral,
onPressed: _saving ? null : () => _attachAndClose(null), onPressed: _saving ? null : () => _attachAndClose(null),
), ),
], ],
); );
Widget _nameField() => TextField( Widget _hint() => Container(
controller: _name, width: double.infinity,
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(
padding: const EdgeInsets.all(AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceAlt, color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd, borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
), ),
child: Row( child: const Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 19, color: color), Icon(Icons.dialpad_rounded,
const SizedBox(width: AppSpacing.md), size: 19, color: AppColors.textTertiary,),
SizedBox(width: AppSpacing.md),
Expanded( Expanded(
child: Text( child: Text(
text, 'Key in the mobile number, add a name if the shopper gives '
style: const TextStyle( '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, fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
height: 1.5, height: 1.5,

View File

@@ -5,18 +5,16 @@ import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/sync/sync_engine.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 '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.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 /// Read-only. Uploading is the sync engine's job — it pushes after every sale
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0 /// and retries on its own — so this page reports rather than drives. The
/// and is retried on the next tap. /// 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 { class EventsView extends ConsumerWidget {
const EventsView({super.key}); const EventsView({super.key});
@@ -25,8 +23,6 @@ class EventsView extends ConsumerWidget {
final report = ref.watch(todayReportProvider); final report = ref.watch(todayReportProvider);
final pending = ref.watch(unsyncedCountProvider).value ?? 0; final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final rows = ref.watch(orderSyncRowsProvider).value ?? const []; 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 // 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 // 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), 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( PanelCard(
title: 'Orders', title: 'Orders',
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload', subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
child: ResponsiveTable( child: ResponsiveTable(
columns: const [ columns: const [
TableCol('Invoice', flex: 3), 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('Total', flex: 2, numeric: true),
TableCol('Sync', flex: 2, numeric: true), TableCol('Sync', flex: 2, numeric: true),
], ],
rows: rows rows: rows
.map((o) => [ .map((o) => [
Cell(o.invoiceNumber, bold: true, mono: true), Cell(o.invoiceNumber, bold: true, mono: true),
Cell(Formatters.time(o.createdAt), // Date sits with the time because this table outlives the
color: AppColors.textTertiary,), // 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), Cell(Formatters.money(o.total), mono: true, bold: true),
TagChip( TagChip(
o.isSynced ? 'Synced' : 'Pending', o.isSynced ? 'Synced' : 'Pending',
@@ -205,127 +113,17 @@ class EventsView extends ConsumerWidget {
.toList(), .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) { /// Units on a bill. Whole where they are whole — loose goods are sold by
final ok = outcome.isSuccess; /// weight, so "2.5" is a real answer here and rounding it would be a lie.
final uploaded = outcome.uploaded; static String _units(double count) =>
final attempted = outcome.attempted; count % 1 == 0 ? count.toStringAsFixed(0) : count.toStringAsFixed(2);
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) { /// Flags a bill that could not reach the server on its own.
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,
),
),
],
),
);
}
/// Flags a bill that could not reach the server on its own — the automatic
/// push right after checkout, not the manual "Sync" button below.
/// ///
/// Sits above everything else on the page because a bill stuck here is the /// 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 /// 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 retry = engine.nextAttemptAt;
final retryNote = halted final retryNote = halted
? 'Retrying will not help until this is fixed — press Sync below ' ? 'Retrying will not help until this is fixed.'
'once it is sorted.'
: retry == null : retry == null
? 'It will retry automatically, or press Sync below to try now.' ? 'It will retry automatically.'
: 'It will retry automatically at ${Formatters.time(retry)}, or ' : 'It will retry automatically at ${Formatters.time(retry)}.';
'press Sync below to try now.';
return Container( return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg), margin: const EdgeInsets.only(bottom: AppSpacing.lg),

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
@@ -25,14 +24,18 @@ class ProductImportView extends ConsumerWidget {
final ready = ref.watch(catalogueReadyProvider); final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider); final lastImport = ref.watch(lastImportAtProvider);
final products = ref.watch(allProductsProvider).value ?? const <Product>[]; final products = ref.watch(allProductsProvider).value ?? const <Product>[];
final revision = ref.watch(syncRepositoryProvider).catalogueRevision;
return ModulePage( return ModulePage(
children: [ children: [
if (!ready) _NotImportedBanner(state: state), if (!ready) _NotImportedBanner(state: state),
if (ready) ...[ 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( Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: AppSpacing.lg, spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg, runSpacing: AppSpacing.lg,
children: [ children: [
@@ -43,13 +46,6 @@ class ProductImportView extends ConsumerWidget {
color: AppColors.success, color: AppColors.success,
caption: 'available offline', caption: 'available offline',
), ),
StatTile(
label: 'Catalogue Revision',
value: revision ?? '',
icon: Icons.tag_rounded,
color: AppColors.info,
caption: 'server version',
),
StatTile( StatTile(
label: 'Last Imported', label: 'Last Imported',
value: lastImport == null value: lastImport == null
@@ -91,7 +87,7 @@ class ProductImportView extends ConsumerWidget {
child: ResponsiveTable( child: ResponsiveTable(
columns: const [ columns: const [
TableCol('Product', flex: 4), TableCol('Product', flex: 4),
TableCol('SKU', flex: 3, priority: 1), TableCol('Barcode', flex: 3, priority: 1),
TableCol('Category', flex: 2, priority: 1), TableCol('Category', flex: 2, priority: 1),
TableCol('Price', flex: 2, numeric: true), TableCol('Price', flex: 2, numeric: true),
TableCol('Stock', 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)), 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, TagChip(p.category.label,
color: AppColors.textSecondary,), color: AppColors.textSecondary,),
Cell(Formatters.money(p.price), mono: true, bold: true), Cell(Formatters.money(p.price), mono: true, bold: true),

View File

@@ -33,7 +33,11 @@ class PromosView extends ConsumerWidget {
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
), ),
error: (e, _) => Text('Could not load campaigns: $e'), 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( data: (promos) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_summary(promos), _summary(promos),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
@@ -55,6 +59,7 @@ class PromosView extends ConsumerWidget {
.length; .length;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: StatTile( child: StatTile(
@@ -119,6 +124,7 @@ class PromosView extends ConsumerWidget {
) )
: Column( : Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (final promo in promos) for (final promo in promos)
_PromoRow(promo: promo, isAdmin: isAdmin), _PromoRow(promo: promo, isAdmin: isAdmin),

View File

@@ -9,6 +9,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
import '../../../data/local/order_dao.dart'; import '../../../data/local/order_dao.dart';
import '../../../data/local/void_pin_store.dart';
import '../../../domain/entities/store_account.dart'; import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart'; import '../providers/printer_settings.dart';
@@ -17,6 +18,7 @@ import '../widgets/staff_dialogs.dart';
import '../widgets/store_details_dialog.dart'; import '../widgets/store_details_dialog.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart'; import '../widgets/module_widgets.dart';
import '../../../core/widgets/numeric_keypad.dart';
/// Terminal and store configuration. /// Terminal and store configuration.
class SettingsView extends ConsumerStatefulWidget { class SettingsView extends ConsumerStatefulWidget {
@@ -32,6 +34,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
bool _testingDrawer = false; bool _testingDrawer = false;
bool _loadedDrawerFields = false; bool _loadedDrawerFields = false;
/// Null until the first read comes back from the meta table.
bool? _hasRemovalPin;
bool _loadedRemovalPin = false;
bool _scannerSound = true; bool _scannerSound = true;
bool _roundOff = true; bool _roundOff = true;
bool _autoLoyalty = true; bool _autoLoyalty = true;
@@ -50,6 +56,16 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
// Seeded once, from whatever was persisted. Assigning on every build would // Seeded once, from whatever was persisted. Assigning on every build would
// fight the cashier for the cursor while they type. // 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); final printer = ref.watch(printerSettingsProvider);
if (!_loadedDrawerFields && printer.hasDrawer) { if (!_loadedDrawerFields && printer.hasDrawer) {
_loadedDrawerFields = true; _loadedDrawerFields = true;
@@ -80,6 +96,8 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
_staffCard(store, user), _staffCard(store, user),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
_removalPinCard(user),
const SizedBox(height: AppSpacing.lg),
_aboutCard(), _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() { Widget _connectivityCard() {
final ready = ref.watch(catalogueReadyProvider); final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider); 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'),
),
],
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/router/app_router.dart'; import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.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/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart'; import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart'; import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart'; import '../../customer/widgets/customer_capture_sheet.dart';
import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/cart_controller.dart';
@@ -97,7 +99,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
_customerCard(), _customerCard(),
const SizedBox(height: AppSpacing.md), 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), _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,16 +122,37 @@ 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), padding: EdgeInsets.all(pad),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1340),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(flex: 4, child: SingleChildScrollView(child: left)), Expanded(child: left),
const SizedBox(width: AppSpacing.lg), const SizedBox(width: AppSpacing.lg),
Expanded(flex: 5, child: SingleChildScrollView(child: right)), 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,23 +334,32 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
); );
} }
/// Amount entry for whichever method is active. Every method works the /// Amount entry for whichever method is active.
/// same way now — a keypad, an Exact shortcut, and an explicit amount — ///
/// rather than cash alone asking what was received while card/UPI/wallet /// Every method works the same way — a keypad, an Exact shortcut and an
/// silently assumed the full balance. Cash additionally gets denomination /// explicit amount — rather than cash alone asking what was received while
/// chips and a change-due row, since only cash can be over-tendered; a /// card/UPI/wallet silently assumed the full balance. Cash additionally gets
/// method that captures a reference (card, UPI, gift card) additionally /// 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. /// gets that field below the keypad.
Widget _amountTender(PaymentController controller, PaymentState state) { Widget _amountTender(PaymentController controller, PaymentState state) {
final cash = state.activeMethod.needsChange; 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( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
children: [ children: [
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)), Icon(methodIcon(state.activeMethod),
size: 20, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
@@ -387,68 +376,31 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
], ],
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Container( _amountField(),
padding: const EdgeInsets.symmetric( const SizedBox(height: AppSpacing.md),
horizontal: AppSpacing.lg,
vertical: AppSpacing.md, if (!sideBySide) ...[
), _shortcutWrap(controller, cash),
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), 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) ...[ if (cash) ...[
const SizedBox(height: AppSpacing.md),
_changeRow(controller.changeDue), _changeRow(controller.changeDue),
],
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Center( ],
child: NumericKeypad(
allowDecimal: true, if (sideBySide)
maxWidth: 330, Row(
onKey: _appendCash, crossAxisAlignment: CrossAxisAlignment.start,
onBackspace: _backspaceCash, children: [
), Expanded(child: _shortcutColumn(controller)),
), const SizedBox(width: AppSpacing.lg),
SizedBox(width: 296, child: _keypad()),
],
)
else
Center(child: _keypad()),
if (state.activeMethod.needsReference) ...[ if (state.activeMethod.needsReference) ...[
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
TextField( TextField(
@@ -464,8 +416,109 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
), ),
), ),
], ],
const SizedBox(height: AppSpacing.md),
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0), _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: [
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,
),
child: Text('+ ₹$note'),
),
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
],
], ],
); );
} }
@@ -513,22 +566,380 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
); );
} }
Widget _splitButton(PaymentController controller, {double? amount}) { /// Takes part of the bill on the current method.
return OutlinedButton.icon( ///
onPressed: controller.balanceDue > 0 /// 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,
controller.addSplit( /// or to press it for a payment that is not one. It appears only when the
amount: amount?.clamp(0, controller.balanceDue).toDouble(), /// 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 = ''); setState(() => _cashBuffer = '');
} },
: null, icon: const Icon(Icons.add_rounded, size: 18),
icon: const Icon(Icons.call_split_rounded, size: 17), label: Text(
label: const Text('Add as split payment'), 'Take ${Formatters.money(entered)} by '
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), '${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 // ------------------------------------------------------------ Bottom bar
Widget _bottomBar( Widget _bottomBar(
PaymentController controller, PaymentController controller,
@@ -679,7 +1090,11 @@ class _MethodTile extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: AppSpacing.xs),
Text( Text(
method.label, 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,
};

View File

@@ -208,10 +208,19 @@ class CartController extends StateNotifier<Cart> {
setQuantity(productId, line.quantity + by); 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}) { void decrement(String productId, {double by = 1}) {
final line = state.lineFor(productId); final line = state.lineFor(productId);
if (line == null) return; 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) { void removeLine(String productId) {

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_controller.dart';
/// The modules a cashier needs. Deliberately excludes analytics — this /// The modules a cashier needs. Deliberately excludes analytics — this
/// terminal is for billing, not back-office reporting. /// terminal is for billing, not back-office reporting.
enum PosModule { enum PosModule {
@@ -41,4 +43,37 @@ enum NavSection {
PosModule.values.where((m) => m.section == this).toList(); 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); 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();
});

View File

@@ -35,6 +35,11 @@ import 'pos_view.dart';
/// * `11201300` sidebar as an icon rail, docked bill /// * `11201300` sidebar as an icon rail, docked bill
/// * `9201120` icon rail, bill becomes a bottom sheet /// * `9201120` icon rail, bill becomes a bottom sheet
/// * `< 920` sidebar goes off-canvas behind a menu button /// * `< 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 { class PosDashboardScreen extends ConsumerStatefulWidget {
const PosDashboardScreen({super.key}); const PosDashboardScreen({super.key});
@@ -119,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final layout = PosLayout.of(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 ready = ref.watch(catalogueReadyProvider);
final isPos = module == PosModule.pos && ready; final isPos = module == PosModule.pos && ready;
final cashierMode = ref.watch(isCashierModeProvider);
final showSidebar = !cashierMode;
// Only the terminal itself needs the bill docked beside it. // Only the terminal itself needs the bill docked beside it.
final showDockedBill = isPos && !layout.billingIsSheet; final showDockedBill = isPos && !layout.billingIsSheet;
final showCartFab = isPos && layout.billingIsSheet; final showCartFab = isPos && layout.billingIsSheet;
@@ -130,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
return Scaffold( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
drawer: layout.sidebarIsDrawer drawer: showSidebar && layout.sidebarIsDrawer
? Drawer( ? Drawer(
width: PosLayout.expandedWidth, width: PosLayout.expandedWidth,
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
@@ -157,13 +167,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar), if (showSidebar && !layout.sidebarIsDrawer)
AppSidebar(mode: layout.sidebar),
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
PageHeader( PageHeader(
layout: layout, 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( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(

View File

@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_layout.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../widgets/category_chips.dart'; import '../widgets/category_chips.dart';
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
final state = ref.watch(catalogueImportProvider); final state = ref.watch(catalogueImportProvider);
final running = state is ImportRunning; 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( return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl), padding: const EdgeInsets.all(AppSpacing.xxl),
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
), ),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
Text( Text(
'Import products to start billing', cashier
? 'No products on this terminal'
: 'Import products to start billing',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
const Text( Text(
'This terminal has no catalogue yet. Pull the current products ' cashier
'once at the start of your shift — after that everything runs ' ? 'Nothing has been imported for this shift yet. Ask an '
'offline.', '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, textAlign: TextAlign.center,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.textSecondary, color: AppColors.textSecondary,
height: 1.6, height: 1.6,
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
), ),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
if (running) ...[ if (!cashier && state is ImportRunning) ...[
Text( Text(
state.stage, state.stage,
style: const TextStyle( style: const TextStyle(
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
], ],
if (state is ImportFailed) ...[ if (!cashier && state is ImportFailed) ...[
Container( Container(
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md),
margin: const EdgeInsets.only(bottom: AppSpacing.lg), margin: const EdgeInsets.only(bottom: AppSpacing.lg),
@@ -171,6 +183,34 @@ class _CatalogueRequired extends ConsumerWidget {
), ),
], ],
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( PrimaryButton(
label: 'Import catalogue now', label: 'Import catalogue now',
icon: Icons.cloud_download_rounded, icon: Icons.cloud_download_rounded,
@@ -189,6 +229,7 @@ class _CatalogueRequired extends ConsumerWidget {
label: const Text('Open Product Import'), label: const Text('Open Product Import'),
), ),
], ],
],
), ),
), ),
), ),

View File

@@ -5,16 +5,18 @@ import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/numeric_keypad.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 /// Prompts for the removal PIN before taking a rung item back off a bill, and
/// scanned item back out of the bill, or clearing it — and resolves `true` /// resolves `true` only once it verifies.
/// only once a PIN belonging to an [StaffRole.admin] account is verified. ///
/// 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 /// 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 /// quietly taking something back out of a bill a customer has already been
/// shown, after it was rung up. /// shown, after it was rung up.
Future<bool> requireAdminPin( Future<bool> requireVoidPin(
BuildContext context, BuildContext context,
WidgetRef ref, { WidgetRef ref, {
required String reason, required String reason,
@@ -67,11 +69,11 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
}); });
final store = ref.read(localStoreProvider); final store = ref.read(localStoreProvider);
final user = await store.staff.authenticate(_pin); final ok = await store.voidPin.verify(_pin);
if (!mounted) return; if (!mounted) return;
if (user == null) { if (!ok) {
setState(() { setState(() {
_checking = false; _checking = false;
_error = 'Incorrect PIN.'; _error = 'Incorrect PIN.';
@@ -80,16 +82,6 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
return; 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); Navigator.of(context).pop(true);
} }
@@ -112,7 +104,7 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
const Expanded( const Expanded(
child: Text( child: Text(
'Admin PIN required', 'Removal PIN required',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,

View File

@@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_layout.dart';
import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_typography.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../../sync/widgets/sign_out_dialog.dart';
import '../providers/cart_controller.dart'; import '../providers/cart_controller.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
@@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
child: Column( child: Column(
children: [ 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: section, section: section,
expanded: expanded, 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( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( const BrandMark(size: 36),
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,
),
),
),
if (expanded) ...[ if (expanded) ...[
const SizedBox(width: AppSpacing.md), const SizedBox(width: AppSpacing.md),
Flexible( Flexible(
@@ -228,7 +212,8 @@ class _Section extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 cartCount = ref.watch(cartItemCountProvider);
final ready = ref.watch(catalogueReadyProvider); final ready = ref.watch(catalogueReadyProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0; final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
@@ -255,7 +240,7 @@ class _Section extends ConsumerWidget {
), ),
child: Divider(height: 1), child: Divider(height: 1),
), ),
for (final module in section.modules) for (final module in section.modules.where(visible.contains))
_NavTile( _NavTile(
module: module, module: module,
expanded: expanded, 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,
),
),
],
],
),
),
),
),
);
}
}

View File

@@ -18,7 +18,6 @@ import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart'; import '../providers/cart_controller.dart';
import 'admin_pin_dialog.dart'; import 'admin_pin_dialog.dart';
import 'cart_line_tile.dart'; import 'cart_line_tile.dart';
import 'discount_sheet.dart';
/// Always-visible bill on the right of the dashboard. /// Always-visible bill on the right of the dashboard.
class BillingPanel extends ConsumerWidget { class BillingPanel extends ConsumerWidget {
@@ -66,8 +65,6 @@ class BillingPanel extends ConsumerWidget {
controller, controller,
line.product.id, 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 /// Once an item is on the bill, taking it back off needs the removal PIN an
/// approval — a cashier can always start an entirely new sale instead. This /// admin sets in Settings — a cashier can always start an entirely new sale
/// is the one gate both removal paths (a single line, or the whole cart) go /// instead. This is the one gate every removal path goes through, so the
/// through, so they can never drift out of sync with each other. /// close button and the swipe can never drift apart.
Future<void> _removeLine( Future<void> _removeLine(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
CartController controller, CartController controller,
String productId, String productId,
) async { ) async {
final ok = await requireAdminPin( final ok = await requireVoidPin(
context, context,
ref, 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); 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 { class _Header extends ConsumerWidget {
const _Header({required this.cart, required this.inSheet}); const _Header({required this.cart, required this.inSheet});
@@ -118,7 +102,6 @@ class _Header extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 // 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. // line up on one visual line instead of the cart title floating lower.
final contentPadding = PosLayout.of(context).contentPadding; final contentPadding = PosLayout.of(context).contentPadding;
@@ -127,8 +110,9 @@ class _Header extends ConsumerWidget {
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(horizontal: contentPadding), padding: EdgeInsets.symmetric(horizontal: contentPadding),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( Flexible(
child: Text( child: Text(
'Cart', 'Cart',
@@ -137,9 +121,12 @@ class _Header extends ConsumerWidget {
), ),
), ),
if (cart.isNotEmpty) ...[ if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: AppColors.primarySurface, color: AppColors.primarySurface,
borderRadius: AppRadius.brPill, borderRadius: AppRadius.brPill,
@@ -154,43 +141,14 @@ class _Header extends ConsumerWidget {
), ),
), ),
], ],
SizedBox(),
SizedBox(),
Spacer(), const Spacer(),
// Undo, Park and Clear used to sit here as three icon buttons. They
// Icon-only actions: labelled buttons overflowed the 380px panel. // are the least-pressed controls on the panel and they were the
// Grouped tight with even spacing, flush against the same right // first thing the eye landed on, above the bill itself. Undo is on
// edge the header buttons on the left use. // F8; Park and Clear moved down beside the total, next to the button
Row( // a cashier is already reaching for.
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) if (inSheet)
_IconAction( _IconAction(
icon: Icons.close_rounded, icon: Icons.close_rounded,
@@ -200,8 +158,6 @@ class _Header extends ConsumerWidget {
), ),
], ],
), ),
],
),
); );
} }
} }
@@ -313,21 +269,6 @@ class _Summary extends ConsumerWidget {
.join(', '), .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) if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
InkWell( InkWell(
onTap: () => cart.pointsRedeemed > 0 onTap: () => cart.pointsRedeemed > 0
@@ -466,18 +407,23 @@ class _Actions extends ConsumerWidget {
AppSpacing.xl, AppSpacing.xl,
AppSpacing.xl, AppSpacing.xl,
), ),
child: PrimaryButton( child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PrimaryButton(
label: 'CHARGE', label: 'CHARGE',
large: true, large: true,
onPressed: enabled onPressed: enabled
? () async { ? () async {
// Ask once per bill, before payment. Skipping is one tap and // Ask once per bill, before payment. Skipping is one tap
// leaves the sale as walk-in. // and leaves the sale as walk-in.
if (ref.read(cartControllerProvider).customer == null) { if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context); await showCustomerCaptureSheet(context);
} }
// Navigation result is not needed here. // Navigation result is not needed here.
if (context.mounted) unawaited(context.push(AppRoutes.payment)); if (context.mounted) {
unawaited(context.push(AppRoutes.payment));
}
} }
: null, : null,
trailing: enabled trailing: enabled
@@ -487,6 +433,8 @@ class _Actions extends ConsumerWidget {
) )
: null, : null,
), ),
],
),
); );
} }
} }

View File

@@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget {
required this.onIncrement, required this.onIncrement,
required this.onDecrement, required this.onDecrement,
required this.onRemove, required this.onRemove,
this.onDiscount,
}); });
final CartLine line; final CartLine line;
final VoidCallback onIncrement; final VoidCallback onIncrement;
final VoidCallback onDecrement; final VoidCallback onDecrement;
final VoidCallback onRemove; final VoidCallback onRemove;
final VoidCallback? onDiscount;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget {
return Dismissible( return Dismissible(
key: ValueKey('dismiss_${p.id}'), key: ValueKey('dismiss_${p.id}'),
direction: DismissDirection.endToStart, 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( background: Container(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: AppSpacing.xl), padding: const EdgeInsets.only(right: AppSpacing.xl),
@@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget {
color: AppColors.textTertiary, color: AppColors.textTertiary,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
tooltip: 'Remove', tooltip: 'Remove from bill (needs the removal PIN)',
), ),
],), ],),
@@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget {
onIncrement: onIncrement, onIncrement: onIncrement,
onDecrement: onDecrement, 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(), const Spacer(),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget {
borderRadius: AppRadius.brSm, borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border), 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: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
_btn(Icons.remove_rounded, onDecrement), _btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null),
Container( Container(
constraints: const BoxConstraints(minWidth: 42), constraints: const BoxConstraints(minWidth: 42),
alignment: Alignment.center, 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, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 34, width: 34,
height: 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,
),
), ),
), ),
); );

View File

@@ -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,
),
),
],),
],),
),
);
}
}

View File

@@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_layout.dart';
import '../../../core/utils/formatters.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/cart_controller.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, box) { builder: (context, box) {
final showStatus = box.maxWidth >= 720; 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, WidgetRef ref,
bool compact, bool compact,
bool showStatus, bool showStatus,
bool showBrand,
) { ) {
final module = ref.watch(activeModuleProvider);
final now = ref.watch(clockProvider).value ?? DateTime.now(); 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( return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
), ),
child: Row( child: Row(
children: [ children: [
if (compact) ...[ if (compact && onMenuTap != null) ...[
IconButton( IconButton(
onPressed: onMenuTap, onPressed: onMenuTap,
icon: const Icon(Icons.menu_rounded), icon: const Icon(Icons.menu_rounded),
@@ -68,7 +78,12 @@ class PageHeader extends ConsumerWidget {
const SizedBox(width: AppSpacing.xs), 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) ...[ if (showStatus) ...[
_LivePill(offline: ref.watch(simulateOfflineProvider)), _LivePill(offline: ref.watch(simulateOfflineProvider)),
@@ -82,14 +97,26 @@ class PageHeader extends ConsumerWidget {
fontFeatures: [FontFeature.tabularFigures()], 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), _ParkedBillsButton(compact: compact),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
_NewSaleButton(compact: compact), _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),
);
}
}

View 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,
],
),
);
}
}

View 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 todays 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,),
],
),
),
),
);
}
}

View File

@@ -222,18 +222,6 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
await store.syncConfig.load(ref.read(syncConfigProvider)); 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(); await ref.read(connectivityServiceProvider).start();
final engine = ref.read(syncEngineProvider); final engine = ref.read(syncEngineProvider);

View File

@@ -10,6 +10,7 @@ import '../../../core/widgets/primary_button.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart'; import '../../pos/providers/catalog_providers.dart';
import '../../pos/providers/navigation_provider.dart';
import '../../../domain/repositories/sync_repository.dart'; import '../../../domain/repositories/sync_repository.dart';
import '../providers/sync_controller.dart'; import '../providers/sync_controller.dart';
@@ -38,21 +39,52 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
Future<void> _finish() async { Future<void> _finish() async {
ref.read(cartControllerProvider.notifier).reset(); 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(); await ref.read(authControllerProvider.notifier).signOut();
// Mirrors what a successful import does on the way in: bump the version // Mirrors what a successful import does on the way in: bump the version so
// so catalogueReadyProvider re-reads hasCatalogue as false, and drop the // catalogueReadyProvider re-reads hasCatalogue, and drop the cached product
// cached product lists so the next session's grid doesn't flash this // lists so the next session's grid doesn't flash this session's data before
// session's now-cleared data before it re-fetches. // 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.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider); ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider); ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider); ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider); 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; 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(); Navigator.of(context).pop();
context.go(AppRoutes.login); 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 { Future<void> _pushThenFinish() async {
@@ -76,8 +108,10 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
final pushing = ref.watch(orderSyncProvider) is SyncRunning; final pushing = ref.watch(orderSyncProvider) is SyncRunning;
final failed = _result != null && !_result!.isSuccess; final failed = _result != null && !_result!.isSuccess;
final cashier = ref.watch(isCashierModeProvider);
return AlertDialog( return AlertDialog(
title: const Text('End shift'), title: Text(cashier ? 'End shift' : 'Sign out'),
contentPadding: const EdgeInsets.fromLTRB( contentPadding: const EdgeInsets.fromLTRB(
AppSpacing.xxl, AppSpacing.xxl,
AppSpacing.lg, AppSpacing.lg,
@@ -92,6 +126,26 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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) if (cart.isNotEmpty)
_Banner( _Banner(
icon: Icons.warning_amber_rounded, icon: Icons.warning_amber_rounded,

View File

@@ -274,10 +274,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:

30
test/widget_test.dart Normal file
View File

@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}