diff --git a/assets/images/bg.webp b/assets/images/bg.webp new file mode 100644 index 0000000..9c6e191 Binary files /dev/null and b/assets/images/bg.webp differ diff --git a/lib/app/providers.dart b/lib/app/providers.dart index 140f473..c04e1c5 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -17,9 +17,7 @@ import '../data/remote/simulated_order_transport.dart'; import '../data/repositories/store_repository_impl.dart'; import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart'; -import '../data/local/session_store.dart'; import '../data/local/terminal_identity.dart'; -import '../data/remote/pos_auth_api.dart'; import '../data/sync/sync_engine.dart'; import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/product_repository.dart'; @@ -163,28 +161,10 @@ final storeRepositoryProvider = Provider( (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((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((ref) => SessionStore()); - /// The outlet, refreshed whenever staff or details change. -/// -/// The email is the signed-in account's, not a constant. It used to be -/// `DemoCredentials.email` — the same address on every install of a build, -/// which is what made the store login decorative. final storeAccountProvider = FutureProvider( (ref) => ref.watch(storeRepositoryProvider).load( - email: ref.watch(authControllerProvider.notifier).session?.email ?? '', + email: DemoCredentials.email, ), ); diff --git a/lib/core/config/sync_config.dart b/lib/core/config/sync_config.dart index 500a736..fb13417 100644 --- a/lib/core/config/sync_config.dart +++ b/lib/core/config/sync_config.dart @@ -35,7 +35,6 @@ class SyncConfig { this.password, this.httpBaseUrl = '', this.apiKey, - this.sessionToken, this.ackTimeout = const Duration(seconds: 20), this.batchSize = 50, }); @@ -53,38 +52,8 @@ class SyncConfig { final String? password; final String httpBaseUrl; - - /// A static key shared by every terminal at a deployment, if one is set. - /// - /// Predates sign-in and says nothing about *who* is at the till, so it cannot - /// scope a request to an outlet. Kept for deployments that put one in front - /// of the endpoint. final String? apiKey; - /// The signed session from `POST /login`, held for the trading day. - /// - /// Distinct from [apiKey] because the two answer different questions. The key - /// says "this request came from our fleet"; the session says "this request - /// came from Selvapuram, signed in as Ragul, and may touch that outlet and no - /// other". Only the second can stop a till reaching another tenant's books, - /// which is why it takes precedence when both are present. - final String? sessionToken; - - /// What goes in the Authorization header. - /// - /// One accessor rather than the same `??` repeated at each call site, because - /// the request that forgot it would be the one silently sending no - /// credentials at all. - String? get bearerToken { - final session = sessionToken?.trim(); - if (session != null && session.isNotEmpty) return session; - - final key = apiKey?.trim(); - if (key != null && key.isNotEmpty) return key; - - return null; - } - /// How long to wait for the back office to confirm a batch before treating /// the outcome as unknown and leaving every row pending. /// @@ -179,7 +148,6 @@ class SyncConfig { String? password, String? httpBaseUrl, String? apiKey, - String? sessionToken, Duration? ackTimeout, int? batchSize, }) => @@ -194,7 +162,6 @@ class SyncConfig { password: password ?? this.password, httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl, apiKey: apiKey ?? this.apiKey, - sessionToken: sessionToken ?? this.sessionToken, ackTimeout: ackTimeout ?? this.ackTimeout, batchSize: batchSize ?? this.batchSize, ); diff --git a/lib/core/constants/asset_paths.dart b/lib/core/constants/asset_paths.dart index c833145..d2cbd80 100644 --- a/lib/core/constants/asset_paths.dart +++ b/lib/core/constants/asset_paths.dart @@ -1,11 +1,17 @@ /// Typed references to bundled assets. /// -/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome -/// artwork is painted in code, so there are no raster or SVG assets to ship. +/// Product imagery uses emoji glyphs and the welcome artwork is painted in +/// code, so the only raster asset shipped is the mark itself. class AssetPaths { const AssetPaths._(); static const String _snd = 'assets/sounds'; + static const String _img = 'assets/images'; + + /// The Nearle mark. Every place that used to draw a letter "N" in a + /// gradient box now renders this instead, so the brand cannot drift between + /// the login screen, the sidebar and the cashier header. + static const String logo = '$_img/logo.png'; static const String beepSuccess = '$_snd/beep_success.wav'; static const String beepError = '$_snd/beep_error.wav'; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d777f40..d4dee76 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -8,6 +8,7 @@ import '../../presentation/auth/screens/login_screen.dart'; import '../../presentation/payment/screens/payment_screen.dart'; import '../../presentation/pos/screens/pos_dashboard_screen.dart'; import '../../presentation/receipt/screens/receipt_screen.dart'; +import '../../presentation/shift/screens/end_shift_screen.dart'; class AppRoutes { const AppRoutes._(); @@ -19,6 +20,10 @@ class AppRoutes { static const String pos = '/'; static const String payment = '/payment'; static const String receipt = '/receipt'; + + /// Drawer count and hand-over. Reached from the session-end chooser, never + /// linked to directly, and guarded like every other signed-in route. + static const String endShift = '/end-shift'; } /// Router with an authentication guard. @@ -60,6 +65,11 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => _fade(state, const PosDashboardScreen()), ), + GoRoute( + path: AppRoutes.endShift, + name: 'endShift', + pageBuilder: (context, state) => _slide(state, const EndShiftScreen()), + ), GoRoute( path: AppRoutes.payment, name: 'payment', diff --git a/lib/core/widgets/brand_mark.dart b/lib/core/widgets/brand_mark.dart new file mode 100644 index 0000000..f5e85c4 --- /dev/null +++ b/lib/core/widgets/brand_mark.dart @@ -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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index d2a3edc..d98798f 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -9,6 +9,7 @@ import '../local/staff_dao.dart'; import '../local/sync_config_store.dart'; import '../local/sync_log_dao.dart'; import '../local/terminal_identity.dart'; +import '../local/void_pin_store.dart'; /// Terminal-side storage facade. /// @@ -28,6 +29,7 @@ class LocalStore { late PromoDao promos; late SyncConfigStore syncConfig; late TerminalIdentityStore identityStore; + late VoidPinStore voidPin; /// Who this till is. Minted on first run, then stable forever. late TerminalIdentity terminal; @@ -60,6 +62,7 @@ class LocalStore { promos = PromoDao(AppDatabase.instance.db); syncConfig = SyncConfigStore(catalogue); identityStore = TerminalIdentityStore(catalogue); + voidPin = VoidPinStore(catalogue, staff); // A terminal with no staff cannot be signed into at all, so this runs // before anything else can ask who is on shift. @@ -148,9 +151,13 @@ class LocalStore { /// Drops the imported catalogue from disk and from the in-memory cache. /// - /// Called at sign-out so the next shift never bills against a copy left - /// over from this one — [hasCatalogue] goes back to false, and the only way - /// to sell again is a fresh pull from the back office. + /// Called when a *cashier* signs out, so the next shift never bills against + /// a copy left over from this one — [hasCatalogue] goes back to false, and + /// the only way to sell again is a fresh pull from the back office. + /// + /// Not called on an admin sign-out. An admin's whole job at this terminal is + /// to pull the catalogue and hand the till over, so wiping it on the way out + /// would undo the thing they just did. Future clearCatalogue() async { await catalogue.clearCatalogue(); _products.clear(); diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index 3f11a8d..e163ddc 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -524,4 +524,14 @@ class MetaKeys { static const String printerName = 'printer_name'; static const String autoPrint = 'auto_print'; static const String openDrawer = 'open_cash_drawer'; + + /// PIN that authorises taking a rung item back off a bill. + /// + /// Stored hashed, like a staff PIN — the terminal only ever holds the hash + /// and its salt, so lifting the database file does not hand over the ability + /// to void. Deliberately separate from staff PINs: an admin sets it once and + /// gives it to whoever is on the counter, so a removal can be authorised + /// without an admin walking over to the till. + static const String voidPinHash = 'void_pin_hash'; + static const String voidPinSalt = 'void_pin_salt'; } diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart index 7602e27..0d020c6 100644 --- a/lib/data/local/order_dao.dart +++ b/lib/data/local/order_dao.dart @@ -303,20 +303,27 @@ class OrderDao { return rows.isEmpty ? null : rows.first; } - Future>> syncRows({int limit = 200}) => _db.query( - Tables.orders, - columns: [ - 'id', - 'invoice_number', - 'total', - 'created_at', - 'sync_status', - 'synced_at', - 'sync_attempts', - 'sync_error', - ], - orderBy: 'created_at DESC', - limit: limit, + /// Rows for the sync log, with each bill's unit count folded in. + /// + /// The count comes from a correlated sum over [Tables.orderItems] rather + /// than a column on the order: quantity can be fractional (loose weight), so + /// there is no line count that answers "how many units were on this bill". + /// A single aggregate keeps this to one query rather than one per row. + Future>> syncRows({int limit = 200}) => + _db.rawQuery( + ''' + SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status, + o.synced_at, o.sync_attempts, o.sync_error, + COALESCE( + (SELECT SUM(i.quantity) FROM ${Tables.orderItems} i + WHERE i.order_id = o.id), + 0 + ) AS item_count + FROM ${Tables.orders} o + ORDER BY o.created_at DESC + LIMIT ? + ''', + [limit], ); // ----------------------------------------------------------------- Sync diff --git a/lib/data/local/session_store.dart b/lib/data/local/session_store.dart deleted file mode 100644 index 9013ea2..0000000 --- a/lib/data/local/session_store.dart +++ /dev/null @@ -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 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); - 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 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 clear() async { - try { - await _secure.delete(key: _key); - } on Object catch (e) { - debugPrint('Could not clear the session: $e'); - } - } -} diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart index b5cc873..414d9d6 100644 --- a/lib/data/local/staff_dao.dart +++ b/lib/data/local/staff_dao.dart @@ -25,10 +25,6 @@ class StaffDao { final Database _db; - /// Exposed for [StaffImport], which lives in this file and is part of this - /// type in everything but syntax — an extension cannot see a private field. - Database get db => _db; - static const _uuid = Uuid(); /// The accounts a shop starts with. @@ -281,93 +277,3 @@ class StaffDao { isActive: (row['is_active'] as int? ?? 1) == 1, ); } - -/// Replaces the terminal's staff with what the back office says. -/// -/// The back office is the source of truth for who works at a shop, and this is -/// where that becomes true rather than aspirational. It exists because the -/// alternative — three names and three PINs compiled into the app — meant every -/// install of a build shared the same three logins, readable by anyone with the -/// APK. -/// -/// Three things happen, and the second is the one that matters: -/// -/// 1. every person the back office named is written, keyed on their user id so -/// a re-sync updates rather than duplicates; -/// 2. **the seeded accounts are deactivated**, so the moment a shop has real -/// staff the built-in PINs stop working — without this the hardcoded -/// logins would survive alongside the real ones for ever; and -/// 3. anyone previously imported who is no longer named is deactivated too, -/// because a leaver removed in the back office must lose the till. -/// -/// Deactivated, never deleted. Bills carry the cashier's name and shifts are -/// settled against it, so a hard delete would orphan a day's takings. -/// -/// Does nothing at all when [members] is empty. That is the common case today — -/// most outlets have no staff recorded — and wiping a working till's logins -/// because the back office has not been filled in yet would close a shop. -extension StaffImport on StaffDao { - Future replaceFromBackOffice(List members) async { - if (members.isEmpty) return 0; - - final now = DateTime.now().millisecondsSinceEpoch; - final imported = {}; - - 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; -} diff --git a/lib/data/local/void_pin_store.dart b/lib/data/local/void_pin_store.dart new file mode 100644 index 0000000..a0d4389 --- /dev/null +++ b/lib/data/local/void_pin_store.dart @@ -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 get isConfigured async { + final hash = await _meta.meta(MetaKeys.voidPinHash); + return hash != null && hash.isNotEmpty; + } + + Future 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 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 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; +} diff --git a/lib/data/remote/http_catalogue_source.dart b/lib/data/remote/http_catalogue_source.dart index f8db208..e6d6a28 100644 --- a/lib/data/remote/http_catalogue_source.dart +++ b/lib/data/remote/http_catalogue_source.dart @@ -156,8 +156,7 @@ class HttpCatalogueSource implements CatalogueSource { uri, headers: { 'accept': 'application/json', - if (config.bearerToken != null) - 'authorization': 'Bearer ${config.bearerToken}', + if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}', }, ).timeout(_timeout); } on Exception catch (e) { diff --git a/lib/data/remote/http_order_transport.dart b/lib/data/remote/http_order_transport.dart index 2532683..766080d 100644 --- a/lib/data/remote/http_order_transport.dart +++ b/lib/data/remote/http_order_transport.dart @@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport { Uri.parse('${config.httpBaseUrl}/health'), headers: { 'content-type': 'application/json', - if (config.bearerToken != null) - 'authorization': 'Bearer ${config.bearerToken}', + if (config.apiKey != null) + 'authorization': 'Bearer ${config.apiKey}', }, body: payload, ) @@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport { uri, headers: { 'content-type': 'application/json', - if (config.bearerToken != null) - 'authorization': 'Bearer ${config.bearerToken}', + if (config.apiKey != null) + 'authorization': 'Bearer ${config.apiKey}', 'idempotency-key': batchId, }, body: jsonEncode({ diff --git a/lib/data/remote/pos_auth_api.dart b/lib/data/remote/pos_auth_api.dart deleted file mode 100644 index fc40769..0000000 --- a/lib/data/remote/pos_auth_api.dart +++ /dev/null @@ -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 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 = { - '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 decoded; - try { - decoded = jsonDecode(response.body) as Map; - } 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) { - 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(); -} diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index d865c58..a526908 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -178,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository { createdAt: DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int), isSynced: (r['sync_status']! as int) == OrderDao.synced, + itemCount: (r['item_count'] as num?)?.toDouble() ?? 0, syncedAt: r['synced_at'] == null ? null : DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int), diff --git a/lib/domain/entities/pos_session.dart b/lib/domain/entities/pos_session.dart deleted file mode 100644 index 31a7908..0000000 --- a/lib/domain/entities/pos_session.dart +++ /dev/null @@ -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 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 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 json) { - final outlets = (json['locations'] as List? ?? const []) - .whereType>() - .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? ?? const []) - .whereType>() - .map(PosStaffMember.fromJson) - .where((m) => m.pin.isNotEmpty) - .toList(), - ); - } - - Map 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 json) => PosOutlet( - locationId: _int(json['location_id']), - locationName: (json['location_name'] as String?) ?? '', - address: (json['address'] as String?) ?? '', - city: (json['city'] as String?) ?? '', - ); - - Map 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 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 toJson() => { - 'user_id': userId, - 'full_name': fullName, - 'pin': pin, - 'role': role, - }; -} diff --git a/lib/domain/repositories/sync_repository.dart b/lib/domain/repositories/sync_repository.dart index 424b6bc..6c9cb87 100644 --- a/lib/domain/repositories/sync_repository.dart +++ b/lib/domain/repositories/sync_repository.dart @@ -39,6 +39,7 @@ class OrderSyncRow { required this.total, required this.createdAt, required this.isSynced, + this.itemCount = 0, this.syncedAt, this.attempts = 0, this.error, @@ -49,6 +50,9 @@ class OrderSyncRow { final double total; final DateTime createdAt; final bool isSynced; + + /// Units on the bill. Fractional because loose goods are sold by weight. + final double itemCount; final DateTime? syncedAt; final int attempts; final String? error; diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index d2e04da..238eb19 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,9 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; -import '../../../data/local/staff_dao.dart'; -import '../../../data/remote/pos_auth_api.dart'; -import '../../../domain/entities/pos_session.dart'; import '../../../domain/entities/store_account.dart'; /// Sign-in state for the terminal. @@ -22,10 +19,23 @@ class Authenticating extends AuthState { } class Authenticated extends AuthState { - const Authenticated({required this.store, required this.user}); + const Authenticated({ + required this.store, + required this.user, + required this.login, + }); final StoreAccount store; final StaffUser user; + + /// Which credential opened this session. The authority on what the terminal + /// is allowed to show — not [user], which can be swapped at the till. + final TerminalLogin login; + + StaffRole get role => login.role; + + bool get isAdmin => login == TerminalLogin.admin; + bool get isCashier => login == TerminalLogin.cashier; } class AuthFailure extends AuthState { @@ -34,150 +44,110 @@ class AuthFailure extends AuthState { final String message; } -/// Signs the terminal in against the back office and holds the session. +/// The two ways into this terminal. /// -/// This used to compare against two constants compiled into the app — -/// `admin@nearle.in` / `nearle123` — with a 600ms delay standing in for a -/// network call that was never made. Two things were wrong with that, and the -/// second was the serious one: +/// Store-level credentials, not a person's: they are replaced wholesale when +/// the terminal is registered against a real back office. Staff PINs — the +/// credential that actually opens a till drawer — are not here. They live +/// hashed in the database. /// -/// 1. every install of a build shared one password, and changing it meant a -/// rebuild; and -/// 2. because nothing was checked with the back office, the *outlet* could not -/// come from the sign-in. It came from a store id typed into Settings — so -/// a till named its own shop and was believed, and one number changed on -/// one screen moved a terminal into another tenant's books. +/// The split is what the two roles are *for*, not decoration: /// -/// Now a person signs in with their own back-office account, and the outlet -/// arrives as a consequence: sealed in a signed token, checked server-side on -/// every request, and not editable from this device. +/// * [admin] runs the whole shell and is the only login that can pull the +/// catalogue. Signing out leaves the products on the terminal. +/// * [cashier] gets the billing screen and nothing else, and signing out +/// takes the catalogue with it. +enum TerminalLogin { + admin( + label: 'Admin', + email: 'admin@nearle.in', + password: 'nearle123', + role: StaffRole.admin, + blurb: 'Full shell — import products, promos, settings.', + ), + cashier( + label: 'Cashier', + email: 'cashier@nearle.in', + password: 'cashier123', + role: StaffRole.cashier, + blurb: 'Billing only, on the products the admin imported.', + ); + + const TerminalLogin({ + required this.label, + required this.email, + required this.password, + required this.role, + required this.blurb, + }); + + final String label; + final String email; + final String password; + final StaffRole role; + final String blurb; + + /// The catalogue is pulled once by an admin and billed against by whoever is + /// on the counter, so only the cashier's sign-out drops it. An admin closing + /// the shell is a handover, not the end of the day. + bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier; + + static TerminalLogin? byEmail(String email) { + final normalised = email.trim().toLowerCase(); + for (final login in TerminalLogin.values) { + if (login.email == normalised) return login; + } + return null; + } +} + +/// Kept for the store record, which is keyed on the outlet's own address. +class DemoCredentials { + const DemoCredentials._(); + + static const String email = 'admin@nearle.in'; + static const String password = 'nearle123'; + + static const String cashierEmail = 'cashier@nearle.in'; + static const String cashierPassword = 'cashier123'; +} + +/// Validates store credentials and holds the signed-in session. class AuthController extends StateNotifier { AuthController(this._ref) : super(const Unauthenticated()); final Ref _ref; - /// The back office's answer to the last sign-in, if there is one. + /// Whether signing out right now would wipe the products off this terminal. /// - /// Held so the outlet picker can offer a proprietor their other shops without - /// asking for the password a second time. - PosSession? _session; - PosSession? get session => _session; - - /// Restores a session saved on a previous run. - /// - /// Called at start-up so a till that was rebooted mid-shift comes back - /// trading rather than showing a login screen to a queue of customers. - /// Returns false when there is nothing usable, which includes an expired - /// session — [SessionStore] treats those as absent. - Future restore() async { - final saved = await _ref.read(sessionStoreProvider).read(); - if (saved == null) return false; - - await _adopt(saved); - return state is Authenticated; + /// Read *before* [signOut] by anything that needs to warn the operator, since + /// the session is gone by the time it returns. + bool get clearsCatalogueOnSignOut { + final current = state; + return current is Authenticated && current.login.clearsCatalogueOnSignOut; } Future signIn({ required String email, required String password, - int? locationId, }) async { state = const Authenticating(); - final terminal = _ref.read(terminalIdentityProvider); + // Stand-in for the network round trip. + await Future.delayed(const Duration(milliseconds: 600)); - final PosSession session; - try { - session = await _ref.read(posAuthApiProvider).login( - authname: email, - password: password, - terminalId: terminal.code, - deviceId: terminal.deviceId, - locationId: locationId, - ); - } on PosAuthException catch (e) { - state = AuthFailure(e.message); - return false; - } on Object { - state = const AuthFailure( - 'Sign-in failed for an unexpected reason. Please try again.', - ); + final login = TerminalLogin.byEmail(email); + + if (login == null) { + state = const AuthFailure('No account is registered against that email.'); return false; } - await _ref.read(sessionStoreProvider).write(session); - await _adopt(session); + if (password != login.password) { + state = const AuthFailure('Incorrect password. Please try again.'); + return false; + } - return state is Authenticated; - } - - /// Moves this terminal to another of the signed-in account's outlets. - /// - /// A fresh sign-in rather than a local switch, because the outlet is inside - /// the signed token: the back office has to issue a new one, and re-checking - /// entitlement at that moment is the point. Requires the password again, - /// which is correct — moving a till between shops changes whose books it - /// writes to. - Future 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 _adopt(PosSession session) async { - _session = session; - - await _ref.read(localStoreProvider).identityStore.rename( - storeId: session.storeId, - ); - _ref.invalidate(terminalIdentityProvider); - - _ref.read(syncConfigProvider.notifier).state = - _ref.read(syncConfigProvider).copyWith( - storeId: session.storeId, - sessionToken: session.token, - ); - - // Store details for the receipt come from the back office now, not from - // constants compiled into the build. A GSTIN is a legal requirement on a - // tax invoice; it should not need a rebuild to correct. - await _ref.read(storeRepositoryProvider).save( - name: session.locationName.isNotEmpty - ? session.locationName - : session.tenantName, - address: session.address, - gstin: session.gstin, - phone: session.phone, - ); - - // Who may ring a bill here, per the back office. - // - // This is what retires the seeded logins. The till ships with three names - // and three PINs compiled into it — the same three on every install — and - // they exist only so a shop whose back office has no staff recorded can - // still trade on day one. The moment real staff arrive they are - // deactivated, which is the whole point of importing rather than merging. - // - // Empty is the common case rather than an error: most outlets have nobody - // recorded, including the one this build ships pointed at. The import - // no-ops, the seeds survive, and the shop keeps selling. - await _importStaff(session); - - _ref.invalidate(storeAccountProvider); final store = await _ref.read(storeAccountProvider.future); final staff = store.staff; @@ -185,61 +155,29 @@ class AuthController extends StateNotifier { state = const AuthFailure( 'This terminal has no staff accounts. Reinstall to seed them.', ); - return; + return false; } - // The first admin, or whoever is there. A person switches to their own - // account at the till. + // Whoever on this terminal matches the role that just signed in. Falls + // back rather than failing: the session's permissions come from [login], + // so a shop with no cashier row still gets a usable till — the bills are + // just stamped with the account that is there. final opener = staff.firstWhere( - (s) => s.role == StaffRole.admin, + (s) => s.role == login.role, orElse: () => staff.first, ); - state = Authenticated(store: store, user: opener); + state = Authenticated(store: store, user: opener, login: login); + return true; } - /// Writes the back office's staff over this terminal's. - /// - /// Failures are swallowed. A shop must be able to open its till even when the - /// staff import fails — the seeded or previously-synced accounts are still - /// there, and refusing the sign-in would trade a working counter for a - /// tidier database. - Future _importStaff(PosSession session) async { - if (session.staff.isEmpty) return; - - try { - await _ref.read(localStoreProvider).staff.replaceFromBackOffice([ - for (final member in session.staff) - StaffImportRecord( - localId: member.localId, - name: member.fullName, - role: _roleFor(member.role), - pin: member.pin, - ), - ]); - } on Object { - // Deliberately silent — see above. - } - } - - /// Maps the back office's role names onto the till's three. - /// - /// `app_roles` holds six rows for four distinct roles — Admin and Manager are - /// each in there twice — and most accounts carry a `roleid` that is not in - /// the table at all. So this matches on the name and falls back to the least - /// privileged answer: an unrecognised role must not silently become an admin. - StaffRole _roleFor(String backOfficeRole) => - switch (backOfficeRole.trim().toLowerCase()) { - 'super admin' || 'admin' => StaffRole.admin, - 'manager' || 'operations' => StaffRole.manager, - _ => StaffRole.cashier, - }; - /// Switches the active operator, checking their PIN. /// /// Every bill is stamped with whoever is active, so this is the boundary that /// decides who a sale is attributed to — it cannot be a bare selection from a - /// list. + /// list. It changes who the bill names, never what the session may open: + /// [Authenticated.login] is untouched, so a cashier terminal stays a cashier + /// terminal. Future switchUser(String pin) async { final current = state; if (current is! Authenticated) return false; @@ -248,7 +186,11 @@ class AuthController extends StateNotifier { final user = await store.staff.authenticate(pin); if (user == null) return false; - state = Authenticated(store: current.store, user: user); + state = Authenticated( + store: current.store, + user: user, + login: current.login, + ); return true; } @@ -266,25 +208,25 @@ class AuthController extends StateNotifier { // Signed out if the active operator was just deactivated — carrying on // would keep stamping bills with an account the shop has revoked. user: me.isEmpty ? store.staff.first : me.first, + login: current.login, ); } - /// The next shift should only ever bill against what the back office - /// answers with, never a catalogue instance carried over from this - /// session — so the local product table is dropped before the session - /// itself is. + /// Ends the session, and — for a cashier only — the catalogue with it. /// - /// The token goes with it, from the keystore and from the live configuration - /// both. Leaving it in place would let a signed-out terminal keep uploading - /// as the shop that signed in this morning. + /// Every way out of a cashier session clears the products: ending a shift, + /// and a temporary logout alike. There is no exception for stepping away for + /// ten minutes, because the terminal is left unattended either way and the + /// next session should bill against what the back office answers with rather + /// than a catalogue carried over. + /// + /// An admin signing out is the opposite case. They have just pulled the + /// products *so that* a cashier can pick the terminal up, so dropping the + /// table here would make the import pointless. Future signOut() async { - await _ref.read(localStoreProvider).clearCatalogue(); - await _ref.read(sessionStoreProvider).clear(); - - _ref.read(syncConfigProvider.notifier).state = - _ref.read(syncConfigProvider).copyWith(sessionToken: ''); - - _session = null; + if (clearsCatalogueOnSignOut) { + await _ref.read(localStoreProvider).clearCatalogue(); + } state = const Unauthenticated(); } @@ -309,6 +251,24 @@ final currentUserProvider = Provider((ref) { return s is Authenticated ? s.user : null; }); +/// Which credential is holding this session open, or null before sign-in. +final terminalLoginProvider = Provider((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( + (ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier, +); + +final isAdminModeProvider = Provider( + (ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin, +); + /// True while anyone is still on a seeded or admin-reset PIN. final mustChangePinProvider = Provider((ref) { final user = ref.watch(currentUserProvider); diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index 17b89e2..d069540 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.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_dimens.dart'; import '../../../core/utils/validators.dart'; +import '../../../core/widgets/brand_mark.dart'; import '../../../core/widgets/primary_button.dart'; -import '../../../domain/entities/pos_session.dart'; import '../providers/auth_controller.dart'; /// 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 { const LoginScreen({super.key}); @@ -22,12 +31,28 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - 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 _rememberTerminal = true; + void _selectLogin(TerminalLogin login) { + setState(() { + _login = login; + _email.text = login.email; + _password.text = login.password; + }); + ref.read(authControllerProvider.notifier).clearError(); + } + @override void dispose() { _email.dispose(); @@ -39,217 +64,93 @@ class _LoginScreenState extends ConsumerState { FocusScope.of(context).unfocus(); if (!(_formKey.currentState?.validate() ?? false)) return; - final auth = ref.read(authControllerProvider.notifier); - - var ok = await auth.signIn( + final ok = await ref.read(authControllerProvider.notifier).signIn( email: _email.text, password: _password.text, ); - if (!ok || !mounted) return; - // A proprietor with several shops signs in once and then says which counter - // 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( - 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); + if (ok && mounted) context.go(AppRoutes.pos); } @override Widget build(BuildContext context) { + final auth = ref.watch(authControllerProvider); + final session = ref.watch(cashierSessionProvider); + final busy = auth is Authenticating; + return Scaffold( - backgroundColor: AppColors.background, - body: LayoutBuilder( - builder: (context, constraints) { - // Below this there isn't room for the brand panel beside the form. - final showBrandPanel = constraints.maxWidth >= 1000; + body: Stack( + fit: StackFit.expand, + children: [ + // Background image + Image.asset( + 'assets/images/bg.webp', + fit: BoxFit.cover, + ), - return Row( - children: [ - if (showBrandPanel) - const Expanded(flex: 5, child: _BrandPanel()), - Expanded( - flex: 4, - child: _FormPanel( - formKey: _formKey, - email: _email, - password: _password, - obscure: _obscure, - rememberTerminal: _rememberTerminal, - showCompactLogo: !showBrandPanel, - onToggleObscure: () => setState(() => _obscure = !_obscure), - onToggleRemember: (v) => - setState(() => _rememberTerminal = v ?? true), - onSubmit: _submit, - ), - ), - ], - ); - }, - ), - ); - } -} + // Light blur over the image -class _BrandPanel extends StatelessWidget { - const _BrandPanel(); - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration(gradient: AppColors.primaryGradient), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.giant), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - 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), + 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), ), - const SizedBox(width: AppSpacing.md), - const Flexible( - child: Text( - 'Nearle POS', - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w700, - letterSpacing: -0.4, + 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, + email: _email, + password: _password, + obscure: _obscure, + rememberTerminal: _rememberTerminal, + login: _login, + busy: busy, + failure: auth is AuthFailure ? auth.message : null, + onSelectLogin: _selectLogin, + onToggleObscure: () => + setState(() => _obscure = !_obscure), + onToggleRemember: (v) => + setState(() => _rememberTerminal = v ?? true), + onSubmit: _submit, + ), + const SizedBox(height: AppSpacing.lg), + Center( + child: Text( + 'Terminal ${session.terminalId}', + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + ), + ), + ), + ], ), ), ), - ], - ), - const SizedBox(height: AppSpacing.giant), - const Text( - 'Billing that keeps up\nwith your counter.', - style: TextStyle( - color: Colors.white, - fontSize: 34, - 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, - ), - ), - const SizedBox(height: 2), - Text( - body, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.72), - fontSize: 13, - height: 1.5, - ), - ), - ], + ); + }, ), ), ], @@ -258,14 +159,54 @@ class _Feature extends StatelessWidget { } } -class _FormPanel extends ConsumerWidget { - const _FormPanel({ +/// Mark and product name, directly above the card. +class _Masthead extends StatelessWidget { + const _Masthead({required this.tight}); + + final bool tight; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // BrandMark(size: tight ? 48 : 60), + const SizedBox(height: AppSpacing.md), + const Text( + 'Nearle POS', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: Colors.white, + height: 1.2, + ), + ), + if (!tight) ...[ + const SizedBox(height: 2), + const Text( + 'Scanner-first billing for Indian retail', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ], + ).animate().fadeIn(duration: 260.ms); + } +} + +class _Card extends StatelessWidget { + const _Card({ required this.formKey, required this.email, required this.password, required this.obscure, required this.rememberTerminal, - required this.showCompactLogo, + required this.login, + required this.busy, + required this.failure, + required this.onSelectLogin, required this.onToggleObscure, required this.onToggleRemember, required this.onSubmit, @@ -276,211 +217,202 @@ class _FormPanel extends ConsumerWidget { final TextEditingController password; final bool obscure; final bool rememberTerminal; - final bool showCompactLogo; + final TerminalLogin login; + final bool busy; + final String? failure; + final ValueChanged onSelectLogin; final VoidCallback onToggleObscure; final ValueChanged onToggleRemember; final VoidCallback onSubmit; @override - Widget build(BuildContext context, WidgetRef ref) { - final auth = ref.watch(authControllerProvider); - final session = ref.watch(cashierSessionProvider); - final busy = auth is Authenticating; - - return SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - 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( - 'Use the credentials issued when your outlet was ' - 'registered.', - style: TextStyle( - fontSize: 13.5, - color: AppColors.textSecondary, - height: 1.5, - ), - ), - const SizedBox(height: AppSpacing.xxxl), - - const _Label('Store email'), - TextFormField( - controller: email, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - enabled: !busy, - validator: (v) => (v ?? '').trim().isEmpty - ? 'Store email is required' - : Validators.emailOptional(v), - decoration: const InputDecoration( - hintText: 'store@example.in', - prefixIcon: Icon(Icons.storefront_outlined), - ), - ), - const SizedBox(height: AppSpacing.lg), - - const _Label('Password'), - TextFormField( - controller: password, - obscureText: obscure, - enabled: !busy, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => onSubmit(), - validator: (v) => (v ?? '').isEmpty - ? 'Password is required' - : ((v ?? '').length < 6 - ? 'Password looks too short' - : null), - decoration: InputDecoration( - hintText: 'Enter your password', - prefixIcon: const Icon(Icons.lock_outline_rounded), - suffixIcon: IconButton( - onPressed: onToggleObscure, - icon: Icon( - obscure - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - size: 20, - ), - tooltip: obscure ? 'Show password' : 'Hide password', - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - - // Wrap, not Row — on a narrow tablet these would collide. - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - InkWell( - onTap: busy - ? null - : () => onToggleRemember(!rememberTerminal), - borderRadius: AppRadius.brXs, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 22, - height: 22, - child: Checkbox( - value: rememberTerminal, - onChanged: busy ? null : onToggleRemember, - visualDensity: VisualDensity.compact, - ), - ), - const SizedBox(width: AppSpacing.sm), - const Text( - 'Remember this terminal', - style: TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ), - TextButton( - onPressed: busy ? null : () {}, - child: const Text('Forgot password?'), - ), - ], - ), - - if (auth is AuthFailure) ...[ - const SizedBox(height: AppSpacing.sm), - Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: const BoxDecoration( - color: AppColors.dangerSurface, - borderRadius: AppRadius.brSm, - ), - child: Row( - children: [ - const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18,), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - auth.message, - style: const TextStyle( - color: AppColors.danger, - fontSize: 13, - ), - ), - ), - ], - ), - ).animate().shake(duration: 320.ms, hz: 3), - ], - - const SizedBox(height: AppSpacing.xl), - PrimaryButton( - label: 'Sign in', - icon: Icons.login_rounded, - large: true, - busy: busy, - onPressed: onSubmit, - ), - - - const SizedBox(height: AppSpacing.xxl), - Center( - child: Text( - 'Terminal ${session.terminalId}', - style: const TextStyle( - fontSize: 11.5, - color: AppColors.textTertiary, - ), - ), - ), - ], + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.xxl), + decoration: BoxDecoration( + 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( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Sign in to your store', + style: TextStyle( + 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, + height: 1.5, + ), + ), + 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'), + TextFormField( + controller: email, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + enabled: !busy, + validator: (v) => (v ?? '').trim().isEmpty + ? 'Store email is required' + : Validators.emailOptional(v), + decoration: const InputDecoration( + hintText: 'store@example.in', + prefixIcon: Icon(Icons.storefront_outlined), + ), + ), + const SizedBox(height: AppSpacing.lg), + + const _Label('Password'), + TextFormField( + controller: password, + obscureText: obscure, + enabled: !busy, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onSubmit(), + validator: (v) => (v ?? '').isEmpty + ? 'Password is required' + : ((v ?? '').length < 6 ? 'Password looks too short' : null), + decoration: InputDecoration( + hintText: 'Enter your password', + prefixIcon: const Icon(Icons.lock_outline_rounded), + suffixIcon: IconButton( + onPressed: onToggleObscure, + icon: Icon( + obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + ), + tooltip: obscure ? 'Show password' : 'Hide password', + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + + // Wrap, not Row — on a narrow tablet these would collide. + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + InkWell( + onTap: busy ? null : () => onToggleRemember(!rememberTerminal), + borderRadius: AppRadius.brXs, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: rememberTerminal, + onChanged: busy ? null : onToggleRemember, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: AppSpacing.sm), + const Text( + 'Remember this terminal', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + TextButton( + onPressed: busy ? null : () {}, + child: const Text('Forgot password?'), + ), + ], + ), + + if (failure != null) ...[ + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: const BoxDecoration( + color: AppColors.dangerSurface, + borderRadius: AppRadius.brSm, + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, + color: AppColors.danger, size: 18,), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + failure!, + style: const TextStyle( + color: AppColors.danger, + fontSize: 13, + ), + ), + ), + ], + ), + ).animate().shake(duration: 320.ms, hz: 3), + ], + + const SizedBox(height: AppSpacing.lg), + PrimaryButton( + label: 'Sign in', + icon: Icons.login_rounded, + large: true, + busy: busy, + onPressed: onSubmit, + ), + + const SizedBox(height: AppSpacing.lg), + _DemoHint( + login: login, + onFill: busy ? null : () => onSelectLogin(login), + ), + ], ), ), - ); + ).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 -/// location never sees it, which is the common case — this is for a proprietor -/// whose account reaches several shops. -/// -/// The list comes from the back office and cannot be typed into. That is the -/// whole difference from what this replaced: the outlet used to be a store id -/// entered in Settings, so a till asserted which shop it belonged to. Here it -/// picks from what the account is already entitled to, and the choice is -/// re-checked server-side when the new session is issued. -class _OutletPicker extends StatelessWidget { - const _OutletPicker({required this.session}); +/// The roles are not cosmetic — they decide whether the terminal opens the +/// full shell or the billing screen alone, and whether signing out leaves the +/// products behind — so the choice is made before the credentials rather than +/// inferred from them afterwards. +class _RoleSwitch extends StatelessWidget { + const _RoleSwitch({ + required this.selected, + required this.enabled, + required this.onSelect, + }); - final PosSession session; + final TerminalLogin selected; + final bool enabled; + final ValueChanged onSelect; @override Widget build(BuildContext context) { - return AlertDialog( - backgroundColor: AppColors.surface, - shape: RoundedRectangleBorder(borderRadius: AppRadius.brMd), - title: const Text( - 'Which outlet is this terminal at?', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: AppRadius.brSm, + border: Border.all(color: AppColors.border), ), - 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, + child: Row( + children: [ + for (final login in TerminalLogin.values) + Expanded( + child: _RoleTab( + login: login, + selected: login == selected, + enabled: enabled, + onTap: () => onSelect(login), + ), + ), + ], + ), + ); + } +} + +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, + ), ), ), - ), - 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, - side: BorderSide( - color: isCurrent - ? AppColors.primaryBorder - : AppColors.border, - ), - ), - 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 _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)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart index 4e120bc..dc15051 100644 --- a/lib/presentation/customer/widgets/customer_capture_sheet.dart +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -6,18 +6,22 @@ import '../../../app/providers.dart'; import '../../../core/constants/app_constants.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/utils/formatters.dart'; +import '../../../core/theme/app_typography.dart'; import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/primary_button.dart'; -import '../../../core/widgets/status_pill.dart'; import '../../../domain/entities/customer.dart'; import '../../pos/providers/cart_controller.dart'; -import '../providers/customer_providers.dart'; -/// Attaches a customer to the current bill using nothing but a mobile number. +/// Attaches a customer to the current bill: a mobile number, a name, or +/// neither. /// -/// Registration is deliberately minimal: an unknown number can be saved with -/// just a name, or the whole step skipped. Nothing here blocks the sale. +/// Deliberately not a lookup screen. There is no search against the customers +/// already on the terminal, no recent list, and no membership tier — a cashier +/// at a queue keys the number, keys the name if the shopper gives one, and +/// carries on. A number that turns out to be registered already is reused +/// silently rather than being turned into a decision the counter has to make. +/// +/// Nothing here blocks the sale: Skip closes it with a walk-in bill. Future showCustomerCaptureSheet(BuildContext context) { return showModalBottomSheet( context: context, @@ -35,13 +39,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget { _CustomerCaptureSheetState(); } -class _CustomerCaptureSheetState - extends ConsumerState<_CustomerCaptureSheet> { +class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { String _digits = ''; final _name = TextEditingController(); bool _saving = false; String? _error; + bool get _complete => _digits.length == AppConstants.mobileNumberLength; + @override void dispose() { _name.dispose(); @@ -54,15 +59,14 @@ class _CustomerCaptureSheetState _digits += d; _error = null; }); - if (_digits.length == AppConstants.mobileNumberLength) { - ref.read(customerLookupProvider.notifier).search(_digits); - } } void _backspace() { if (_digits.isEmpty) return; - setState(() => _digits = _digits.substring(0, _digits.length - 1)); - ref.read(customerLookupProvider.notifier).reset(); + setState(() { + _digits = _digits.substring(0, _digits.length - 1); + _error = null; + }); } void _clear() { @@ -70,7 +74,6 @@ class _CustomerCaptureSheetState _digits = ''; _error = null; }); - ref.read(customerLookupProvider.notifier).reset(); } void _attachAndClose(Customer? customer) { @@ -78,9 +81,23 @@ class _CustomerCaptureSheetState Navigator.of(context).pop(); } - /// Saves with whatever was given. Both fields are optional: a bare number - /// is still worth keeping, because it is what the WhatsApp bill is sent to. - Future _quickRegister() async { + /// Saves the number under the name given, then puts it on the bill. + /// + /// The name is optional — a bare number is still worth keeping, because it + /// is what the WhatsApp bill is sent to. + /// + /// A number already on file is the ordinary case, not an error: the existing + /// record is picked up and attached, and a freshly typed name is written + /// over the stored one so a correction at the counter sticks. The cashier + /// sees the same thing either way, which is the point of not having a lookup + /// step. + Future _save() async { + if (!_complete) { + setState(() => _error = 'Enter all ' + '${AppConstants.mobileNumberLength} digits of the mobile number.'); + return; + } + final typed = _name.text.trim(); final name = typed.isEmpty ? 'Customer ${_digits.substring(_digits.length - 4)}' @@ -91,24 +108,43 @@ class _CustomerCaptureSheetState _error = null; }); + final repo = ref.read(customerRepositoryProvider); + try { - final created = await ref.read(customerRepositoryProvider).create( - Customer(id: '', name: name, mobile: _digits), - ); - ref.invalidate(recentCustomersProvider); + final created = await repo.create( + Customer(id: '', name: name, mobile: _digits), + ); if (mounted) _attachAndClose(created); - } catch (e) { + } on StateError { + // Already registered. Reuse the row rather than making the counter + // reconcile it. + try { + final existing = await repo.findByMobile(_digits); + if (existing == null) throw StateError('lookup failed'); + + final updated = typed.isEmpty || typed == existing.name + ? existing + : await repo.update(existing.copyWith(name: typed)); + + if (mounted) _attachAndClose(updated); + } catch (_) { + if (!mounted) return; + setState(() { + _saving = false; + _error = 'Could not save customer. Try again.'; + }); + } + } catch (_) { if (!mounted) return; setState(() { _saving = false; - _error = e is StateError ? e.message : 'Could not save customer.'; + _error = 'Could not save customer. Try again.'; }); } } @override Widget build(BuildContext context) { - final lookup = ref.watch(customerLookupProvider); final attached = ref.watch( cartControllerProvider.select((c) => c.customer), ); @@ -130,36 +166,57 @@ class _CustomerCaptureSheetState mainAxisSize: MainAxisSize.min, children: [ _grabber(), - _header(attached), - const Divider(height: 1), + // Everything inside is capped and centred. A modal sheet on a + // 27-inch till used to run the full width of the screen, which + // put the keypad and the fields at opposite ends of the desk. Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: LayoutBuilder( - builder: (context, constraints) { - // Side by side once there is room for both columns. - final wide = constraints.maxWidth >= 720; - final entry = _entryColumn(); - final result = _resultColumn(lookup); + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header(attached), + const Divider(height: 1), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xxl, + AppSpacing.xl, + AppSpacing.xxl, + AppSpacing.xxl, + ), + child: LayoutBuilder( + builder: (context, box) { + // Side by side once there is room for both. + final wide = box.maxWidth >= 660; + final entry = _entryColumn(); + final details = _detailsColumn(); - if (!wide) { - return Column( - children: [ - entry, - const SizedBox(height: AppSpacing.xl), - result, - ], - ); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: entry), - const SizedBox(width: AppSpacing.xxl), - Expanded(child: result), - ], - ); - }, + if (!wide) { + return Column( + children: [ + entry, + const SizedBox(height: AppSpacing.xl), + details, + ], + ); + } + return Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox(width: 300, child: entry), + const SizedBox(width: AppSpacing.xxl), + Expanded(child: details), + ], + ); + }, + ), + ), + ), + ], + ), ), ), ), @@ -184,11 +241,23 @@ class _CustomerCaptureSheetState padding: const EdgeInsets.fromLTRB( AppSpacing.xxl, 0, - AppSpacing.md, + AppSpacing.lg, AppSpacing.lg, ), child: Row( children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadius.brSm, + border: Border.all(color: AppColors.primaryBorder), + ), + child: const Icon(Icons.person_add_alt_1_outlined, + size: 20, color: AppColors.primary,), + ), + const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -197,10 +266,16 @@ class _CustomerCaptureSheetState Text( attached == null ? 'Add customer' : 'Change customer', overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + color: AppColors.textPrimary, + height: 1.2, + ), ), const Text( - 'Optional — for loyalty points and tier discounts', + 'Optional — number and name, or skip', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, @@ -212,30 +287,45 @@ class _CustomerCaptureSheetState ), const SizedBox(width: AppSpacing.sm), TextButton( - onPressed: () => _attachAndClose(null), + onPressed: _saving ? null : () => _attachAndClose(null), style: TextButton.styleFrom( foregroundColor: AppColors.textSecondary, + minimumSize: const Size(0, 38), ), child: const Text('Skip'), ), IconButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: _saving ? null : () => Navigator.of(context).pop(), icon: const Icon(Icons.close_rounded), + color: AppColors.textTertiary, tooltip: 'Close', ), ], ), ); + // ----------------------------------------------------------------- Entry Widget _entryColumn() => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _display(), - const SizedBox(height: AppSpacing.xl), + const SizedBox(height: AppSpacing.sm), + Text( + _complete + ? 'Number complete' + : '${AppConstants.mobileNumberLength - _digits.length} more ' + 'digit(s)', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + ), + ), + const SizedBox(height: AppSpacing.lg), Center( child: NumericKeypad( - maxWidth: 340, + maxWidth: 300, onKey: _append, onBackspace: _backspace, onClear: _clear, @@ -244,237 +334,96 @@ class _CustomerCaptureSheetState ], ); - Widget _display() => Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadius.brLg, - border: Border.all(color: AppColors.primaryBorder), + /// The number as it is keyed, grouped 5 + 5 the way it is read aloud. + Widget _display() { + final filled = _digits.isNotEmpty; + final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5); + final tail = _digits.length <= 5 ? '' : _digits.substring(5); + + return Container( + height: 64, + padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs), + decoration: BoxDecoration( + color: filled ? AppColors.surface : AppColors.surfaceAlt, + borderRadius: AppRadius.brLg, + border: Border.all( + color: filled ? AppColors.primary : AppColors.border, + width: filled ? 1.4 : 1, ), - child: Row( - children: [ - const Text( + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 3, + ), + decoration: const BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: AppRadius.brXs, + ), + child: const Text( '+91', style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - ), - ), - const SizedBox(width: AppSpacing.md), - // FittedBox guarantees ten digits fit at any sheet width. - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - _digits.isEmpty - ? '– – – – – – – – – –' - : _digits.split('').join(' '), - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - letterSpacing: 1, - color: _digits.isEmpty - ? AppColors.textTertiary - : AppColors.textPrimary, - ), - ), - ), - ), - if (_digits.isNotEmpty) - IconButton( - onPressed: _clear, - icon: const Icon(Icons.close_rounded, size: 20), - color: AppColors.textTertiary, - tooltip: 'Clear', - ), - ], - ), - ); - - Widget _resultColumn(CustomerLookupState state) => switch (state) { - LookupIdle() => _idle(), - LookupSearching() => const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.giant), - child: Center( - child: CircularProgressIndicator(color: AppColors.primary), - ), - ), - LookupFound(:final customer) => _found(customer), - LookupNotFound() => _notFound(), - LookupError(:final message) => _message( - Icons.error_outline_rounded, - AppColors.danger, - message, - ), - }; - - Widget _idle() { - final recent = ref.watch(recentCustomersProvider).value ?? const []; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _message( - Icons.dialpad_rounded, - AppColors.textTertiary, - 'Key in a 10-digit mobile number — the lookup runs automatically. ' - 'Both fields are optional.', - ), - const SizedBox(height: AppSpacing.lg), - _nameField(), - if (recent.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.xl), - const Align( - alignment: Alignment.centerLeft, - child: Text( - 'Recent', - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, + fontSize: 13, + fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), ), ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - for (final c in recent.take(4)) - ActionChip( - avatar: CircleAvatar( - radius: 11, - backgroundColor: AppColors.primarySurface, - child: Text( - Formatters.initials(c.name), - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - color: AppColors.primary, + const SizedBox(width: AppSpacing.md), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: filled + ? Text( + tail.isEmpty ? head : '$head $tail', + style: AppTypography.money(23).copyWith( + letterSpacing: 1.5, + color: AppColors.textPrimary, + ), + ) + : Text( + 'Mobile number', + style: TextStyle( + fontSize: 16, + color: AppColors.textTertiary.withValues(alpha: 0.9), ), ), - ), - label: Text( - c.name, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12.5), - ), - onPressed: () => _attachAndClose(c), - ), - ], + ), ), + if (filled) + IconButton( + onPressed: _saving ? null : _clear, + icon: const Icon(Icons.backspace_outlined, size: 18), + color: AppColors.textTertiary, + tooltip: 'Clear', + ), ], - ], + ), ); } - Widget _found(Customer c) => Column( + // --------------------------------------------------------------- Details + Widget _detailsColumn() => Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: const BoxDecoration( - color: AppColors.successSurface, - borderRadius: AppRadius.brLg, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - CircleAvatar( - radius: 22, - backgroundColor: AppColors.surface, - child: Text( - Formatters.initials(c.name), - style: const TextStyle( - color: AppColors.primary, - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - c.name, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - Text( - Formatters.mobile(c.mobile), - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12.5, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(width: AppSpacing.sm), - StatusPill.tier(c.tier, dense: true), - ], - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: _miniStat( - '${c.loyaltyPoints}', 'points held',), - ), - Expanded( - child: _miniStat( - Formatters.money(c.redeemableValue), - 'redeemable', - ), - ), - if (c.tier.discountRate > 0) - Expanded( - child: _miniStat( - Formatters.percent(c.tier.discountRate), - 'auto discount', - ), - ), - ], - ), - ], + _hint(), + const SizedBox(height: AppSpacing.lg), + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + enabled: !_saving, + onSubmitted: (_) => _save(), + inputFormatters: [LengthLimitingTextInputFormatter(60)], + decoration: const InputDecoration( + labelText: 'Customer name', + hintText: 'Optional', + prefixIcon: Icon(Icons.person_outline_rounded), ), ), - const SizedBox(height: AppSpacing.lg), - PrimaryButton( - label: 'Use this customer', - icon: Icons.check_rounded, - large: true, - onPressed: () => _attachAndClose(c), - ), - ], - ); - - Widget _notFound() => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _message( - Icons.person_search_rounded, - AppColors.warning, - 'New number. Add a name if you have it — the bill can be sent to ' - 'this number on WhatsApp either way.', - ), - const SizedBox(height: AppSpacing.lg), - _nameField(), if (_error != null) ...[ const SizedBox(height: AppSpacing.sm), Text( @@ -488,73 +437,37 @@ class _CustomerCaptureSheetState icon: Icons.person_add_alt_1_rounded, large: true, busy: _saving, - onPressed: _quickRegister, + onPressed: _complete && !_saving ? _save : null, ), const SizedBox(height: AppSpacing.sm), PrimaryButton( - label: 'Continue without customer', + label: 'Skip — no customer', tone: ButtonTone.neutral, onPressed: _saving ? null : () => _attachAndClose(null), ), ], ); - Widget _nameField() => TextField( - controller: _name, - textCapitalization: TextCapitalization.words, - enabled: !_saving, - onSubmitted: (_) => _quickRegister(), - inputFormatters: [LengthLimitingTextInputFormatter(60)], - decoration: const InputDecoration( - labelText: 'Customer name', - hintText: 'Optional', - prefixIcon: Icon(Icons.person_outline_rounded), - ), - ); - - Widget _miniStat(String value, String label) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - value, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.success, - ), - ), - ), - Text( - label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - color: AppColors.textSecondary, - ), - ), - ], - ); - - Widget _message(IconData icon, Color color, String text) => Container( + Widget _hint() => Container( + width: double.infinity, padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( color: AppColors.surfaceAlt, - borderRadius: AppRadius.brMd, + borderRadius: AppRadius.brLg, border: Border.all(color: AppColors.border), ), - child: Row( + child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 19, color: color), - const SizedBox(width: AppSpacing.md), + Icon(Icons.dialpad_rounded, + size: 19, color: AppColors.textTertiary,), + SizedBox(width: AppSpacing.md), Expanded( child: Text( - text, - style: const TextStyle( + 'Key in the mobile number, add a name if the shopper gives ' + 'one, then save. The name is optional and the whole step can ' + 'be skipped — the sale is never held up by it.', + style: TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.5, diff --git a/lib/presentation/modules/screens/events_view.dart b/lib/presentation/modules/screens/events_view.dart index 9bca8b5..509f301 100644 --- a/lib/presentation/modules/screens/events_view.dart +++ b/lib/presentation/modules/screens/events_view.dart @@ -5,18 +5,16 @@ import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; -import '../../../core/widgets/primary_button.dart'; import '../../../data/sync/sync_engine.dart'; -import '../../../domain/entities/transaction.dart'; -import '../../../domain/repositories/sync_repository.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; -/// End-of-day sync. +/// What this terminal has traded, and what has reached the server. /// -/// Shows what the terminal produced today and uploads every bill still at -/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0 -/// and is retried on the next tap. +/// Read-only. Uploading is the sync engine's job — it pushes after every sale +/// and retries on its own — so this page reports rather than drives. The +/// warning banner at the top is the exception: a bill the engine has given up +/// on is the one thing here that needs a person to notice it. class EventsView extends ConsumerWidget { const EventsView({super.key}); @@ -25,8 +23,6 @@ class EventsView extends ConsumerWidget { final report = ref.watch(todayReportProvider); final pending = ref.watch(unsyncedCountProvider).value ?? 0; final rows = ref.watch(orderSyncRowsProvider).value ?? const []; - final syncState = ref.watch(orderSyncProvider); - final events = ref.watch(syncEventsProvider); // The engine may not have emitted yet on a cold start, so fall back to // its current value rather than showing nothing. This is the same state @@ -83,118 +79,30 @@ class EventsView extends ConsumerWidget { ), const SizedBox(height: AppSpacing.lg), - PanelCard( - title: 'Upload bills to server', - subtitle: r == null - ? 'Reading today\u2019s trading from SQLite\u2026' - : '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} ' - '\u00b7 ${r.terminalId}', - action: TagChip( - pending > 0 ? '$pending pending' : 'All synced', - color: pending > 0 ? AppColors.warning : AppColors.success, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (r != null && !r.isEmpty) ...[ - _row('Bills', '${r.billCount}'), - _row('Items sold', r.itemCount.toStringAsFixed(0)), - _row('Gross sales', Formatters.money(r.grossSales)), - _row('GST collected', Formatters.money(r.taxCollected)), - _row('Discount given', Formatters.money(r.discountGiven)), - _row('Average basket', Formatters.money(r.averageBasket)), - if (r.paymentBreakdown.isNotEmpty) ...[ - const Divider(height: AppSpacing.xxl), - for (final e in r.paymentBreakdown.entries) - ProgressRow( - label: '${e.key.emoji} ${e.key.label}', - value: Formatters.money(e.value), - fraction: - r.grossSales <= 0 ? 0 : e.value / r.grossSales, - color: _methodColor(e.key), - ), - ], - const SizedBox(height: AppSpacing.lg), - ], - - if (syncState is SyncRunning) ...[ - Text( - syncState.stage, - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: AppSpacing.sm), - ClipRRect( - borderRadius: AppRadius.brPill, - child: LinearProgressIndicator( - value: syncState.progress, - minHeight: 8, - backgroundColor: AppColors.divider, - valueColor: - const AlwaysStoppedAnimation(AppColors.primary), - ), - ), - const SizedBox(height: AppSpacing.lg), - ], - - if (syncState is SyncFinished) - _outcomeBanner(syncState.outcome), - - PrimaryButton( - label: pending > 0 - ? 'Sync $pending bill${pending == 1 ? '' : 's'}' - : 'Nothing to sync', - icon: Icons.cloud_upload_rounded, - large: true, - busy: syncState is SyncRunning, - onPressed: pending == 0 - ? null - : () => ref.read(orderSyncProvider.notifier).run(), - ), - const SizedBox(height: AppSpacing.md), - const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.shield_outlined, - size: 15, color: AppColors.textTertiary,), - SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - 'Bills are written to SQLite the moment a sale ' - 'completes. A failed upload changes nothing on disk — ' - 'every bill stays until the server confirms it.', - style: TextStyle( - fontSize: 12, - color: AppColors.textTertiary, - height: 1.5, - ), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - PanelCard( title: 'Orders', subtitle: '${rows.length} stored \u00b7 $pending awaiting upload', child: ResponsiveTable( columns: const [ TableCol('Invoice', flex: 3), - TableCol('Time', flex: 2, priority: 1), + TableCol('Date & time', flex: 3, priority: 1), + TableCol('Items', flex: 1, numeric: true, priority: 2), TableCol('Total', flex: 2, numeric: true), TableCol('Sync', flex: 2, numeric: true), ], rows: rows .map((o) => [ Cell(o.invoiceNumber, bold: true, mono: true), - Cell(Formatters.time(o.createdAt), - color: AppColors.textTertiary,), + // Date sits with the time because this table outlives the + // day it was rung on — bills stay on the terminal until + // they are purged, so a bare clock time is ambiguous the + // moment the shop opens again. + Cell( + '${Formatters.date(o.createdAt)} \u00b7 ' + '${Formatters.time(o.createdAt)}', + color: AppColors.textTertiary, + ), + Cell(_units(o.itemCount), mono: true), Cell(Formatters.money(o.total), mono: true, bold: true), TagChip( o.isSynced ? 'Synced' : 'Pending', @@ -205,127 +113,17 @@ class EventsView extends ConsumerWidget { .toList(), ), ), - - if (events.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - PanelCard( - title: 'Sync history', - subtitle: 'This session', - child: ResponsiveTable( - columns: const [ - TableCol('Event', flex: 3), - TableCol('Detail', flex: 5, priority: 1), - TableCol('Time', flex: 2, numeric: true), - ], - rows: events - .map((e) => [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - e.type.isInbound - ? Icons.cloud_download_rounded - : Icons.cloud_upload_rounded, - size: 15, - color: AppColors.textSecondary, - ), - const SizedBox(width: AppSpacing.sm), - Flexible(child: Cell(e.type.label, bold: true)), - ], - ), - Cell( - e.error ?? e.summary, - color: e.error != null - ? AppColors.danger - : AppColors.textSecondary, - ), - Cell(Formatters.time(e.createdAt), - color: AppColors.textTertiary,), - ],) - .toList(), - ), - ), - ], ], ); } - Widget _outcomeBanner(SyncOutcome outcome) { - final ok = outcome.isSuccess; - final uploaded = outcome.uploaded; - final attempted = outcome.attempted; - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.lg), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: ok ? AppColors.successSurface : AppColors.dangerSurface, - borderRadius: AppRadius.brSm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded, - size: 18, - color: ok ? AppColors.success : AppColors.danger, - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - ok - ? '$uploaded of $attempted bills uploaded and marked synced.' - : '${outcome.error}', - style: TextStyle( - fontSize: 13, - height: 1.45, - color: ok ? AppColors.success : AppColors.danger, - ), - ), - ), - ], - ), - ); - } - - static Color _methodColor(PaymentMethod m) => switch (m) { - PaymentMethod.cash => AppColors.success, - PaymentMethod.card => AppColors.info, - PaymentMethod.upi => AppColors.primary, - PaymentMethod.wallet => AppColors.warning, - PaymentMethod.giftCard => AppColors.tierGold, - PaymentMethod.loyalty => AppColors.tierSilver, - }; - - Widget _row(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), - child: Row( - children: [ - Expanded( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13.5, - color: AppColors.textSecondary, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Text( - value, - style: const TextStyle( - fontSize: 13.5, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); + /// Units on a bill. Whole where they are whole — loose goods are sold by + /// weight, so "2.5" is a real answer here and rounding it would be a lie. + static String _units(double count) => + count % 1 == 0 ? count.toStringAsFixed(0) : count.toStringAsFixed(2); } -/// Flags a bill that could not reach the server on its own — the automatic -/// push right after checkout, not the manual "Sync" button below. +/// Flags a bill that could not reach the server on its own. /// /// Sits above everything else on the page because a bill stuck here is the /// one thing on this screen that needs a person to notice it, rather than @@ -343,12 +141,10 @@ class _EngineWarningBanner extends StatelessWidget { final retry = engine.nextAttemptAt; final retryNote = halted - ? 'Retrying will not help until this is fixed — press Sync below ' - 'once it is sorted.' + ? 'Retrying will not help until this is fixed.' : retry == null - ? 'It will retry automatically, or press Sync below to try now.' - : 'It will retry automatically at ${Formatters.time(retry)}, or ' - 'press Sync below to try now.'; + ? 'It will retry automatically.' + : 'It will retry automatically at ${Formatters.time(retry)}.'; return Container( margin: const EdgeInsets.only(bottom: AppSpacing.lg), diff --git a/lib/presentation/modules/screens/product_import_view.dart b/lib/presentation/modules/screens/product_import_view.dart index a0f8c68..d69f265 100644 --- a/lib/presentation/modules/screens/product_import_view.dart +++ b/lib/presentation/modules/screens/product_import_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; @@ -25,14 +24,18 @@ class ProductImportView extends ConsumerWidget { final ready = ref.watch(catalogueReadyProvider); final lastImport = ref.watch(lastImportAtProvider); final products = ref.watch(allProductsProvider).value ?? const []; - final revision = ref.watch(syncRepositoryProvider).catalogueRevision; return ModulePage( children: [ if (!ready) _NotImportedBanner(state: state), if (ready) ...[ + // Start-aligned, so the tiles begin at the same left edge as the + // panels below them rather than drifting with the run's width. Wrap( + alignment: WrapAlignment.start, + runAlignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, spacing: AppSpacing.lg, runSpacing: AppSpacing.lg, children: [ @@ -43,13 +46,6 @@ class ProductImportView extends ConsumerWidget { color: AppColors.success, caption: 'available offline', ), - StatTile( - label: 'Catalogue Revision', - value: revision ?? '—', - icon: Icons.tag_rounded, - color: AppColors.info, - caption: 'server version', - ), StatTile( label: 'Last Imported', value: lastImport == null @@ -91,7 +87,7 @@ class ProductImportView extends ConsumerWidget { child: ResponsiveTable( columns: const [ TableCol('Product', flex: 4), - TableCol('SKU', flex: 3, priority: 1), + TableCol('Barcode', flex: 3, priority: 1), TableCol('Category', flex: 2, priority: 1), TableCol('Price', flex: 2, numeric: true), TableCol('Stock', flex: 2, numeric: true), @@ -107,7 +103,8 @@ class ProductImportView extends ConsumerWidget { Flexible(child: Cell(p.name, bold: true)), ], ), - Cell(p.sku, color: AppColors.textTertiary), + Cell(p.barcode, + color: AppColors.textTertiary, mono: true,), TagChip(p.category.label, color: AppColors.textSecondary,), Cell(Formatters.money(p.price), mono: true, bold: true), diff --git a/lib/presentation/modules/screens/promos_view.dart b/lib/presentation/modules/screens/promos_view.dart index 38df98a..802d031 100644 --- a/lib/presentation/modules/screens/promos_view.dart +++ b/lib/presentation/modules/screens/promos_view.dart @@ -33,7 +33,11 @@ class PromosView extends ConsumerWidget { child: Center(child: CircularProgressIndicator()), ), error: (e, _) => Text('Could not load campaigns: $e'), + // Stretch, not the Column default of centre. Centred, every card + // shrank to its own intrinsic width and floated in the middle of the + // page instead of starting at the left edge like every other module. data: (promos) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _summary(promos), const SizedBox(height: AppSpacing.lg), @@ -55,6 +59,7 @@ class PromosView extends ConsumerWidget { .length; return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: StatTile( @@ -119,6 +124,7 @@ class PromosView extends ConsumerWidget { ) : Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final promo in promos) _PromoRow(promo: promo, isAdmin: isAdmin), diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index ab161b3..bc15d6f 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -9,6 +9,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../data/local/order_dao.dart'; +import '../../../data/local/void_pin_store.dart'; import '../../../domain/entities/store_account.dart'; import '../../auth/providers/auth_controller.dart'; import '../providers/printer_settings.dart'; @@ -17,6 +18,7 @@ import '../widgets/staff_dialogs.dart'; import '../widgets/store_details_dialog.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; +import '../../../core/widgets/numeric_keypad.dart'; /// Terminal and store configuration. class SettingsView extends ConsumerStatefulWidget { @@ -32,6 +34,10 @@ class _SettingsViewState extends ConsumerState { bool _testingDrawer = false; bool _loadedDrawerFields = false; + /// Null until the first read comes back from the meta table. + bool? _hasRemovalPin; + bool _loadedRemovalPin = false; + bool _scannerSound = true; bool _roundOff = true; bool _autoLoyalty = true; @@ -50,6 +56,16 @@ class _SettingsViewState extends ConsumerState { // Seeded once, from whatever was persisted. Assigning on every build would // fight the cashier for the cursor while they type. + if (!_loadedRemovalPin) { + _loadedRemovalPin = true; + final store = ref.read(localStoreProvider); + if (store.isReady) { + store.voidPin.isConfigured.then((has) { + if (mounted) setState(() => _hasRemovalPin = has); + }); + } + } + final printer = ref.watch(printerSettingsProvider); if (!_loadedDrawerFields && printer.hasDrawer) { _loadedDrawerFields = true; @@ -80,6 +96,8 @@ class _SettingsViewState extends ConsumerState { const SizedBox(height: AppSpacing.lg), _staffCard(store, user), const SizedBox(height: AppSpacing.lg), + _removalPinCard(user), + const SizedBox(height: AppSpacing.lg), _aboutCard(), ], ); @@ -430,6 +448,94 @@ class _SettingsViewState extends ConsumerState { ), ); + + // ------------------------------------------------------- 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 _setRemovalPin() async { + final pin = await showDialog( + context: context, + builder: (_) => const _RemovalPinDialog(), + ); + if (pin == null) return; + + final store = ref.read(localStoreProvider); + try { + if (pin.isEmpty) { + await store.voidPin.clearPin(); + } else { + await store.voidPin.setPin(pin); + } + if (!mounted) return; + setState(() => _hasRemovalPin = pin.isNotEmpty); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(pin.isEmpty + ? 'Removal PIN cleared. Removals now need an admin PIN.' + : 'Removal PIN saved.',), + ),); + } on VoidPinException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(e.message))); + } + } + Widget _connectivityCard() { final ready = ref.watch(catalogueReadyProvider); final lastImport = ref.watch(lastImportAtProvider); @@ -609,3 +715,96 @@ class _SettingsViewState extends ConsumerState { ), ); } + + +/// 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'), + ), + ], + ); + } +} diff --git a/lib/presentation/payment/screens/payment_screen.dart b/lib/presentation/payment/screens/payment_screen.dart index 2bcf344..2bf908f 100644 --- a/lib/presentation/payment/screens/payment_screen.dart +++ b/lib/presentation/payment/screens/payment_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../app/providers.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -12,6 +13,7 @@ import '../../../core/widgets/glass_card.dart'; import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/status_pill.dart'; +import '../../../domain/entities/promo.dart'; import '../../../domain/entities/transaction.dart'; import '../../customer/widgets/customer_capture_sheet.dart'; import '../../pos/providers/cart_controller.dart'; @@ -97,7 +99,15 @@ class _PaymentScreenState extends ConsumerState { const SizedBox(height: AppSpacing.md), _customerCard(), const SizedBox(height: AppSpacing.md), + // Only builds anything when a campaign is on the bill or one is + // within reach, so a shop running none sees no empty card. + _offersCard(), _methodsCard(controller, state), + const SizedBox(height: AppSpacing.md), + // Carries the rest of the column's height, and answers the + // question a customer asks at the counter — what am I paying + // for — without going back to the bill. + _summaryCard(), ], ); @@ -112,15 +122,36 @@ class _PaymentScreenState extends ConsumerState { ); } - return Padding( + // One scroll view over both columns, equal flex, centred and capped. + // + // Two independent scrollers with a 4:5 split were what made this read + // as lopsided: the columns started at different widths, ended at + // different heights, and the whole thing sat against the top-left of + // a much larger window. The minimum height fills the viewport so the + // pair sits in the middle of the screen instead of clinging to the + // top edge, and the cap stops the cards stretching into bands on a + // wide till display. + final minHeight = constraints.maxHeight.isFinite + ? (constraints.maxHeight - pad * 2).clamp(0.0, double.infinity) + : 0.0; + + return SingleChildScrollView( padding: EdgeInsets.all(pad), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 4, child: SingleChildScrollView(child: left)), - const SizedBox(width: AppSpacing.lg), - Expanded(flex: 5, child: SingleChildScrollView(child: right)), - ], + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1340), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: left), + const SizedBox(width: AppSpacing.lg), + Expanded(child: right), + ], + ), + ), + ), ), ); }, @@ -289,57 +320,6 @@ class _PaymentScreenState extends ConsumerState { ), ], ), - if (state.splits.isNotEmpty) ...[ - const Divider(height: AppSpacing.xxl), - Row( - children: [ - const Expanded( - child: Text( - 'Split tenders', - style: - TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600), - ), - ), - TextButton( - onPressed: controller.clearSplits, - style: TextButton.styleFrom( - foregroundColor: AppColors.danger, - minimumSize: const Size(0, 32), - ), - child: const Text('Clear'), - ), - ], - ), - for (final e in state.splits.asMap().entries) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: Row( - children: [ - Text(e.value.method.emoji, - style: const TextStyle(fontSize: 15),), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - e.value.method.label, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13.5), - ), - ), - Text(Formatters.money(e.value.amount), - style: AppTypography.money(13.5),), - IconButton( - onPressed: () => controller.removeSplit(e.key), - icon: const Icon(Icons.close_rounded, size: 16), - color: AppColors.textTertiary, - constraints: - const BoxConstraints(minWidth: 30, minHeight: 30), - padding: EdgeInsets.zero, - tooltip: 'Remove tender', - ), - ], - ), - ), - ], ], ), ); @@ -354,118 +334,191 @@ class _PaymentScreenState extends ConsumerState { ); } - /// Amount entry for whichever method is active. Every method works the - /// same way now — a keypad, an Exact shortcut, and an explicit amount — - /// rather than cash alone asking what was received while card/UPI/wallet - /// silently assumed the full balance. Cash additionally gets denomination - /// chips and a change-due row, since only cash can be over-tendered; a - /// method that captures a reference (card, UPI, gift card) additionally + /// Amount entry for whichever method is active. + /// + /// Every method works the same way — a keypad, an Exact shortcut and an + /// explicit amount — rather than cash alone asking what was received while + /// card/UPI/wallet silently assumed the full balance. Cash additionally gets + /// denomination shortcuts and a change-due row, since only cash can be + /// over-tendered; a method that captures a reference (card, UPI, gift card) /// gets that field below the keypad. Widget _amountTender(PaymentController controller, PaymentState state) { final cash = state.activeMethod.needsChange; + return LayoutBuilder( + builder: (context, box) { + // Wide enough to stand the shortcuts beside the keypad instead of + // above it. A centred 330px keypad in a 560px column was the other + // half of the lopsided look — the space beside it did nothing. + final sideBySide = cash && box.maxWidth >= 500; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(methodIcon(state.activeMethod), + size: 20, color: AppColors.primary,), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + cash + ? 'Cash received' + : '${state.activeMethod.label} amount received', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _amountField(), + const SizedBox(height: AppSpacing.md), + + if (!sideBySide) ...[ + _shortcutWrap(controller, cash), + const SizedBox(height: AppSpacing.md), + ], + + if (cash) ...[ + _changeRow(controller.changeDue), + const SizedBox(height: AppSpacing.md), + ], + + if (sideBySide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _shortcutColumn(controller)), + const SizedBox(width: AppSpacing.lg), + SizedBox(width: 296, child: _keypad()), + ], + ) + else + Center(child: _keypad()), + + if (state.activeMethod.needsReference) ...[ + const SizedBox(height: AppSpacing.md), + TextField( + onChanged: controller.setReference, + decoration: InputDecoration( + labelText: switch (state.activeMethod) { + PaymentMethod.card => 'Approval code', + PaymentMethod.upi => 'UPI transaction ID', + PaymentMethod.giftCard => 'Gift card number', + _ => 'Reference', + }, + prefixIcon: const Icon(Icons.tag_rounded), + ), + ), + ], + + _partPaymentAction(controller, state), + _receivedSoFar(controller, state), + ], + ); + }, + ); + } + + Widget _keypad() => NumericKeypad( + allowDecimal: true, + maxWidth: 330, + onKey: _appendCash, + onBackspace: _backspaceCash, + ); + + /// The typed amount. + /// + /// The symbol sits in the same run as the digits and in the same style. It + /// used to be a separate, smaller, grey glyph, which rendered as a mismatched + /// mark floating beside the number rather than part of it. + Widget _amountField() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: AppRadius.brLg, + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + '₹${_cashBuffer.isEmpty ? '0' : _cashBuffer}', + style: AppTypography.money(30), + ), + ), + ), + ], + ), + ); + } + + static const List _notes = [50, 100, 200, 500, 2000]; + + /// Shortcuts above the keypad, for the narrow layout. + Widget _shortcutWrap(PaymentController controller, bool cash) { + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + ActionChip( + avatar: const Icon(Icons.done_all_rounded, size: 15), + label: const Text('Exact'), + onPressed: () => _setCash(controller.balanceDue), + ), + // Denomination shortcuts only make sense for physical notes. + if (cash) + for (final note in _notes) + ActionChip( + label: Text('₹$note'), + onPressed: () => + _setCash((double.tryParse(_cashBuffer) ?? 0) + note), + ), + ], + ); + } + + /// Shortcuts beside the keypad, for the wide layout. + Widget _shortcutColumn(PaymentController controller) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - cash - ? 'Cash received' - : '${state.activeMethod.label} amount received', - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), + FilledButton.tonalIcon( + onPressed: () => _setCash(controller.balanceDue), + icon: const Icon(Icons.done_all_rounded, size: 17), + label: Text('Exact ${Formatters.money(controller.balanceDue)}'), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 48), + backgroundColor: AppColors.primarySurface, + foregroundColor: AppColors.primary, + ), + ), + const SizedBox(height: AppSpacing.sm), + for (final note in _notes) ...[ + OutlinedButton( + onPressed: () => + _setCash((double.tryParse(_cashBuffer) ?? 0) + note), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + foregroundColor: AppColors.textPrimary, ), - ], - ), - const SizedBox(height: AppSpacing.md), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, + child: Text('+ ₹$note'), ), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brLg, - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - const Text('₹', - style: - TextStyle(fontSize: 22, color: AppColors.textTertiary),), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - _cashBuffer.isEmpty ? '0' : _cashBuffer, - style: AppTypography.money(28), - ), - ), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - ActionChip( - avatar: const Icon(Icons.done_all_rounded, size: 15), - label: const Text('Exact'), - onPressed: () => _setCash(controller.balanceDue), - ), - // Denomination shortcuts only make sense for physical notes. - if (cash) - for (final note in const [50, 100, 200, 500, 2000]) - ActionChip( - label: Text('₹$note'), - onPressed: () => - _setCash((double.tryParse(_cashBuffer) ?? 0) + note), - ), - ], - ), - if (cash) ...[ - const SizedBox(height: AppSpacing.md), - _changeRow(controller.changeDue), + if (note != _notes.last) const SizedBox(height: AppSpacing.sm), ], - const SizedBox(height: AppSpacing.md), - Center( - child: NumericKeypad( - allowDecimal: true, - maxWidth: 330, - onKey: _appendCash, - onBackspace: _backspaceCash, - ), - ), - if (state.activeMethod.needsReference) ...[ - const SizedBox(height: AppSpacing.md), - TextField( - onChanged: controller.setReference, - decoration: InputDecoration( - labelText: switch (state.activeMethod) { - PaymentMethod.card => 'Approval code', - PaymentMethod.upi => 'UPI transaction ID', - PaymentMethod.giftCard => 'Gift card number', - _ => 'Reference', - }, - prefixIcon: const Icon(Icons.tag_rounded), - ), - ), - ], - const SizedBox(height: AppSpacing.md), - _splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0), ], ); } @@ -513,22 +566,380 @@ class _PaymentScreenState extends ConsumerState { ); } - Widget _splitButton(PaymentController controller, {double? amount}) { - return OutlinedButton.icon( - onPressed: controller.balanceDue > 0 - ? () { - controller.addSplit( - amount: amount?.clamp(0, controller.balanceDue).toDouble(), - ); + /// Takes part of the bill on the current method. + /// + /// This is the old "Add as split payment" button, and it stages exactly the + /// same tender — but it no longer asks the cashier to know what a split is, + /// or to press it for a payment that is not one. It appears only when the + /// typed amount is genuinely short of the balance, and says what it will do + /// in the customer's terms: take this much now, leave that much to pay. + Widget _partPaymentAction( + PaymentController controller, + PaymentState state, + ) { + final entered = double.tryParse(_cashBuffer) ?? 0; + final due = controller.balanceDue; + final short = entered > 0.009 && entered < due - 0.009; + + if (!short) return const SizedBox.shrink(); + + final rest = due - entered; + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton.tonalIcon( + onPressed: () { + controller.addSplit(amount: entered); setState(() => _cashBuffer = ''); - } - : null, - icon: const Icon(Icons.call_split_rounded, size: 17), - label: const Text('Add as split payment'), - style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), + }, + icon: const Icon(Icons.add_rounded, size: 18), + label: Text( + 'Take ${Formatters.money(entered)} by ' + '${state.activeMethod.label}', + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom(minimumSize: const Size(0, 48)), + ), + const SizedBox(height: AppSpacing.xs), + Text( + '${Formatters.money(rest)} left to pay — pick another method for ' + 'the rest.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + ], + ), ); } + /// Tenders already staged against this bill, and what is still outstanding. + Widget _receivedSoFar(PaymentController controller, PaymentState state) { + if (state.splits.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + const Expanded( + child: Text( + 'Received so far', + style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600), + ), + ), + TextButton( + onPressed: controller.clearSplits, + style: TextButton.styleFrom( + foregroundColor: AppColors.danger, + minimumSize: const Size(0, 32), + ), + child: const Text('Clear'), + ), + ], + ), + for (final e in state.splits.asMap().entries) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Row( + children: [ + Icon(methodIcon(e.value.method), + size: 17, color: AppColors.textSecondary,), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + e.value.method.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13.5), + ), + ), + Text(Formatters.money(e.value.amount), + style: AppTypography.money(13.5),), + IconButton( + onPressed: () => controller.removeSplit(e.key), + icon: const Icon(Icons.close_rounded, size: 16), + color: AppColors.textTertiary, + constraints: + const BoxConstraints(minWidth: 30, minHeight: 30), + padding: EdgeInsets.zero, + tooltip: 'Remove tender', + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + const Expanded( + child: Text( + 'Still to pay', + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + Text( + Formatters.money(controller.balanceDue), + style: AppTypography.money( + 15, + color: controller.balanceDue > 0 + ? AppColors.warning + : AppColors.success, + ), + ), + ], + ), + ], + ), + ); + } + + // --------------------------------------------------------------- Offers + /// Campaigns on this bill, and the nearest one that is not on it yet. + /// + /// The engine already applies everything that qualifies, silently — the + /// shopper only ever saw a discount line. Two things were missing at the + /// counter: a cashier could not answer "did the weekend offer come off?" + /// without opening the promo module, and nobody could see that a bill was a + /// few rupees short of one. The near-miss rows are the point of this card: + /// a minimum-bill campaign is worth nothing if the person paying is never + /// told they are close to it. + /// + /// Read-only. Nothing here applies or removes a campaign — that stays with + /// [PromoEngine], so the till cannot be talked into a discount by hand. + Widget _offersCard() { + final cart = ref.watch(cartControllerProvider); + final promos = ref.watch(activePromosProvider).value ?? const []; + + 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(0, (sum, a) => sum + a.amount), + )}', + style: AppTypography.money(14, color: AppColors.success), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + + for (final a in applied) + _offerRow( + icon: Icons.check_circle_rounded, + tone: AppColors.success, + title: a.promo.name, + subtitle: a.promo.summary, + trailing: '− ${Formatters.money(a.amount)}', + ), + + // Two is the useful number: the next one to reach and the one + // after it. A full list turns a payment screen into a catalogue + // of things the shopper is not getting. + for (final p in withinReach.take(2)) + _offerRow( + icon: Icons.lock_open_rounded, + tone: AppColors.warning, + title: p.name, + subtitle: '${p.summary} \u00b7 add ' + '${Formatters.money(p.minBillValue - cart.subtotal)} more ' + 'to reach ${Formatters.money(p.minBillValue)}', + ), + ], + ), + ), + ); + } + + Widget _offerRow({ + required IconData icon, + required Color tone, + required String title, + required String subtitle, + String? trailing, + }) => + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 17, color: tone), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + ), + ), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AppSpacing.sm), + Text(trailing, style: AppTypography.money(13.5, color: tone)), + ], + ], + ), + ); + + // --------------------------------------------------------- Bill summary + /// What the amount due is made of. + Widget _summaryCard() { + final cart = ref.watch(cartControllerProvider); + final discounts = cart.lineDiscountTotal + cart.billDiscountTotal; + + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.lg), + radius: AppRadius.xl, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Bill summary', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: AppSpacing.md), + _summaryRow('Subtotal', Formatters.money(cart.subtotal)), + if (discounts > 0) + _summaryRow( + 'Discounts', + '− ${Formatters.money(discounts)}', + tone: AppColors.success, + ), + if (cart.loyaltyRedemptionValue > 0) + _summaryRow( + 'Points redeemed', + '− ${Formatters.money(cart.loyaltyRedemptionValue)}', + tone: AppColors.success, + ), + if (cart.roundOff != 0) + _summaryRow('Round off', Formatters.money(cart.roundOff)), + const Divider(height: AppSpacing.xl), + _summaryRow( + 'Total', + Formatters.money(cart.grandTotal), + strong: true, + ), + const SizedBox(height: AppSpacing.xs), + // Prices are GST-inclusive, so this is a breakdown of the total + // rather than another line added to it — said plainly, because a + // customer reading a tax figure will otherwise try to add it on. + Text( + 'Includes GST ${Formatters.money(cart.taxAmount)} ' + '(CGST ${Formatters.money(cart.cgst)} + ' + 'SGST ${Formatters.money(cart.sgst)})', + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + height: 1.45, + ), + ), + ], + ), + ); + } + + Widget _summaryRow( + String label, + String value, { + Color? tone, + bool strong = false, + }) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: strong ? 14.5 : 13.5, + fontWeight: strong ? FontWeight.w600 : FontWeight.w400, + color: strong + ? AppColors.textPrimary + : (tone ?? AppColors.textSecondary), + ), + ), + ), + Text( + value, + style: AppTypography.money( + strong ? 17 : 13.5, + color: tone ?? (strong ? AppColors.primary : null), + ), + ), + ], + ), + ); + // ------------------------------------------------------------ Bottom bar Widget _bottomBar( PaymentController controller, @@ -679,7 +1090,11 @@ class _MethodTile extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(method.emoji, style: const TextStyle(fontSize: 22)), + Icon( + methodIcon(method), + size: 22, + color: selected ? Colors.white : AppColors.textSecondary, + ), const SizedBox(height: AppSpacing.xs), Text( method.label, @@ -699,3 +1114,19 @@ class _MethodTile extends StatelessWidget { ); } } + +/// A flat icon per tender type. +/// +/// These were emoji — 💵 for cash, 💳 for card — which render as small +/// photographic pictures on most platforms and as a fallback box on some. Next +/// to Material iconography everywhere else on the screen they read as clip +/// art pasted into the UI rather than part of it, and the cash one in +/// particular looked like a picture of American banknotes on a rupee till. +IconData methodIcon(PaymentMethod method) => switch (method) { + PaymentMethod.cash => Icons.payments_outlined, + PaymentMethod.card => Icons.credit_card_rounded, + PaymentMethod.upi => Icons.qr_code_2_rounded, + PaymentMethod.wallet => Icons.account_balance_wallet_outlined, + PaymentMethod.giftCard => Icons.card_giftcard_rounded, + PaymentMethod.loyalty => Icons.stars_rounded, + }; diff --git a/lib/presentation/pos/providers/cart_controller.dart b/lib/presentation/pos/providers/cart_controller.dart index 1056996..754ce4a 100644 --- a/lib/presentation/pos/providers/cart_controller.dart +++ b/lib/presentation/pos/providers/cart_controller.dart @@ -208,10 +208,19 @@ class CartController extends StateNotifier { setQuantity(productId, line.quantity + by); } + /// Steps a line down, never off the bill. + /// + /// Floors at one deliberately. Taking the last unit away is a removal, and + /// removals go through the PIN gate on the close button — a stepper that + /// quietly reached zero was a way around it. void decrement(String productId, {double by = 1}) { final line = state.lineFor(productId); if (line == null) return; - setQuantity(productId, line.quantity - by); + + final next = line.quantity - by; + if (next < 1) return; + + setQuantity(productId, next); } void removeLine(String productId) { diff --git a/lib/presentation/pos/providers/navigation_provider.dart b/lib/presentation/pos/providers/navigation_provider.dart index 7bf0699..7db297d 100644 --- a/lib/presentation/pos/providers/navigation_provider.dart +++ b/lib/presentation/pos/providers/navigation_provider.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../auth/providers/auth_controller.dart'; + /// The modules a cashier needs. Deliberately excludes analytics — this /// terminal is for billing, not back-office reporting. enum PosModule { @@ -41,4 +43,37 @@ enum NavSection { PosModule.values.where((m) => m.section == this).toList(); } +/// What a cashier session may open. +/// +/// Billing, and nothing else — not the catalogue, the promos, the sync log or +/// the terminal's configuration. The sidebar is hidden in cashier mode anyway, +/// so this is the belt to that braces: a module reached some other way (a scan +/// handler, a deep link, a stale value left in [activeModuleProvider] from the +/// admin's session) still cannot render. +const cashierModules = [PosModule.pos]; + final activeModuleProvider = StateProvider((ref) => PosModule.pos); + +/// The modules the current session is allowed to reach. +final visibleModulesProvider = Provider>((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((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>((ref) { + final visible = ref.watch(visibleModulesProvider); + return NavSection.values + .where((s) => s.modules.any(visible.contains)) + .toList(); +}); diff --git a/lib/presentation/pos/screens/pos_dashboard_screen.dart b/lib/presentation/pos/screens/pos_dashboard_screen.dart index f2e4c8a..a1798f1 100644 --- a/lib/presentation/pos/screens/pos_dashboard_screen.dart +++ b/lib/presentation/pos/screens/pos_dashboard_screen.dart @@ -35,6 +35,11 @@ import 'pos_view.dart'; /// * `1120–1300` sidebar as an icon rail, docked bill /// * `920–1120` icon rail, bill becomes a bottom sheet /// * `< 920` sidebar goes off-canvas behind a menu button +/// +/// In cashier mode the sidebar is not rendered at any width. That session has +/// exactly two destinations, and both are reachable from the header — a rail +/// holding one live tile is chrome for its own sake. Sign-out and the sync log +/// move up with it, since the sidebar was the only place they lived. class PosDashboardScreen extends ConsumerStatefulWidget { const PosDashboardScreen({super.key}); @@ -119,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { final layout = PosLayout.of(context); - final module = ref.watch(activeModuleProvider); + // Clamped, not raw: a module the admin left active must not carry into a + // cashier session. + final module = ref.watch(resolvedModuleProvider); final ready = ref.watch(catalogueReadyProvider); final isPos = module == PosModule.pos && ready; + final cashierMode = ref.watch(isCashierModeProvider); + final showSidebar = !cashierMode; + // Only the terminal itself needs the bill docked beside it. final showDockedBill = isPos && !layout.billingIsSheet; final showCartFab = isPos && layout.billingIsSheet; @@ -130,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState { return Scaffold( key: _scaffoldKey, backgroundColor: AppColors.background, - drawer: layout.sidebarIsDrawer + drawer: showSidebar && layout.sidebarIsDrawer ? Drawer( width: PosLayout.expandedWidth, backgroundColor: AppColors.surface, @@ -157,13 +167,18 @@ class _PosDashboardScreenState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar), + if (showSidebar && !layout.sidebarIsDrawer) + AppSidebar(mode: layout.sidebar), Expanded( child: Column( children: [ PageHeader( layout: layout, - onMenuTap: () => _scaffoldKey.currentState?.openDrawer(), + // Nothing to open in cashier mode, so the button is not + // offered rather than opening an empty drawer. + onMenuTap: showSidebar + ? () => _scaffoldKey.currentState?.openDrawer() + : null, ), Expanded( child: AnimatedSwitcher( diff --git a/lib/presentation/pos/screens/pos_view.dart b/lib/presentation/pos/screens/pos_view.dart index 23fdc37..a3b132b 100644 --- a/lib/presentation/pos/screens/pos_view.dart +++ b/lib/presentation/pos/screens/pos_view.dart @@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_layout.dart'; import '../../../core/widgets/primary_button.dart'; +import '../../auth/providers/auth_controller.dart'; import '../../sync/providers/sync_controller.dart'; import '../providers/navigation_provider.dart'; import '../widgets/category_chips.dart'; @@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget { final state = ref.watch(catalogueImportProvider); final running = state is ImportRunning; + // Pulling the catalogue is an admin job, and a cashier has no Product + // Import module to be sent to. Offering them a button that opens a screen + // they cannot reach is worse than telling them who to ask. + final cashier = ref.watch(isCashierModeProvider); + return Center( child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.xxl), @@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget { ), const SizedBox(height: AppSpacing.xxl), Text( - 'Import products to start billing', + cashier + ? 'No products on this terminal' + : 'Import products to start billing', textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: AppSpacing.sm), - const Text( - 'This terminal has no catalogue yet. Pull the current products ' - 'once at the start of your shift — after that everything runs ' - 'offline.', + Text( + cashier + ? 'Nothing has been imported for this shift yet. Ask an ' + 'admin to sign in and pull the catalogue — once they ' + 'have, sign in again and everything runs offline.' + : 'This terminal has no catalogue yet. Pull the current ' + 'products once at the start of your shift — after that ' + 'everything runs offline.', textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.textSecondary, height: 1.6, @@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget { ), const SizedBox(height: AppSpacing.xxl), - if (running) ...[ + if (!cashier && state is ImportRunning) ...[ Text( state.stage, style: const TextStyle( @@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget { const SizedBox(height: AppSpacing.lg), ], - if (state is ImportFailed) ...[ + if (!cashier && state is ImportFailed) ...[ Container( padding: const EdgeInsets.all(AppSpacing.md), margin: const EdgeInsets.only(bottom: AppSpacing.lg), @@ -171,23 +183,52 @@ class _CatalogueRequired extends ConsumerWidget { ), ], - PrimaryButton( - label: 'Import catalogue now', - icon: Icons.cloud_download_rounded, - large: true, - busy: running, - onPressed: running - ? null - : () => ref.read(catalogueImportProvider.notifier).run(), - ), - const SizedBox(height: AppSpacing.md), - TextButton.icon( - onPressed: () => ref - .read(activeModuleProvider.notifier) - .state = PosModule.productImport, - icon: const Icon(Icons.open_in_new_rounded, size: 16), - label: const Text('Open Product Import'), - ), + if (cashier) + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: const BoxDecoration( + color: AppColors.infoSurface, + borderRadius: AppRadius.brSm, + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.admin_panel_settings_outlined, + size: 18, color: AppColors.info,), + SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Importing the catalogue is an admin job. Nothing ' + 'can be billed here until it has been done.', + style: TextStyle( + color: AppColors.info, + fontSize: 13, + height: 1.45, + ), + ), + ), + ], + ), + ) + else ...[ + PrimaryButton( + label: 'Import catalogue now', + icon: Icons.cloud_download_rounded, + large: true, + busy: running, + onPressed: running + ? null + : () => ref.read(catalogueImportProvider.notifier).run(), + ), + const SizedBox(height: AppSpacing.md), + TextButton.icon( + onPressed: () => ref + .read(activeModuleProvider.notifier) + .state = PosModule.productImport, + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: const Text('Open Product Import'), + ), + ], ], ), ), diff --git a/lib/presentation/pos/widgets/admin_pin_dialog.dart b/lib/presentation/pos/widgets/admin_pin_dialog.dart index a6220e2..9d19f44 100644 --- a/lib/presentation/pos/widgets/admin_pin_dialog.dart +++ b/lib/presentation/pos/widgets/admin_pin_dialog.dart @@ -5,16 +5,18 @@ import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/numeric_keypad.dart'; -import '../../../domain/entities/store_account.dart'; -/// Prompts for an admin PIN before a theft-sensitive cart action — taking a -/// scanned item back out of the bill, or clearing it — and resolves `true` -/// only once a PIN belonging to an [StaffRole.admin] account is verified. +/// Prompts for the removal PIN before taking a rung item back off a bill, and +/// resolves `true` only once it verifies. +/// +/// The PIN is the one an admin sets in Settings, and an admin's own staff PIN +/// always works too — so a cashier can void a line at the counter without an +/// admin walking over, and the owner is never locked out of their own till. /// /// A cashier can always start a brand new sale; what this exists to stop is /// quietly taking something back out of a bill a customer has already been /// shown, after it was rung up. -Future requireAdminPin( +Future requireVoidPin( BuildContext context, WidgetRef ref, { required String reason, @@ -67,11 +69,11 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> { }); final store = ref.read(localStoreProvider); - final user = await store.staff.authenticate(_pin); + final ok = await store.voidPin.verify(_pin); if (!mounted) return; - if (user == null) { + if (!ok) { setState(() { _checking = false; _error = 'Incorrect PIN.'; @@ -80,16 +82,6 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> { return; } - if (user.role != StaffRole.admin) { - setState(() { - _checking = false; - _error = "${user.name}'s PIN is ${user.role.label.toLowerCase()} — " - 'this needs an admin.'; - _pin = ''; - }); - return; - } - Navigator.of(context).pop(true); } @@ -112,7 +104,7 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> { const SizedBox(width: AppSpacing.sm), const Expanded( child: Text( - 'Admin PIN required', + 'Removal PIN required', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, diff --git a/lib/presentation/pos/widgets/app_sidebar.dart b/lib/presentation/pos/widgets/app_sidebar.dart index f2fee29..2956926 100644 --- a/lib/presentation/pos/widgets/app_sidebar.dart +++ b/lib/presentation/pos/widgets/app_sidebar.dart @@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_typography.dart'; +import '../../../core/widgets/brand_mark.dart'; import '../../../core/utils/formatters.dart'; import '../../auth/providers/auth_controller.dart'; -import '../../sync/widgets/sign_out_dialog.dart'; import '../providers/cart_controller.dart'; import '../../sync/providers/sync_controller.dart'; import '../providers/navigation_provider.dart'; @@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), child: Column( children: [ - for (final section in NavSection.values) + // Sections with nothing this session may open are not + // rendered as empty headings. + for (final section in ref.watch(visibleSectionsProvider)) _Section( section: section, expanded: expanded, @@ -69,8 +71,6 @@ class AppSidebar extends ConsumerWidget { ), ), ), - const Divider(height: 1), - _LogoutTile(expanded: expanded), ], ), ), @@ -94,23 +94,7 @@ class _Brand extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(10), - ), - alignment: Alignment.center, - child: const Text( - 'N', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - ), + const BrandMark(size: 36), if (expanded) ...[ const SizedBox(width: AppSpacing.md), Flexible( @@ -228,7 +212,8 @@ class _Section extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final active = ref.watch(activeModuleProvider); + final active = ref.watch(resolvedModuleProvider); + final visible = ref.watch(visibleModulesProvider); final cartCount = ref.watch(cartItemCountProvider); final ready = ref.watch(catalogueReadyProvider); final outstanding = ref.watch(unsyncedCountProvider).value ?? 0; @@ -255,7 +240,7 @@ class _Section extends ConsumerWidget { ), child: Divider(height: 1), ), - for (final module in section.modules) + for (final module in section.modules.where(visible.contains)) _NavTile( module: module, expanded: expanded, @@ -434,48 +419,3 @@ class _Badge extends StatelessWidget { ); } } - -class _LogoutTile extends ConsumerWidget { - const _LogoutTile({required this.expanded}); - - final bool expanded; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => showSignOutDialog(context, ref), - borderRadius: AppRadius.brSm, - child: Container( - height: AppSizes.navItemHeight, - padding: EdgeInsets.symmetric( - horizontal: expanded ? AppSpacing.md : 0, - ), - alignment: expanded ? Alignment.centerLeft : Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.logout_rounded, - size: 19, color: AppColors.danger,), - if (expanded) ...[ - const SizedBox(width: AppSpacing.md), - const Text( - 'Logout', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.danger, - ), - ), - ], - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index d4b43d6..b177387 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -18,7 +18,6 @@ import '../../customer/widgets/customer_capture_sheet.dart'; import '../providers/cart_controller.dart'; import 'admin_pin_dialog.dart'; import 'cart_line_tile.dart'; -import 'discount_sheet.dart'; /// Always-visible bill on the right of the dashboard. class BillingPanel extends ConsumerWidget { @@ -66,8 +65,6 @@ class BillingPanel extends ConsumerWidget { controller, line.product.id, ), - onDiscount: () => - showLineDiscountSheet(context, ref, line), ); }, ), @@ -79,37 +76,24 @@ class BillingPanel extends ConsumerWidget { } } -/// Once an item is on the bill, taking it back off needs an admin's -/// approval — a cashier can always start an entirely new sale instead. This -/// is the one gate both removal paths (a single line, or the whole cart) go -/// through, so they can never drift out of sync with each other. +/// Once an item is on the bill, taking it back off needs the removal PIN an +/// admin sets in Settings — a cashier can always start an entirely new sale +/// instead. This is the one gate every removal path goes through, so the +/// close button and the swipe can never drift apart. Future _removeLine( BuildContext context, WidgetRef ref, CartController controller, String productId, ) async { - final ok = await requireAdminPin( + final ok = await requireVoidPin( context, ref, - reason: 'Removing a scanned item from the bill needs admin approval.', + reason: 'Removing a scanned item from the bill needs the removal PIN.', ); if (ok) controller.removeLine(productId); } -Future _clearCart( - BuildContext context, - WidgetRef ref, - CartController controller, -) async { - final ok = await requireAdminPin( - context, - ref, - reason: 'Clearing the whole bill needs admin approval.', - ); - if (ok) controller.clear(); -} - class _Header extends ConsumerWidget { const _Header({required this.cart, required this.inSheet}); @@ -118,7 +102,6 @@ class _Header extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final controller = ref.read(cartControllerProvider.notifier); // Same fixed height as the page header on the left, so the two bars // line up on one visual line instead of the cart title floating lower. final contentPadding = PosLayout.of(context).contentPadding; @@ -127,8 +110,9 @@ class _Header extends ConsumerWidget { constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), padding: EdgeInsets.symmetric(horizontal: contentPadding), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + // Title and count read as one label, so they sit together rather + // than being pushed to opposite ends by a space-between row. Flexible( child: Text( 'Cart', @@ -137,9 +121,12 @@ class _Header extends ConsumerWidget { ), ), if (cart.isNotEmpty) ...[ - + const SizedBox(width: AppSpacing.sm), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brPill, @@ -154,52 +141,21 @@ class _Header extends ConsumerWidget { ), ), ], - SizedBox(), - SizedBox(), - Spacer(), + const Spacer(), - - // Icon-only actions: labelled buttons overflowed the 380px panel. - // Grouped tight with even spacing, flush against the same right - // edge the header buttons on the left use. - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (controller.canUndo) - _IconAction( - icon: Icons.undo_rounded, - tooltip: 'Undo (F8)', - color: AppColors.textSecondary, - onTap: controller.undo, - ), - if (cart.isNotEmpty) ...[ - _IconAction( - icon: Icons.pause_circle_outline_rounded, - tooltip: 'Park bill', - color: AppColors.warning, - onTap: () async { - await controller.park(); - ref.invalidate(parkedBillsProvider); - if (context.mounted) context.showSnack('Bill parked'); - }, - ), - _IconAction( - icon: Icons.delete_outline_rounded, - tooltip: 'Clear bill', - color: AppColors.danger, - onTap: () => _clearCart(context, ref, controller), - ), - ], - if (inSheet) - _IconAction( - icon: Icons.close_rounded, - tooltip: 'Close', - color: AppColors.textSecondary, - onTap: () => Navigator.of(context).pop(), - ), - ], - ), + // Undo, Park and Clear used to sit here as three icon buttons. They + // are the least-pressed controls on the panel and they were the + // first thing the eye landed on, above the bill itself. Undo is on + // F8; Park and Clear moved down beside the total, next to the button + // a cashier is already reaching for. + if (inSheet) + _IconAction( + icon: Icons.close_rounded, + tooltip: 'Close', + color: AppColors.textSecondary, + onTap: () => Navigator.of(context).pop(), + ), ], ), ); @@ -313,21 +269,6 @@ class _Summary extends ConsumerWidget { .join(', '), ), - InkWell( - onTap: () => showBillDiscountSheet(context, ref), - borderRadius: AppRadius.brXs, - child: _Row( - label: 'Discount', - value: cart.manualBillDiscountAmount > 0 - ? '-${Formatters.money(cart.manualBillDiscountAmount)}' - : '-${Formatters.money(0)}', - valueColor: cart.manualBillDiscountAmount > 0 - ? AppColors.success - : null, - trailingIcon: Icons.edit_outlined, - ), - ), - if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0) InkWell( onTap: () => cart.pointsRedeemed > 0 @@ -466,26 +407,33 @@ class _Actions extends ConsumerWidget { AppSpacing.xl, AppSpacing.xl, ), - child: PrimaryButton( - label: 'CHARGE', - large: true, - onPressed: enabled - ? () async { - // Ask once per bill, before payment. Skipping is one tap and - // leaves the sale as walk-in. - if (ref.read(cartControllerProvider).customer == null) { - await showCustomerCaptureSheet(context); - } - // Navigation result is not needed here. - if (context.mounted) unawaited(context.push(AppRoutes.payment)); - } - : null, - trailing: enabled - ? Text( - Formatters.money(cart.grandTotal), - style: AppTypography.money(21, color: Colors.white), - ) - : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PrimaryButton( + label: 'CHARGE', + large: true, + onPressed: enabled + ? () async { + // Ask once per bill, before payment. Skipping is one tap + // and leaves the sale as walk-in. + if (ref.read(cartControllerProvider).customer == null) { + await showCustomerCaptureSheet(context); + } + // Navigation result is not needed here. + if (context.mounted) { + unawaited(context.push(AppRoutes.payment)); + } + } + : null, + trailing: enabled + ? Text( + Formatters.money(cart.grandTotal), + style: AppTypography.money(21, color: Colors.white), + ) + : null, + ), + ], ), ); } diff --git a/lib/presentation/pos/widgets/cart_line_tile.dart b/lib/presentation/pos/widgets/cart_line_tile.dart index bb3776c..ca09e67 100644 --- a/lib/presentation/pos/widgets/cart_line_tile.dart +++ b/lib/presentation/pos/widgets/cart_line_tile.dart @@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget { required this.onIncrement, required this.onDecrement, required this.onRemove, - this.onDiscount, }); final CartLine line; final VoidCallback onIncrement; final VoidCallback onDecrement; final VoidCallback onRemove; - final VoidCallback? onDiscount; @override Widget build(BuildContext context) { @@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget { return Dismissible( key: ValueKey('dismiss_${p.id}'), direction: DismissDirection.endToStart, - onDismissed: (_) => onRemove(), + // Confirm rather than dismiss: [onRemove] opens the PIN dialog, and a + // refused PIN must leave the line exactly where it was. Dismissing first + // and asking after left the row gone from the screen but still in the + // cart — and Flutter asserting about a dismissed widget still in the + // tree. Returning false always is correct: when the PIN is accepted the + // line disappears because the cart changed, not because of the swipe. + confirmDismiss: (_) async { + onRemove(); + return false; + }, background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: AppSpacing.xl), @@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget { color: AppColors.textTertiary, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, - tooltip: 'Remove', + tooltip: 'Remove from bill (needs the removal PIN)', ), ],), @@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget { onIncrement: onIncrement, onDecrement: onDecrement, ), - if (onDiscount != null) ...[ - const SizedBox(width: AppSpacing.sm), - IconButton( - onPressed: onDiscount, - icon: const Icon(Icons.local_offer_outlined, size: 17), - color: AppColors.textSecondary, - constraints: const BoxConstraints(minWidth: 34, minHeight: 34), - padding: EdgeInsets.zero, - tooltip: 'Line discount', - ), - ], const Spacer(), Column( crossAxisAlignment: CrossAxisAlignment.end, @@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget { borderRadius: AppRadius.brSm, border: Border.all(color: AppColors.border), ), + // Minus stops at one rather than emptying the line. Dropping to zero + // was a silent removal that skipped the PIN the close button asks for — + // two taps of a stepper should not be a way around the till's only + // theft control. child: Row(mainAxisSize: MainAxisSize.min, children: [ - _btn(Icons.remove_rounded, onDecrement), + _btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null), Container( constraints: const BoxConstraints(minWidth: 42), alignment: Alignment.center, @@ -226,7 +226,7 @@ class _Stepper extends StatelessWidget { ); } - Widget _btn(IconData icon, VoidCallback onTap) => Material( + Widget _btn(IconData icon, VoidCallback? onTap) => Material( color: Colors.transparent, child: InkWell( onTap: onTap, @@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget { child: SizedBox( width: 34, height: 34, - child: Icon(icon, size: 17, color: AppColors.primary), + child: Icon( + icon, + size: 17, + color: onTap == null + ? AppColors.textTertiary.withValues(alpha: 0.5) + : AppColors.primary, + ), ), ), ); diff --git a/lib/presentation/pos/widgets/discount_sheet.dart b/lib/presentation/pos/widgets/discount_sheet.dart deleted file mode 100644 index cf1a324..0000000 --- a/lib/presentation/pos/widgets/discount_sheet.dart +++ /dev/null @@ -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 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 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 _show({ - required BuildContext context, - required String title, - required String subtitle, - required Discount current, - required ValueChanged onApply, -}) { - return showModalBottomSheet( - 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 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( - 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, - ), - ), - ],), - ],), - ), - ); - } -} diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 7eb8a5a..2d1403f 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_layout.dart'; import '../../../core/utils/formatters.dart'; +import '../../../core/widgets/brand_mark.dart'; +import '../../auth/providers/auth_controller.dart'; +import '../../shift/widgets/session_end_sheet.dart'; import '../providers/cart_controller.dart'; import '../providers/navigation_provider.dart'; @@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget { return LayoutBuilder( builder: (context, box) { final showStatus = box.maxWidth >= 720; - return _bar(context, ref, compact, showStatus); + // The brand block only earns its space once the bar is genuinely wide; + // below that it would push the actions off the end. + final showBrand = box.maxWidth >= 1000; + return _bar(context, ref, compact, showStatus, showBrand); }, ); } @@ -42,10 +48,14 @@ class PageHeader extends ConsumerWidget { WidgetRef ref, bool compact, bool showStatus, + bool showBrand, ) { - final module = ref.watch(activeModuleProvider); final now = ref.watch(clockProvider).value ?? DateTime.now(); + // With no sidebar there is nothing else on screen carrying the brand, the + // sync log or the way out — so all three are promoted into this bar. + final cashierMode = ref.watch(isCashierModeProvider); + return Container( constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), padding: EdgeInsets.symmetric( @@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget { ), child: Row( children: [ - if (compact) ...[ + if (compact && onMenuTap != null) ...[ IconButton( onPressed: onMenuTap, icon: const Icon(Icons.menu_rounded), @@ -68,7 +78,12 @@ class PageHeader extends ConsumerWidget { const SizedBox(width: AppSpacing.xs), ], - + // Dropped first when the bar gets tight: the actions are what the + // counter actually presses. + if (cashierMode && showBrand) ...[ + const _CashierBrand(), + const SizedBox(width: AppSpacing.lg), + ], if (showStatus) ...[ _LivePill(offline: ref.watch(simulateOfflineProvider)), @@ -82,14 +97,26 @@ class PageHeader extends ConsumerWidget { fontFeatures: [FontFeature.tabularFigures()], ), ), - const SizedBox(width: AppSpacing.lg), - Container(width: 1, height: 26, color: AppColors.border), - const SizedBox(width: AppSpacing.lg), ], + // The bar is always the same shape: status on the left, actions + // pinned to the right, for admin and cashier alike. Packed left with + // a divider between them, the actions landed in a different place on + // every screen — mid-bar on a wide admin window, hard left on a + // narrow one — and the two roles never agreed with each other. + const Spacer(), + _ParkedBillsButton(compact: compact), const SizedBox(width: AppSpacing.sm), _NewSaleButton(compact: compact), + + // Every role signs out from here. It used to sit at the foot of the + // sidebar for admins and up here for cashiers, which meant the same + // action lived in two places depending on who was holding the till. + const SizedBox(width: AppSpacing.md), + Container(width: 1, height: 26, color: AppColors.border), + const SizedBox(width: AppSpacing.md), + const _LogoutButton(), ], ), ); @@ -366,3 +393,77 @@ class _NewSaleButton extends ConsumerWidget { ); } } + + +/// Brand and outlet name, shown only in cashier mode. +/// +/// The sidebar normally carries these; without it the bar reads as a fragment +/// of an app rather than the top of one. +class _CashierBrand extends ConsumerWidget { + const _CashierBrand(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final store = ref.watch(currentStoreProvider); + final user = ref.watch(currentUserProvider); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const BrandMark(size: 32), + const SizedBox(width: AppSpacing.sm), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 170), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + store?.name ?? 'Nearle POS', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: AppColors.textPrimary, + height: 1.15, + ), + ), + Text( + user == null ? 'Cashier' : '${user.name} · Cashier', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + height: 1.25, + ), + ), + ], + ), + ), + ], + ); + } +} + +/// Signing out, for both roles. +/// +/// Opens the session-end chooser rather than signing out directly: a cashier +/// stepping away for ten minutes and a cashier finishing for the day want two +/// very different things to happen to the drawer and the catalogue. +class _LogoutButton extends ConsumerWidget { + const _LogoutButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + tooltip: 'Sign out', + onPressed: () => showSessionEndSheet(context, ref), + style: IconButton.styleFrom( + foregroundColor: AppColors.danger, + backgroundColor: AppColors.dangerSurface, + ), + icon: const Icon(Icons.logout_rounded), + ); + } +} diff --git a/lib/presentation/shift/screens/end_shift_screen.dart b/lib/presentation/shift/screens/end_shift_screen.dart new file mode 100644 index 0000000..a996bc7 --- /dev/null +++ b/lib/presentation/shift/screens/end_shift_screen.dart @@ -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 createState() => _EndShiftScreenState(); +} + +/// What a till drawer actually holds, largest first. +const _denominations = [2000, 500, 200, 100, 50, 20, 10, 5, 2, 1]; + +class _EndShiftScreenState extends ConsumerState { + /// Note or coin value → how many were counted. + final Map _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 _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 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 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 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, + ], + ), + ); + } +} diff --git a/lib/presentation/shift/widgets/session_end_sheet.dart b/lib/presentation/shift/widgets/session_end_sheet.dart new file mode 100644 index 0000000..fc4d6e0 --- /dev/null +++ b/lib/presentation/shift/widgets/session_end_sheet.dart @@ -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 showSessionEndSheet(BuildContext context, WidgetRef ref) async { + if (!ref.read(isCashierModeProvider)) { + return showSignOutDialog(context, ref); + } + + return showDialog( + 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 _temporaryLogout(BuildContext context, WidgetRef ref) async { + ref.read(cartControllerProvider.notifier).reset(); + await ref.read(authControllerProvider.notifier).signOut(); + + // Bump the version so catalogueReadyProvider re-reads hasCatalogue, and + // drop the cached lists so the next session's grid does not flash this + // session's products before it re-checks what is on disk. + ref.read(catalogueVersionProvider.notifier).state++; + ref.invalidate(allProductsProvider); + ref.invalidate(visibleProductsProvider); + ref.invalidate(categoryCountsProvider); + ref.invalidate(lowStockProductsProvider); + + ref.read(activeModuleProvider.notifier).state = PosModule.pos; + ref.read(searchQueryProvider.notifier).state = ''; + ref.read(selectedCategoryProvider.notifier).state = null; + + if (!context.mounted) return; + + // Resolved while this context is still mounted — the messenger lives above + // the router, so the bar survives the route change below. + final messenger = ScaffoldMessenger.of(context); + + Navigator.of(context).pop(); + context.go(AppRoutes.login); + + messenger + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar( + content: Text( + 'Logged out. The shift is still open, and the product catalogue has ' + 'been removed from this terminal.', + ), + ),); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pending = ref.watch(unsyncedCountProvider).value ?? 0; + final cart = ref.watch(cartControllerProvider); + + return AlertDialog( + title: const Text('Leaving the till'), + contentPadding: const EdgeInsets.fromLTRB( + AppSpacing.xxl, + AppSpacing.lg, + AppSpacing.xxl, + AppSpacing.sm, + ), + content: SizedBox( + width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 460.0), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (cart.isNotEmpty) + Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: const BoxDecoration( + color: AppColors.warningSurface, + borderRadius: AppRadius.brSm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, + size: 18, color: AppColors.warning,), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'The current bill has ${cart.lineCount} item(s) and ' + 'will be cleared either way.', + style: const TextStyle( + fontSize: 12.5, + color: AppColors.warning, + height: 1.45, + ), + ), + ), + ], + ), + ), + + // The one thing both choices do, said once here rather than + // repeated in each card and discovered at the next login. + Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: const BoxDecoration( + color: AppColors.infoSurface, + borderRadius: AppRadius.brSm, + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.delete_sweep_outlined, + size: 18, color: AppColors.info,), + SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Either way, the product catalogue is removed from ' + 'this terminal. An admin imports it again before the ' + 'counter is worked.', + style: TextStyle( + fontSize: 12.5, + color: AppColors.info, + height: 1.45, + ), + ), + ), + ], + ), + ), + + _Choice( + icon: Icons.lock_clock_outlined, + tone: AppColors.info, + title: 'Temporary logout', + body: 'Locks the terminal without closing the shift. Bills ' + 'stay queued and today’s totals keep running, so the ' + 'drawer is still counted once at the end.', + onTap: () => _temporaryLogout(context, ref), + ), + const SizedBox(height: AppSpacing.md), + _Choice( + icon: Icons.point_of_sale_rounded, + tone: AppColors.primary, + title: 'End shift', + body: pending == 0 + ? 'Count the drawer, check it against what was rung, then ' + 'hand the till over.' + : 'Count the drawer, check it against what was rung, then ' + 'upload the $pending bill(s) still held here.', + onTap: () { + Navigator.of(context).pop(); + context.push(AppRoutes.endShift); + }, + emphasised: true, + ), + ], + ), + ), + ), + actionsPadding: const EdgeInsets.fromLTRB( + AppSpacing.xxl, + 0, + AppSpacing.xxl, + AppSpacing.lg, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay signed in'), + ), + ], + ); + } +} + +class _Choice extends StatelessWidget { + const _Choice({ + required this.icon, + required this.tone, + required this.title, + required this.body, + required this.onTap, + this.emphasised = false, + }); + + final IconData icon; + final Color tone; + final String title; + final String body; + final VoidCallback onTap; + final bool emphasised; + + @override + Widget build(BuildContext context) { + return Material( + color: emphasised ? AppColors.primarySurface : AppColors.surfaceAlt, + borderRadius: AppRadius.brLg, + child: InkWell( + onTap: onTap, + borderRadius: AppRadius.brLg, + child: Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + borderRadius: AppRadius.brLg, + border: Border.all( + color: emphasised ? AppColors.primaryBorder : AppColors.border, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 22, color: tone), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + body, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + const Icon(Icons.chevron_right_rounded, + size: 20, color: AppColors.textTertiary,), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index 4a6de6b..adcd90f 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -222,18 +222,6 @@ final syncBootstrapProvider = FutureProvider((ref) async { await store.syncConfig.load(ref.read(syncConfigProvider)); } - // Bring back the session this terminal was signed in under. - // - // Runs before the engine starts, and that ordering is load-bearing: the - // session carries both the bearer token and the outlet, so a drain that began - // first would upload the day's bills unauthenticated — and, once the backend - // is enforcing, have them refused. - // - // A till signs in when a shop opens and trades all day. Without this a reboot - // mid-shift would put a login screen in front of a queue of customers, which - // is a worse outage than the one it protects against. - await ref.read(authControllerProvider.notifier).restore(); - await ref.read(connectivityServiceProvider).start(); final engine = ref.read(syncEngineProvider); diff --git a/lib/presentation/sync/widgets/sign_out_dialog.dart b/lib/presentation/sync/widgets/sign_out_dialog.dart index 841f0c6..92b6ba3 100644 --- a/lib/presentation/sync/widgets/sign_out_dialog.dart +++ b/lib/presentation/sync/widgets/sign_out_dialog.dart @@ -10,6 +10,7 @@ import '../../../core/widgets/primary_button.dart'; import '../../auth/providers/auth_controller.dart'; import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/catalog_providers.dart'; +import '../../pos/providers/navigation_provider.dart'; import '../../../domain/repositories/sync_repository.dart'; import '../providers/sync_controller.dart'; @@ -38,21 +39,52 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { Future _finish() async { ref.read(cartControllerProvider.notifier).reset(); + + // Read before signing out — the session that decides this is gone by the + // time signOut returns. + final cleared = + ref.read(authControllerProvider.notifier).clearsCatalogueOnSignOut; + await ref.read(authControllerProvider.notifier).signOut(); - // Mirrors what a successful import does on the way in: bump the version - // so catalogueReadyProvider re-reads hasCatalogue as false, and drop the - // cached product lists so the next session's grid doesn't flash this - // session's now-cleared data before it re-fetches. + // Mirrors what a successful import does on the way in: bump the version so + // catalogueReadyProvider re-reads hasCatalogue, and drop the cached product + // lists so the next session's grid doesn't flash this session's data before + // it re-fetches. + // + // Run either way. After an admin sign-out the catalogue is still there and + // these simply re-read it — which is the point: the next session must see + // what is on disk now, not what this one had in memory. ref.read(catalogueVersionProvider.notifier).state++; ref.invalidate(allProductsProvider); ref.invalidate(visibleProductsProvider); ref.invalidate(categoryCountsProvider); ref.invalidate(lowStockProductsProvider); + // The next session starts on the till, never wherever this one left off. + ref.read(activeModuleProvider.notifier).state = PosModule.pos; + ref.read(searchQueryProvider.notifier).state = ''; + ref.read(selectedCategoryProvider.notifier).state = null; + if (!mounted) return; + + // Resolved while this context is still mounted. The messenger itself lives + // above the router, so the bar survives the route change below. + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); context.go(AppRoutes.login); + + if (cleared) { + messenger + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar( + content: Text( + 'Signed out. The product catalogue has been removed from this ' + 'terminal.', + ), + ),); + } } Future _pushThenFinish() async { @@ -76,8 +108,10 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { final pushing = ref.watch(orderSyncProvider) is SyncRunning; final failed = _result != null && !_result!.isSuccess; + final cashier = ref.watch(isCashierModeProvider); + return AlertDialog( - title: const Text('End shift'), + title: Text(cashier ? 'End shift' : 'Sign out'), contentPadding: const EdgeInsets.fromLTRB( AppSpacing.xxl, AppSpacing.lg, @@ -92,6 +126,26 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // What happens to the products is the difference between the two + // sign-outs, so it is said plainly rather than left to be + // discovered at the next login. + _Banner( + icon: cashier + ? Icons.delete_sweep_outlined + : Icons.inventory_2_outlined, + color: cashier ? AppColors.warning : AppColors.info, + background: cashier + ? AppColors.warningSurface + : AppColors.infoSurface, + message: cashier + ? 'The product catalogue will be removed from this ' + 'terminal. An admin imports it again for the next ' + 'shift.' + : 'The imported products stay on this terminal, so a ' + 'cashier can sign in and start billing without a ' + 'connection.', + ), + if (cart.isNotEmpty) _Banner( icon: Icons.warning_amber_rounded, diff --git a/pubspec.lock b/pubspec.lock index 911d333..122d0d2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -274,10 +274,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_secure_storage_platform_interface: dependency: transitive description: diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..d76d2a7 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +}