From eebd10da6d27a8351cc1376e0b49ddd115768d48 Mon Sep 17 00:00:00 2001 From: Anbarasu22004 Date: Thu, 6 Aug 2026 19:26:53 +0530 Subject: [PATCH] pos changes --- assets/images/bg.webp | Bin 0 -> 72864 bytes lib/app/providers.dart | 22 +- lib/core/config/sync_config.dart | 33 - lib/core/constants/asset_paths.dart | 10 +- lib/core/router/app_router.dart | 10 + lib/core/widgets/brand_mark.dart | 66 ++ lib/data/datasources/local_store.dart | 13 +- lib/data/local/app_database.dart | 10 + lib/data/local/order_dao.dart | 35 +- lib/data/local/session_store.dart | 81 -- lib/data/local/staff_dao.dart | 94 -- lib/data/local/void_pin_store.dart | 87 ++ lib/data/remote/http_catalogue_source.dart | 3 +- lib/data/remote/http_order_transport.dart | 8 +- lib/data/remote/pos_auth_api.dart | 149 --- .../repositories/sync_repository_impl.dart | 1 + lib/domain/entities/pos_session.dart | 253 ----- lib/domain/repositories/sync_repository.dart | 4 + .../auth/providers/auth_controller.dart | 328 +++--- .../auth/screens/login_screen.dart | 959 +++++++++--------- .../widgets/customer_capture_sheet.dart | 553 +++++----- .../modules/screens/events_view.dart | 254 +---- .../modules/screens/product_import_view.dart | 19 +- .../modules/screens/promos_view.dart | 6 + .../modules/screens/settings_view.dart | 199 ++++ .../payment/screens/payment_screen.dart | 777 ++++++++++---- .../pos/providers/cart_controller.dart | 11 +- .../pos/providers/navigation_provider.dart | 35 + .../pos/screens/pos_dashboard_screen.dart | 23 +- lib/presentation/pos/screens/pos_view.dart | 91 +- .../pos/widgets/admin_pin_dialog.dart | 28 +- lib/presentation/pos/widgets/app_sidebar.dart | 76 +- .../pos/widgets/billing_panel.dart | 158 +-- .../pos/widgets/cart_line_tile.dart | 42 +- .../pos/widgets/discount_sheet.dart | 213 ---- lib/presentation/pos/widgets/page_header.dart | 115 ++- .../shift/screens/end_shift_screen.dart | 823 +++++++++++++++ .../shift/widgets/session_end_sheet.dart | 283 ++++++ .../sync/providers/sync_controller.dart | 12 - .../sync/widgets/sign_out_dialog.dart | 64 +- pubspec.lock | 4 +- test/widget_test.dart | 30 + 42 files changed, 3451 insertions(+), 2531 deletions(-) create mode 100644 assets/images/bg.webp create mode 100644 lib/core/widgets/brand_mark.dart delete mode 100644 lib/data/local/session_store.dart create mode 100644 lib/data/local/void_pin_store.dart delete mode 100644 lib/data/remote/pos_auth_api.dart delete mode 100644 lib/domain/entities/pos_session.dart delete mode 100644 lib/presentation/pos/widgets/discount_sheet.dart create mode 100644 lib/presentation/shift/screens/end_shift_screen.dart create mode 100644 lib/presentation/shift/widgets/session_end_sheet.dart create mode 100644 test/widget_test.dart diff --git a/assets/images/bg.webp b/assets/images/bg.webp new file mode 100644 index 0000000000000000000000000000000000000000..9c6e191c9ed58c6f6c98d012d705cd80c1b070c0 GIT binary patch literal 72864 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 literal 0 HcmV?d00001 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); + }); +}