pos changes
This commit is contained in:
BIN
assets/images/bg.webp
Normal file
BIN
assets/images/bg.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -17,9 +17,7 @@ import '../data/remote/simulated_order_transport.dart';
|
|||||||
import '../data/repositories/store_repository_impl.dart';
|
import '../data/repositories/store_repository_impl.dart';
|
||||||
import '../data/repositories/sync_repository_impl.dart';
|
import '../data/repositories/sync_repository_impl.dart';
|
||||||
import '../data/repositories/transaction_repository_impl.dart';
|
import '../data/repositories/transaction_repository_impl.dart';
|
||||||
import '../data/local/session_store.dart';
|
|
||||||
import '../data/local/terminal_identity.dart';
|
import '../data/local/terminal_identity.dart';
|
||||||
import '../data/remote/pos_auth_api.dart';
|
|
||||||
import '../data/sync/sync_engine.dart';
|
import '../data/sync/sync_engine.dart';
|
||||||
import '../domain/repositories/customer_repository.dart';
|
import '../domain/repositories/customer_repository.dart';
|
||||||
import '../domain/repositories/product_repository.dart';
|
import '../domain/repositories/product_repository.dart';
|
||||||
@@ -163,28 +161,10 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
|
|||||||
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
|
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Signs a terminal in against the back office.
|
|
||||||
///
|
|
||||||
/// Points at the same base URL the uplinks use, so re-pointing a terminal in
|
|
||||||
/// Settings moves its sign-in with it rather than leaving it authenticating
|
|
||||||
/// against the endpoint it used to belong to.
|
|
||||||
final posAuthApiProvider = Provider<PosAuthApi>((ref) {
|
|
||||||
final api = PosAuthApi(baseUrl: ref.watch(syncConfigProvider).httpBaseUrl);
|
|
||||||
ref.onDispose(api.dispose);
|
|
||||||
return api;
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Where the signed session survives a restart.
|
|
||||||
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore());
|
|
||||||
|
|
||||||
/// The outlet, refreshed whenever staff or details change.
|
/// The outlet, refreshed whenever staff or details change.
|
||||||
///
|
|
||||||
/// The email is the signed-in account's, not a constant. It used to be
|
|
||||||
/// `DemoCredentials.email` — the same address on every install of a build,
|
|
||||||
/// which is what made the store login decorative.
|
|
||||||
final storeAccountProvider = FutureProvider<StoreAccount>(
|
final storeAccountProvider = FutureProvider<StoreAccount>(
|
||||||
(ref) => ref.watch(storeRepositoryProvider).load(
|
(ref) => ref.watch(storeRepositoryProvider).load(
|
||||||
email: ref.watch(authControllerProvider.notifier).session?.email ?? '',
|
email: DemoCredentials.email,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class SyncConfig {
|
|||||||
this.password,
|
this.password,
|
||||||
this.httpBaseUrl = '',
|
this.httpBaseUrl = '',
|
||||||
this.apiKey,
|
this.apiKey,
|
||||||
this.sessionToken,
|
|
||||||
this.ackTimeout = const Duration(seconds: 20),
|
this.ackTimeout = const Duration(seconds: 20),
|
||||||
this.batchSize = 50,
|
this.batchSize = 50,
|
||||||
});
|
});
|
||||||
@@ -53,38 +52,8 @@ class SyncConfig {
|
|||||||
final String? password;
|
final String? password;
|
||||||
|
|
||||||
final String httpBaseUrl;
|
final String httpBaseUrl;
|
||||||
|
|
||||||
/// A static key shared by every terminal at a deployment, if one is set.
|
|
||||||
///
|
|
||||||
/// Predates sign-in and says nothing about *who* is at the till, so it cannot
|
|
||||||
/// scope a request to an outlet. Kept for deployments that put one in front
|
|
||||||
/// of the endpoint.
|
|
||||||
final String? apiKey;
|
final String? apiKey;
|
||||||
|
|
||||||
/// The signed session from `POST /login`, held for the trading day.
|
|
||||||
///
|
|
||||||
/// Distinct from [apiKey] because the two answer different questions. The key
|
|
||||||
/// says "this request came from our fleet"; the session says "this request
|
|
||||||
/// came from Selvapuram, signed in as Ragul, and may touch that outlet and no
|
|
||||||
/// other". Only the second can stop a till reaching another tenant's books,
|
|
||||||
/// which is why it takes precedence when both are present.
|
|
||||||
final String? sessionToken;
|
|
||||||
|
|
||||||
/// What goes in the Authorization header.
|
|
||||||
///
|
|
||||||
/// One accessor rather than the same `??` repeated at each call site, because
|
|
||||||
/// the request that forgot it would be the one silently sending no
|
|
||||||
/// credentials at all.
|
|
||||||
String? get bearerToken {
|
|
||||||
final session = sessionToken?.trim();
|
|
||||||
if (session != null && session.isNotEmpty) return session;
|
|
||||||
|
|
||||||
final key = apiKey?.trim();
|
|
||||||
if (key != null && key.isNotEmpty) return key;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long to wait for the back office to confirm a batch before treating
|
/// How long to wait for the back office to confirm a batch before treating
|
||||||
/// the outcome as unknown and leaving every row pending.
|
/// the outcome as unknown and leaving every row pending.
|
||||||
///
|
///
|
||||||
@@ -179,7 +148,6 @@ class SyncConfig {
|
|||||||
String? password,
|
String? password,
|
||||||
String? httpBaseUrl,
|
String? httpBaseUrl,
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
String? sessionToken,
|
|
||||||
Duration? ackTimeout,
|
Duration? ackTimeout,
|
||||||
int? batchSize,
|
int? batchSize,
|
||||||
}) =>
|
}) =>
|
||||||
@@ -194,7 +162,6 @@ class SyncConfig {
|
|||||||
password: password ?? this.password,
|
password: password ?? this.password,
|
||||||
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
|
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
|
||||||
apiKey: apiKey ?? this.apiKey,
|
apiKey: apiKey ?? this.apiKey,
|
||||||
sessionToken: sessionToken ?? this.sessionToken,
|
|
||||||
ackTimeout: ackTimeout ?? this.ackTimeout,
|
ackTimeout: ackTimeout ?? this.ackTimeout,
|
||||||
batchSize: batchSize ?? this.batchSize,
|
batchSize: batchSize ?? this.batchSize,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
/// Typed references to bundled assets.
|
/// Typed references to bundled assets.
|
||||||
///
|
///
|
||||||
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome
|
/// Product imagery uses emoji glyphs and the welcome artwork is painted in
|
||||||
/// artwork is painted in code, so there are no raster or SVG assets to ship.
|
/// code, so the only raster asset shipped is the mark itself.
|
||||||
class AssetPaths {
|
class AssetPaths {
|
||||||
const AssetPaths._();
|
const AssetPaths._();
|
||||||
|
|
||||||
static const String _snd = 'assets/sounds';
|
static const String _snd = 'assets/sounds';
|
||||||
|
static const String _img = 'assets/images';
|
||||||
|
|
||||||
|
/// The Nearle mark. Every place that used to draw a letter "N" in a
|
||||||
|
/// gradient box now renders this instead, so the brand cannot drift between
|
||||||
|
/// the login screen, the sidebar and the cashier header.
|
||||||
|
static const String logo = '$_img/logo.png';
|
||||||
|
|
||||||
static const String beepSuccess = '$_snd/beep_success.wav';
|
static const String beepSuccess = '$_snd/beep_success.wav';
|
||||||
static const String beepError = '$_snd/beep_error.wav';
|
static const String beepError = '$_snd/beep_error.wav';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../presentation/auth/screens/login_screen.dart';
|
|||||||
import '../../presentation/payment/screens/payment_screen.dart';
|
import '../../presentation/payment/screens/payment_screen.dart';
|
||||||
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
||||||
import '../../presentation/receipt/screens/receipt_screen.dart';
|
import '../../presentation/receipt/screens/receipt_screen.dart';
|
||||||
|
import '../../presentation/shift/screens/end_shift_screen.dart';
|
||||||
|
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
const AppRoutes._();
|
const AppRoutes._();
|
||||||
@@ -19,6 +20,10 @@ class AppRoutes {
|
|||||||
static const String pos = '/';
|
static const String pos = '/';
|
||||||
static const String payment = '/payment';
|
static const String payment = '/payment';
|
||||||
static const String receipt = '/receipt';
|
static const String receipt = '/receipt';
|
||||||
|
|
||||||
|
/// Drawer count and hand-over. Reached from the session-end chooser, never
|
||||||
|
/// linked to directly, and guarded like every other signed-in route.
|
||||||
|
static const String endShift = '/end-shift';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Router with an authentication guard.
|
/// Router with an authentication guard.
|
||||||
@@ -60,6 +65,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) =>
|
||||||
_fade(state, const PosDashboardScreen()),
|
_fade(state, const PosDashboardScreen()),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.endShift,
|
||||||
|
name: 'endShift',
|
||||||
|
pageBuilder: (context, state) => _slide(state, const EndShiftScreen()),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.payment,
|
path: AppRoutes.payment,
|
||||||
name: 'payment',
|
name: 'payment',
|
||||||
|
|||||||
66
lib/core/widgets/brand_mark.dart
Normal file
66
lib/core/widgets/brand_mark.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../constants/asset_paths.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// The Nearle mark on its tile.
|
||||||
|
///
|
||||||
|
/// Every surface that shows the brand — login, sidebar, cashier header —
|
||||||
|
/// renders this, so the mark cannot drift between them. It replaces the
|
||||||
|
/// hand-drawn letter "N" in a gradient box that each of those screens used to
|
||||||
|
/// build for itself.
|
||||||
|
class BrandMark extends StatelessWidget {
|
||||||
|
const BrandMark({
|
||||||
|
super.key,
|
||||||
|
this.size = 36,
|
||||||
|
this.radius,
|
||||||
|
this.onDark = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double size;
|
||||||
|
final double? radius;
|
||||||
|
|
||||||
|
/// Set on a coloured background, where the tile needs no border to separate
|
||||||
|
/// it from what is behind.
|
||||||
|
final bool onDark;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final corner = BorderRadius.circular(radius ?? size * 0.28);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
// Clipped, not padded: the mark fills the tile edge to edge and the
|
||||||
|
// rounded corner does the trimming, so nothing can spill past the box
|
||||||
|
// whatever aspect ratio the file happens to have.
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: corner,
|
||||||
|
border: onDark ? null : Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Image.asset(
|
||||||
|
AssetPaths.logo,
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
// A missing or undeclared asset would otherwise blank the brand out
|
||||||
|
// of the sidebar entirely; the letterform is a poor substitute but a
|
||||||
|
// better failure than nothing.
|
||||||
|
errorBuilder: (context, _, __) => FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Text(
|
||||||
|
'N',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: size,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../local/staff_dao.dart';
|
|||||||
import '../local/sync_config_store.dart';
|
import '../local/sync_config_store.dart';
|
||||||
import '../local/sync_log_dao.dart';
|
import '../local/sync_log_dao.dart';
|
||||||
import '../local/terminal_identity.dart';
|
import '../local/terminal_identity.dart';
|
||||||
|
import '../local/void_pin_store.dart';
|
||||||
|
|
||||||
/// Terminal-side storage facade.
|
/// Terminal-side storage facade.
|
||||||
///
|
///
|
||||||
@@ -28,6 +29,7 @@ class LocalStore {
|
|||||||
late PromoDao promos;
|
late PromoDao promos;
|
||||||
late SyncConfigStore syncConfig;
|
late SyncConfigStore syncConfig;
|
||||||
late TerminalIdentityStore identityStore;
|
late TerminalIdentityStore identityStore;
|
||||||
|
late VoidPinStore voidPin;
|
||||||
|
|
||||||
/// Who this till is. Minted on first run, then stable forever.
|
/// Who this till is. Minted on first run, then stable forever.
|
||||||
late TerminalIdentity terminal;
|
late TerminalIdentity terminal;
|
||||||
@@ -60,6 +62,7 @@ class LocalStore {
|
|||||||
promos = PromoDao(AppDatabase.instance.db);
|
promos = PromoDao(AppDatabase.instance.db);
|
||||||
syncConfig = SyncConfigStore(catalogue);
|
syncConfig = SyncConfigStore(catalogue);
|
||||||
identityStore = TerminalIdentityStore(catalogue);
|
identityStore = TerminalIdentityStore(catalogue);
|
||||||
|
voidPin = VoidPinStore(catalogue, staff);
|
||||||
|
|
||||||
// A terminal with no staff cannot be signed into at all, so this runs
|
// A terminal with no staff cannot be signed into at all, so this runs
|
||||||
// before anything else can ask who is on shift.
|
// before anything else can ask who is on shift.
|
||||||
@@ -148,9 +151,13 @@ class LocalStore {
|
|||||||
|
|
||||||
/// Drops the imported catalogue from disk and from the in-memory cache.
|
/// Drops the imported catalogue from disk and from the in-memory cache.
|
||||||
///
|
///
|
||||||
/// Called at sign-out so the next shift never bills against a copy left
|
/// Called when a *cashier* signs out, so the next shift never bills against
|
||||||
/// over from this one — [hasCatalogue] goes back to false, and the only way
|
/// a copy left over from this one — [hasCatalogue] goes back to false, and
|
||||||
/// to sell again is a fresh pull from the back office.
|
/// the only way to sell again is a fresh pull from the back office.
|
||||||
|
///
|
||||||
|
/// Not called on an admin sign-out. An admin's whole job at this terminal is
|
||||||
|
/// to pull the catalogue and hand the till over, so wiping it on the way out
|
||||||
|
/// would undo the thing they just did.
|
||||||
Future<void> clearCatalogue() async {
|
Future<void> clearCatalogue() async {
|
||||||
await catalogue.clearCatalogue();
|
await catalogue.clearCatalogue();
|
||||||
_products.clear();
|
_products.clear();
|
||||||
|
|||||||
@@ -524,4 +524,14 @@ class MetaKeys {
|
|||||||
static const String printerName = 'printer_name';
|
static const String printerName = 'printer_name';
|
||||||
static const String autoPrint = 'auto_print';
|
static const String autoPrint = 'auto_print';
|
||||||
static const String openDrawer = 'open_cash_drawer';
|
static const String openDrawer = 'open_cash_drawer';
|
||||||
|
|
||||||
|
/// PIN that authorises taking a rung item back off a bill.
|
||||||
|
///
|
||||||
|
/// Stored hashed, like a staff PIN — the terminal only ever holds the hash
|
||||||
|
/// and its salt, so lifting the database file does not hand over the ability
|
||||||
|
/// to void. Deliberately separate from staff PINs: an admin sets it once and
|
||||||
|
/// gives it to whoever is on the counter, so a removal can be authorised
|
||||||
|
/// without an admin walking over to the till.
|
||||||
|
static const String voidPinHash = 'void_pin_hash';
|
||||||
|
static const String voidPinSalt = 'void_pin_salt';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,20 +303,27 @@ class OrderDao {
|
|||||||
return rows.isEmpty ? null : rows.first;
|
return rows.isEmpty ? null : rows.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query(
|
/// Rows for the sync log, with each bill's unit count folded in.
|
||||||
Tables.orders,
|
///
|
||||||
columns: [
|
/// The count comes from a correlated sum over [Tables.orderItems] rather
|
||||||
'id',
|
/// than a column on the order: quantity can be fractional (loose weight), so
|
||||||
'invoice_number',
|
/// there is no line count that answers "how many units were on this bill".
|
||||||
'total',
|
/// A single aggregate keeps this to one query rather than one per row.
|
||||||
'created_at',
|
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) =>
|
||||||
'sync_status',
|
_db.rawQuery(
|
||||||
'synced_at',
|
'''
|
||||||
'sync_attempts',
|
SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status,
|
||||||
'sync_error',
|
o.synced_at, o.sync_attempts, o.sync_error,
|
||||||
],
|
COALESCE(
|
||||||
orderBy: 'created_at DESC',
|
(SELECT SUM(i.quantity) FROM ${Tables.orderItems} i
|
||||||
limit: limit,
|
WHERE i.order_id = o.id),
|
||||||
|
0
|
||||||
|
) AS item_count
|
||||||
|
FROM ${Tables.orders} o
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
[limit],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ----------------------------------------------------------------- Sync
|
// ----------------------------------------------------------------- Sync
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
|
|
||||||
import '../../domain/entities/pos_session.dart';
|
|
||||||
|
|
||||||
/// Keeps a terminal signed in across restarts.
|
|
||||||
///
|
|
||||||
/// A till is not a browser. It signs in when a shop opens and bills for the
|
|
||||||
/// whole trading day, often on a connection that comes and goes, and it must
|
|
||||||
/// survive being rebooted mid-shift without a queue of customers waiting while
|
|
||||||
/// somebody finds the manager's password.
|
|
||||||
///
|
|
||||||
/// The whole session goes to the platform keystore rather than to SQLite. The
|
|
||||||
/// token is a bearer credential — anything holding it can bill as this shop —
|
|
||||||
/// and SQLite here is a file on a machine behind a shop counter, readable by
|
|
||||||
/// anything that can open it. The rest of the session travels with the token
|
|
||||||
/// because splitting them invites the two halves to disagree about which outlet
|
|
||||||
/// this terminal is.
|
|
||||||
class SessionStore {
|
|
||||||
SessionStore({FlutterSecureStorage? secureStorage})
|
|
||||||
: _secure = secureStorage ?? const FlutterSecureStorage();
|
|
||||||
|
|
||||||
final FlutterSecureStorage _secure;
|
|
||||||
|
|
||||||
static const _key = 'pos.session';
|
|
||||||
|
|
||||||
/// Reads the saved session, or null when there is none worth using.
|
|
||||||
///
|
|
||||||
/// An expired session is treated as absent rather than returned for the
|
|
||||||
/// caller to check. Every caller would have to make the same check, and the
|
|
||||||
/// one that forgot would send a dead token all day and read the resulting
|
|
||||||
/// 401s as a server fault.
|
|
||||||
Future<PosSession?> read({DateTime? now}) async {
|
|
||||||
final String? raw;
|
|
||||||
try {
|
|
||||||
raw = await _secure.read(key: _key);
|
|
||||||
} on Object catch (e) {
|
|
||||||
// No keystore — a headless test host, or a Linux box with no secret
|
|
||||||
// service. Signing in again is the safe way to fail.
|
|
||||||
debugPrint('Secure storage unavailable, session not loaded: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw == null || raw.isEmpty) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final session =
|
|
||||||
PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
|
|
||||||
if (!session.isValidAt(now ?? DateTime.now())) return null;
|
|
||||||
if (session.token.isEmpty || session.locationId <= 0) return null;
|
|
||||||
return session;
|
|
||||||
} on Object catch (e) {
|
|
||||||
// A stored session this build cannot parse — most likely written by an
|
|
||||||
// older one. Dropped rather than repaired: a half-understood session is
|
|
||||||
// worse than none, and re-authenticating costs one screen.
|
|
||||||
debugPrint('Stored session could not be read, discarding: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> write(PosSession session) async {
|
|
||||||
try {
|
|
||||||
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
|
|
||||||
} on Object catch (e) {
|
|
||||||
// The terminal keeps working on the session it holds in memory; it just
|
|
||||||
// will not survive a restart. Failing the sign-in over this would close a
|
|
||||||
// shop for a keystore problem.
|
|
||||||
debugPrint('Could not persist the session: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clear() async {
|
|
||||||
try {
|
|
||||||
await _secure.delete(key: _key);
|
|
||||||
} on Object catch (e) {
|
|
||||||
debugPrint('Could not clear the session: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,10 +25,6 @@ class StaffDao {
|
|||||||
|
|
||||||
final Database _db;
|
final Database _db;
|
||||||
|
|
||||||
/// Exposed for [StaffImport], which lives in this file and is part of this
|
|
||||||
/// type in everything but syntax — an extension cannot see a private field.
|
|
||||||
Database get db => _db;
|
|
||||||
|
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// The accounts a shop starts with.
|
/// The accounts a shop starts with.
|
||||||
@@ -281,93 +277,3 @@ class StaffDao {
|
|||||||
isActive: (row['is_active'] as int? ?? 1) == 1,
|
isActive: (row['is_active'] as int? ?? 1) == 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the terminal's staff with what the back office says.
|
|
||||||
///
|
|
||||||
/// The back office is the source of truth for who works at a shop, and this is
|
|
||||||
/// where that becomes true rather than aspirational. It exists because the
|
|
||||||
/// alternative — three names and three PINs compiled into the app — meant every
|
|
||||||
/// install of a build shared the same three logins, readable by anyone with the
|
|
||||||
/// APK.
|
|
||||||
///
|
|
||||||
/// Three things happen, and the second is the one that matters:
|
|
||||||
///
|
|
||||||
/// 1. every person the back office named is written, keyed on their user id so
|
|
||||||
/// a re-sync updates rather than duplicates;
|
|
||||||
/// 2. **the seeded accounts are deactivated**, so the moment a shop has real
|
|
||||||
/// staff the built-in PINs stop working — without this the hardcoded
|
|
||||||
/// logins would survive alongside the real ones for ever; and
|
|
||||||
/// 3. anyone previously imported who is no longer named is deactivated too,
|
|
||||||
/// because a leaver removed in the back office must lose the till.
|
|
||||||
///
|
|
||||||
/// Deactivated, never deleted. Bills carry the cashier's name and shifts are
|
|
||||||
/// settled against it, so a hard delete would orphan a day's takings.
|
|
||||||
///
|
|
||||||
/// Does nothing at all when [members] is empty. That is the common case today —
|
|
||||||
/// most outlets have no staff recorded — and wiping a working till's logins
|
|
||||||
/// because the back office has not been filled in yet would close a shop.
|
|
||||||
extension StaffImport on StaffDao {
|
|
||||||
Future<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
|
|
||||||
if (members.isEmpty) return 0;
|
|
||||||
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
final imported = <String>{};
|
|
||||||
|
|
||||||
for (final member in members) {
|
|
||||||
final pin = member.pin.trim();
|
|
||||||
// A blank or malformed PIN cannot be signed in with. Skipped rather than
|
|
||||||
// written, so the till does not show a name nobody can use.
|
|
||||||
if (pin.length < 4 || int.tryParse(pin) == null) continue;
|
|
||||||
|
|
||||||
final salt = PinHasher.newSalt();
|
|
||||||
imported.add(member.localId);
|
|
||||||
|
|
||||||
await db.insert(
|
|
||||||
Tables.staff,
|
|
||||||
{
|
|
||||||
'id': member.localId,
|
|
||||||
'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name,
|
|
||||||
'role': member.role.name,
|
|
||||||
'pin_hash': PinHasher.hash(pin, salt),
|
|
||||||
'pin_salt': salt,
|
|
||||||
// Not flagged for change: this PIN was set by the shop in the back
|
|
||||||
// office, so it is already theirs. The flag is for the seeds.
|
|
||||||
'must_change_pin': 0,
|
|
||||||
'is_active': 1,
|
|
||||||
'created_at': now,
|
|
||||||
'updated_at': now,
|
|
||||||
},
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing usable came back — leave the till exactly as it was rather than
|
|
||||||
// stranding it with no way to sign in.
|
|
||||||
if (imported.isEmpty) return 0;
|
|
||||||
|
|
||||||
final placeholders = List.filled(imported.length, '?').join(',');
|
|
||||||
await db.update(
|
|
||||||
Tables.staff,
|
|
||||||
{'is_active': 0, 'updated_at': now},
|
|
||||||
where: 'id NOT IN ($placeholders)',
|
|
||||||
whereArgs: imported.toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return imported.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One person to import, already mapped onto the till's own role vocabulary.
|
|
||||||
class StaffImportRecord {
|
|
||||||
const StaffImportRecord({
|
|
||||||
required this.localId,
|
|
||||||
required this.name,
|
|
||||||
required this.role,
|
|
||||||
required this.pin,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String localId;
|
|
||||||
final String name;
|
|
||||||
final StaffRole role;
|
|
||||||
final String pin;
|
|
||||||
}
|
|
||||||
|
|||||||
87
lib/data/local/void_pin_store.dart
Normal file
87
lib/data/local/void_pin_store.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import '../../core/security/pin_hasher.dart';
|
||||||
|
import '../../domain/entities/store_account.dart';
|
||||||
|
import 'app_database.dart';
|
||||||
|
import 'catalogue_dao.dart';
|
||||||
|
import 'staff_dao.dart';
|
||||||
|
|
||||||
|
/// The PIN that authorises removing a rung item from a bill.
|
||||||
|
///
|
||||||
|
/// Set once by an admin and handed to whoever is on the counter, so a cashier
|
||||||
|
/// can void a line without an admin walking over. It is a *separate* secret
|
||||||
|
/// from staff PINs on purpose: a staff PIN identifies a person and is what
|
||||||
|
/// stamps a bill, and sharing one to allow voids would put every sale that
|
||||||
|
/// shift under the wrong name.
|
||||||
|
///
|
||||||
|
/// Stored hashed with its own salt, never in the clear. Until an admin sets
|
||||||
|
/// one, [verify] falls back to any admin's staff PIN — a terminal that cannot
|
||||||
|
/// void at all is worse than one that needs the admin present.
|
||||||
|
class VoidPinStore {
|
||||||
|
const VoidPinStore(this._meta, this._staff);
|
||||||
|
|
||||||
|
final CatalogueDao _meta;
|
||||||
|
final StaffDao _staff;
|
||||||
|
|
||||||
|
/// Whether an admin has set a dedicated removal PIN on this terminal.
|
||||||
|
///
|
||||||
|
/// Empty counts as absent: [clearPin] blanks the row rather than deleting
|
||||||
|
/// it, so a null check alone would report a cleared PIN as still set.
|
||||||
|
Future<bool> get isConfigured async {
|
||||||
|
final hash = await _meta.meta(MetaKeys.voidPinHash);
|
||||||
|
return hash != null && hash.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPin(String pin) async {
|
||||||
|
_assertAcceptable(pin);
|
||||||
|
final salt = PinHasher.newSalt();
|
||||||
|
await _meta.setMeta(MetaKeys.voidPinHash, PinHasher.hash(pin, salt));
|
||||||
|
await _meta.setMeta(MetaKeys.voidPinSalt, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the dedicated PIN, returning the terminal to admin-PIN-only voids.
|
||||||
|
Future<void> clearPin() async {
|
||||||
|
await _meta.setMeta(MetaKeys.voidPinHash, '');
|
||||||
|
await _meta.setMeta(MetaKeys.voidPinSalt, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when [pin] may authorise a removal.
|
||||||
|
///
|
||||||
|
/// Checks the dedicated PIN first, then admin staff PINs. An admin's own PIN
|
||||||
|
/// always works, so setting a removal PIN never locks the owner out of their
|
||||||
|
/// own till.
|
||||||
|
Future<bool> verify(String pin) async {
|
||||||
|
final hash = await _meta.meta(MetaKeys.voidPinHash);
|
||||||
|
final salt = await _meta.meta(MetaKeys.voidPinSalt);
|
||||||
|
|
||||||
|
if (hash != null && hash.isNotEmpty && salt != null && salt.isNotEmpty) {
|
||||||
|
if (PinHasher.verify(pin, salt: salt, hash: hash)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final user = await _staff.authenticate(pin);
|
||||||
|
return user != null && user.role == StaffRole.admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same rule the staff PINs use, for the same reason: these are typed on a
|
||||||
|
/// keypad behind a counter, in front of a queue.
|
||||||
|
static void _assertAcceptable(String pin) {
|
||||||
|
if (pin.length < 4 || int.tryParse(pin) == null) {
|
||||||
|
throw const VoidPinException('A PIN must be at least four digits.');
|
||||||
|
}
|
||||||
|
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
|
||||||
|
'7777', '8888', '9999', '1234', '4321', '0123',};
|
||||||
|
if (tooObvious.contains(pin)) {
|
||||||
|
throw const VoidPinException(
|
||||||
|
'That PIN is too easy to guess from across the counter. '
|
||||||
|
'Choose another.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoidPinException implements Exception {
|
||||||
|
const VoidPinException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
@@ -156,8 +156,7 @@ class HttpCatalogueSource implements CatalogueSource {
|
|||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'accept': 'application/json',
|
'accept': 'application/json',
|
||||||
if (config.bearerToken != null)
|
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
|
||||||
'authorization': 'Bearer ${config.bearerToken}',
|
|
||||||
},
|
},
|
||||||
).timeout(_timeout);
|
).timeout(_timeout);
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport {
|
|||||||
Uri.parse('${config.httpBaseUrl}/health'),
|
Uri.parse('${config.httpBaseUrl}/health'),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
if (config.bearerToken != null)
|
if (config.apiKey != null)
|
||||||
'authorization': 'Bearer ${config.bearerToken}',
|
'authorization': 'Bearer ${config.apiKey}',
|
||||||
},
|
},
|
||||||
body: payload,
|
body: payload,
|
||||||
)
|
)
|
||||||
@@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport {
|
|||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
if (config.bearerToken != null)
|
if (config.apiKey != null)
|
||||||
'authorization': 'Bearer ${config.bearerToken}',
|
'authorization': 'Bearer ${config.apiKey}',
|
||||||
'idempotency-key': batchId,
|
'idempotency-key': batchId,
|
||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../domain/entities/pos_session.dart';
|
|
||||||
|
|
||||||
/// Raised when the back office refuses or cannot answer a sign-in.
|
|
||||||
///
|
|
||||||
/// Carries a message meant to be shown to whoever is standing at the till, so
|
|
||||||
/// it is written for them rather than for a log: what happened, and what they
|
|
||||||
/// can do about it.
|
|
||||||
class PosAuthException implements Exception {
|
|
||||||
const PosAuthException(this.message, {this.isCredentialFailure = false});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
/// Whether the details were wrong, as opposed to the back office being
|
|
||||||
/// unreachable. The till reacts differently: a bad password is worth
|
|
||||||
/// re-typing, an unreachable server is worth waiting for.
|
|
||||||
final bool isCredentialFailure;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signs a terminal in against the back office.
|
|
||||||
///
|
|
||||||
/// Talks to the same `app_users` accounts as the web console, so a manager who
|
|
||||||
/// can open the back office can open the till with the same details — one
|
|
||||||
/// account store means deactivating a leaver closes both doors at once.
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// POST {base}/login
|
|
||||||
/// { "authname": "…", "password": "…", "terminal_id": "T5EDD" }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// answered with `{ code, status, details: { token, store_id, locations, … } }`.
|
|
||||||
class PosAuthApi {
|
|
||||||
PosAuthApi({required this.baseUrl, http.Client? client})
|
|
||||||
: _client = client ?? http.Client();
|
|
||||||
|
|
||||||
final String baseUrl;
|
|
||||||
final http.Client _client;
|
|
||||||
|
|
||||||
/// Generous, because this runs on a shop's connection while somebody watches.
|
|
||||||
/// Short enough that a dead endpoint is reported rather than hung on.
|
|
||||||
static const _timeout = Duration(seconds: 20);
|
|
||||||
|
|
||||||
/// Exchanges credentials for a session.
|
|
||||||
///
|
|
||||||
/// [locationId] is only meaningful for an account entitled to several
|
|
||||||
/// outlets: it says which one this terminal is standing in. It is a request,
|
|
||||||
/// not an assertion — the back office checks it against what the account may
|
|
||||||
/// actually reach, and that check is the whole point of the endpoint.
|
|
||||||
Future<PosSession> login({
|
|
||||||
required String authname,
|
|
||||||
required String password,
|
|
||||||
String? terminalId,
|
|
||||||
String? deviceId,
|
|
||||||
int? locationId,
|
|
||||||
int? configId,
|
|
||||||
}) async {
|
|
||||||
if (baseUrl.isEmpty) {
|
|
||||||
throw const PosAuthException(
|
|
||||||
'This terminal has no back office configured. Set the endpoint in '
|
|
||||||
'Settings → Connectivity & sync.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final body = <String, Object?>{
|
|
||||||
'authname': authname.trim(),
|
|
||||||
'password': password,
|
|
||||||
if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId,
|
|
||||||
if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId,
|
|
||||||
if (locationId != null && locationId > 0) 'location_id': locationId,
|
|
||||||
// Sent only when known. The backend infers it when absent, and a shop
|
|
||||||
// has no way to find out what its configid is.
|
|
||||||
if (configId != null && configId > 0) 'configid': configId,
|
|
||||||
};
|
|
||||||
|
|
||||||
final http.Response response;
|
|
||||||
try {
|
|
||||||
response = await _client
|
|
||||||
.post(
|
|
||||||
Uri.parse('$baseUrl/login'),
|
|
||||||
headers: const {'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode(body),
|
|
||||||
)
|
|
||||||
.timeout(_timeout);
|
|
||||||
} on TimeoutException {
|
|
||||||
throw const PosAuthException(
|
|
||||||
'The back office did not answer in time. Check the connection and try '
|
|
||||||
'again.',
|
|
||||||
);
|
|
||||||
} on Object {
|
|
||||||
throw const PosAuthException(
|
|
||||||
'Could not reach the back office. Check the connection and try again.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> decoded;
|
|
||||||
try {
|
|
||||||
decoded = jsonDecode(response.body) as Map<String, Object?>;
|
|
||||||
} on Object {
|
|
||||||
throw PosAuthException(
|
|
||||||
'The back office answered with something this terminal could not read '
|
|
||||||
'(HTTP ${response.statusCode}).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw PosAuthException(
|
|
||||||
(decoded['message'] as String?) ??
|
|
||||||
'Sign-in was refused (HTTP ${response.statusCode}).',
|
|
||||||
// 401 is a wrong email or password; 403 is a real account that may not
|
|
||||||
// open this till. Only the first is worth re-typing.
|
|
||||||
isCredentialFailure: response.statusCode == 401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final details = decoded['details'];
|
|
||||||
if (details is! Map<String, Object?>) {
|
|
||||||
throw const PosAuthException(
|
|
||||||
'The back office accepted the sign-in but returned no session.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = PosSession.fromJson(details);
|
|
||||||
|
|
||||||
// A session with no token cannot authenticate anything, and one with no
|
|
||||||
// outlet cannot bill. Refused here rather than being saved and failing
|
|
||||||
// later against every request, which would be much harder to diagnose.
|
|
||||||
if (session.token.isEmpty) {
|
|
||||||
throw const PosAuthException(
|
|
||||||
'The back office returned a session with no token.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (session.locationId <= 0) {
|
|
||||||
throw const PosAuthException(
|
|
||||||
'This account is not attached to an outlet, so it cannot open a till.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() => _client.close();
|
|
||||||
}
|
|
||||||
@@ -178,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
createdAt:
|
createdAt:
|
||||||
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
||||||
isSynced: (r['sync_status']! as int) == OrderDao.synced,
|
isSynced: (r['sync_status']! as int) == OrderDao.synced,
|
||||||
|
itemCount: (r['item_count'] as num?)?.toDouble() ?? 0,
|
||||||
syncedAt: r['synced_at'] == null
|
syncedAt: r['synced_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
/// What the back office answers a sign-in with.
|
|
||||||
///
|
|
||||||
/// Replaces the arrangement where a till held a store id typed into Settings
|
|
||||||
/// and a password compiled into the app. That made the store id a *claim*: any
|
|
||||||
/// terminal could name any outlet and be believed, so one leaked build reached
|
|
||||||
/// every tenant on the platform.
|
|
||||||
///
|
|
||||||
/// Now the outlet arrives *from* the back office as a consequence of who signed
|
|
||||||
/// in, sealed inside a signed token the terminal cannot edit. The till stops
|
|
||||||
/// deciding which shop it belongs to and starts being told.
|
|
||||||
class PosSession {
|
|
||||||
const PosSession({
|
|
||||||
required this.token,
|
|
||||||
required this.expiresAt,
|
|
||||||
required this.userId,
|
|
||||||
required this.fullName,
|
|
||||||
required this.roleId,
|
|
||||||
required this.tenantId,
|
|
||||||
required this.tenantName,
|
|
||||||
required this.storeId,
|
|
||||||
required this.locationId,
|
|
||||||
required this.locationName,
|
|
||||||
this.email = '',
|
|
||||||
this.gstin = '',
|
|
||||||
this.address = '',
|
|
||||||
this.phone = '',
|
|
||||||
this.outlets = const [],
|
|
||||||
this.staff = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The bearer token, sent on every request from here on.
|
|
||||||
///
|
|
||||||
/// Opaque on purpose. The terminal must not parse it, reason about it, or
|
|
||||||
/// trust anything it appears to say — its only correct use is to hand it back
|
|
||||||
/// and let the server decide what it means.
|
|
||||||
final String token;
|
|
||||||
|
|
||||||
final DateTime expiresAt;
|
|
||||||
|
|
||||||
final int userId;
|
|
||||||
final String fullName;
|
|
||||||
final String email;
|
|
||||||
final int roleId;
|
|
||||||
|
|
||||||
final int tenantId;
|
|
||||||
final String tenantName;
|
|
||||||
|
|
||||||
/// The outlet this terminal bills for, as a string because that is the shape
|
|
||||||
/// the sync configuration and every uplink already use.
|
|
||||||
final String storeId;
|
|
||||||
final int locationId;
|
|
||||||
final String locationName;
|
|
||||||
|
|
||||||
/// Printed on the invoice, so a legal requirement rather than decoration.
|
|
||||||
/// Arriving with the session means a shop that corrects its GSTIN in the back
|
|
||||||
/// office sees it on the next receipt instead of at the next rebuild.
|
|
||||||
final String gstin;
|
|
||||||
final String address;
|
|
||||||
final String phone;
|
|
||||||
|
|
||||||
/// Every outlet this account may open a till at.
|
|
||||||
///
|
|
||||||
/// A single-shop user gets a list of one, so the sign-in flow has no special
|
|
||||||
/// case: it offers a choice when there is one and skips it when there is not.
|
|
||||||
final List<PosOutlet> outlets;
|
|
||||||
|
|
||||||
bool get hasChoiceOfOutlet => outlets.length > 1;
|
|
||||||
|
|
||||||
/// The people the back office says may ring a bill here.
|
|
||||||
///
|
|
||||||
/// Very often empty. Only 116 of 596 accounts on the platform have a PIN set,
|
|
||||||
/// and most outlets — including the one this terminal ships pointed at — have
|
|
||||||
/// none at all. A till must treat that as an unfinished setup rather than as
|
|
||||||
/// a failure, which is why the seeded accounts still exist as a last resort.
|
|
||||||
final List<PosStaffMember> staff;
|
|
||||||
|
|
||||||
/// Whether the session is still worth sending.
|
|
||||||
///
|
|
||||||
/// Checked against the terminal's own clock, which is the only one available
|
|
||||||
/// offline. A till whose clock is wrong will re-authenticate unnecessarily —
|
|
||||||
/// annoying, and much better than billing a whole day against a session the
|
|
||||||
/// server has already stopped accepting.
|
|
||||||
bool isValidAt(DateTime now) => now.isBefore(expiresAt);
|
|
||||||
|
|
||||||
PosSession copyWith({
|
|
||||||
String? storeId,
|
|
||||||
int? locationId,
|
|
||||||
String? locationName,
|
|
||||||
String? address,
|
|
||||||
}) =>
|
|
||||||
PosSession(
|
|
||||||
token: token,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
userId: userId,
|
|
||||||
fullName: fullName,
|
|
||||||
email: email,
|
|
||||||
roleId: roleId,
|
|
||||||
tenantId: tenantId,
|
|
||||||
tenantName: tenantName,
|
|
||||||
storeId: storeId ?? this.storeId,
|
|
||||||
locationId: locationId ?? this.locationId,
|
|
||||||
locationName: locationName ?? this.locationName,
|
|
||||||
gstin: gstin,
|
|
||||||
address: address ?? this.address,
|
|
||||||
phone: phone,
|
|
||||||
outlets: outlets,
|
|
||||||
staff: staff,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory PosSession.fromJson(Map<String, Object?> json) {
|
|
||||||
final outlets = (json['locations'] as List<Object?>? ?? const [])
|
|
||||||
.whereType<Map<String, Object?>>()
|
|
||||||
.map(PosOutlet.fromJson)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return PosSession(
|
|
||||||
token: (json['token'] as String?) ?? '',
|
|
||||||
// A session with no readable expiry is treated as already finished rather
|
|
||||||
// than as never finishing. Guessing "valid" here would keep a till
|
|
||||||
// sending a token the server stopped honouring hours ago.
|
|
||||||
expiresAt: DateTime.tryParse((json['expires_at'] as String?) ?? '')
|
|
||||||
?.toLocal() ??
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(0),
|
|
||||||
userId: _int(json['user_id']),
|
|
||||||
fullName: (json['full_name'] as String?)?.trim() ?? '',
|
|
||||||
email: (json['email'] as String?) ?? '',
|
|
||||||
roleId: _int(json['role_id']),
|
|
||||||
tenantId: _int(json['tenant_id']),
|
|
||||||
tenantName: (json['tenant_name'] as String?) ?? '',
|
|
||||||
storeId: (json['store_id'] as String?) ?? '${_int(json['location_id'])}',
|
|
||||||
locationId: _int(json['location_id']),
|
|
||||||
locationName: (json['location_name'] as String?) ?? '',
|
|
||||||
gstin: (json['gstin'] as String?) ?? '',
|
|
||||||
address: (json['address'] as String?) ?? '',
|
|
||||||
phone: (json['phone'] as String?) ?? '',
|
|
||||||
outlets: outlets,
|
|
||||||
staff: (json['staff'] as List<Object?>? ?? const [])
|
|
||||||
.whereType<Map<String, Object?>>()
|
|
||||||
.map(PosStaffMember.fromJson)
|
|
||||||
.where((m) => m.pin.isNotEmpty)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'token': token,
|
|
||||||
'expires_at': expiresAt.toIso8601String(),
|
|
||||||
'user_id': userId,
|
|
||||||
'full_name': fullName,
|
|
||||||
'email': email,
|
|
||||||
'role_id': roleId,
|
|
||||||
'tenant_id': tenantId,
|
|
||||||
'tenant_name': tenantName,
|
|
||||||
'store_id': storeId,
|
|
||||||
'location_id': locationId,
|
|
||||||
'location_name': locationName,
|
|
||||||
'gstin': gstin,
|
|
||||||
'address': address,
|
|
||||||
'phone': phone,
|
|
||||||
'locations': outlets.map((o) => o.toJson()).toList(),
|
|
||||||
'staff': staff.map((m) => m.toJson()).toList(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One outlet a signed-in account may bill for.
|
|
||||||
class PosOutlet {
|
|
||||||
const PosOutlet({
|
|
||||||
required this.locationId,
|
|
||||||
required this.locationName,
|
|
||||||
this.address = '',
|
|
||||||
this.city = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
final int locationId;
|
|
||||||
final String locationName;
|
|
||||||
final String address;
|
|
||||||
final String city;
|
|
||||||
|
|
||||||
String get storeId => '$locationId';
|
|
||||||
|
|
||||||
factory PosOutlet.fromJson(Map<String, Object?> json) => PosOutlet(
|
|
||||||
locationId: _int(json['location_id']),
|
|
||||||
locationName: (json['location_name'] as String?) ?? '',
|
|
||||||
address: (json['address'] as String?) ?? '',
|
|
||||||
city: (json['city'] as String?) ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'location_id': locationId,
|
|
||||||
'location_name': locationName,
|
|
||||||
'address': address,
|
|
||||||
'city': city,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads an id that may arrive as a number or as a string.
|
|
||||||
///
|
|
||||||
/// The backend sends `location_id` as an int and `store_id` as a string for the
|
|
||||||
/// same value, and a till that accepted only one shape would silently read zero
|
|
||||||
/// for the other — which looks like "no outlet" rather than like a bug.
|
|
||||||
int _int(Object? value) => switch (value) {
|
|
||||||
final int v => v,
|
|
||||||
final num v => v.toInt(),
|
|
||||||
final String v => int.tryParse(v.trim()) ?? 0,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// One person the back office says may ring a bill at this outlet.
|
|
||||||
///
|
|
||||||
/// The PIN arrives in the clear over TLS and is hashed before it touches disk —
|
|
||||||
/// see [PosSession] and the backend's `PosStaffMember` for why hashing it
|
|
||||||
/// server-side would have bought the appearance of strength and not the
|
|
||||||
/// substance. A four-digit PIN is brute-forceable in microseconds regardless;
|
|
||||||
/// what it is, is *shift attribution* — which of the people already inside a
|
|
||||||
/// shop gets credited with a sale. The security boundary is the session token.
|
|
||||||
class PosStaffMember {
|
|
||||||
const PosStaffMember({
|
|
||||||
required this.userId,
|
|
||||||
required this.fullName,
|
|
||||||
required this.pin,
|
|
||||||
this.role = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
final int userId;
|
|
||||||
final String fullName;
|
|
||||||
final String pin;
|
|
||||||
|
|
||||||
/// The back office's own role name — "Admin", "Manager", "Operations". Blank
|
|
||||||
/// for the many accounts whose `roleid` is not in `app_roles` at all.
|
|
||||||
final String role;
|
|
||||||
|
|
||||||
/// A stable local id for a row that came from the back office.
|
|
||||||
///
|
|
||||||
/// Prefixed so an imported account can be told apart from one seeded on this
|
|
||||||
/// device. That distinction is what lets a sync retire the seeded logins
|
|
||||||
/// without touching anything a shop created itself.
|
|
||||||
String get localId => 'boffice-$userId';
|
|
||||||
|
|
||||||
factory PosStaffMember.fromJson(Map<String, Object?> json) =>
|
|
||||||
PosStaffMember(
|
|
||||||
userId: _int(json['user_id']),
|
|
||||||
fullName: ((json['full_name'] as String?) ?? '').trim(),
|
|
||||||
pin: ((json['pin'] as String?) ?? '').trim(),
|
|
||||||
role: ((json['role'] as String?) ?? '').trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'user_id': userId,
|
|
||||||
'full_name': fullName,
|
|
||||||
'pin': pin,
|
|
||||||
'role': role,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,7 @@ class OrderSyncRow {
|
|||||||
required this.total,
|
required this.total,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.isSynced,
|
required this.isSynced,
|
||||||
|
this.itemCount = 0,
|
||||||
this.syncedAt,
|
this.syncedAt,
|
||||||
this.attempts = 0,
|
this.attempts = 0,
|
||||||
this.error,
|
this.error,
|
||||||
@@ -49,6 +50,9 @@ class OrderSyncRow {
|
|||||||
final double total;
|
final double total;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final bool isSynced;
|
final bool isSynced;
|
||||||
|
|
||||||
|
/// Units on the bill. Fractional because loose goods are sold by weight.
|
||||||
|
final double itemCount;
|
||||||
final DateTime? syncedAt;
|
final DateTime? syncedAt;
|
||||||
final int attempts;
|
final int attempts;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/providers.dart';
|
import '../../../app/providers.dart';
|
||||||
import '../../../data/local/staff_dao.dart';
|
|
||||||
import '../../../data/remote/pos_auth_api.dart';
|
|
||||||
import '../../../domain/entities/pos_session.dart';
|
|
||||||
import '../../../domain/entities/store_account.dart';
|
import '../../../domain/entities/store_account.dart';
|
||||||
|
|
||||||
/// Sign-in state for the terminal.
|
/// Sign-in state for the terminal.
|
||||||
@@ -22,10 +19,23 @@ class Authenticating extends AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Authenticated extends AuthState {
|
class Authenticated extends AuthState {
|
||||||
const Authenticated({required this.store, required this.user});
|
const Authenticated({
|
||||||
|
required this.store,
|
||||||
|
required this.user,
|
||||||
|
required this.login,
|
||||||
|
});
|
||||||
|
|
||||||
final StoreAccount store;
|
final StoreAccount store;
|
||||||
final StaffUser user;
|
final StaffUser user;
|
||||||
|
|
||||||
|
/// Which credential opened this session. The authority on what the terminal
|
||||||
|
/// is allowed to show — not [user], which can be swapped at the till.
|
||||||
|
final TerminalLogin login;
|
||||||
|
|
||||||
|
StaffRole get role => login.role;
|
||||||
|
|
||||||
|
bool get isAdmin => login == TerminalLogin.admin;
|
||||||
|
bool get isCashier => login == TerminalLogin.cashier;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthFailure extends AuthState {
|
class AuthFailure extends AuthState {
|
||||||
@@ -34,150 +44,110 @@ class AuthFailure extends AuthState {
|
|||||||
final String message;
|
final String message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signs the terminal in against the back office and holds the session.
|
/// The two ways into this terminal.
|
||||||
///
|
///
|
||||||
/// This used to compare against two constants compiled into the app —
|
/// Store-level credentials, not a person's: they are replaced wholesale when
|
||||||
/// `admin@nearle.in` / `nearle123` — with a 600ms delay standing in for a
|
/// the terminal is registered against a real back office. Staff PINs — the
|
||||||
/// network call that was never made. Two things were wrong with that, and the
|
/// credential that actually opens a till drawer — are not here. They live
|
||||||
/// second was the serious one:
|
/// hashed in the database.
|
||||||
///
|
///
|
||||||
/// 1. every install of a build shared one password, and changing it meant a
|
/// The split is what the two roles are *for*, not decoration:
|
||||||
/// rebuild; and
|
|
||||||
/// 2. because nothing was checked with the back office, the *outlet* could not
|
|
||||||
/// come from the sign-in. It came from a store id typed into Settings — so
|
|
||||||
/// a till named its own shop and was believed, and one number changed on
|
|
||||||
/// one screen moved a terminal into another tenant's books.
|
|
||||||
///
|
///
|
||||||
/// Now a person signs in with their own back-office account, and the outlet
|
/// * [admin] runs the whole shell and is the only login that can pull the
|
||||||
/// arrives as a consequence: sealed in a signed token, checked server-side on
|
/// catalogue. Signing out leaves the products on the terminal.
|
||||||
/// every request, and not editable from this device.
|
/// * [cashier] gets the billing screen and nothing else, and signing out
|
||||||
|
/// takes the catalogue with it.
|
||||||
|
enum TerminalLogin {
|
||||||
|
admin(
|
||||||
|
label: 'Admin',
|
||||||
|
email: 'admin@nearle.in',
|
||||||
|
password: 'nearle123',
|
||||||
|
role: StaffRole.admin,
|
||||||
|
blurb: 'Full shell — import products, promos, settings.',
|
||||||
|
),
|
||||||
|
cashier(
|
||||||
|
label: 'Cashier',
|
||||||
|
email: 'cashier@nearle.in',
|
||||||
|
password: 'cashier123',
|
||||||
|
role: StaffRole.cashier,
|
||||||
|
blurb: 'Billing only, on the products the admin imported.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const TerminalLogin({
|
||||||
|
required this.label,
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
required this.role,
|
||||||
|
required this.blurb,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
final StaffRole role;
|
||||||
|
final String blurb;
|
||||||
|
|
||||||
|
/// The catalogue is pulled once by an admin and billed against by whoever is
|
||||||
|
/// on the counter, so only the cashier's sign-out drops it. An admin closing
|
||||||
|
/// the shell is a handover, not the end of the day.
|
||||||
|
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
|
||||||
|
|
||||||
|
static TerminalLogin? byEmail(String email) {
|
||||||
|
final normalised = email.trim().toLowerCase();
|
||||||
|
for (final login in TerminalLogin.values) {
|
||||||
|
if (login.email == normalised) return login;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kept for the store record, which is keyed on the outlet's own address.
|
||||||
|
class DemoCredentials {
|
||||||
|
const DemoCredentials._();
|
||||||
|
|
||||||
|
static const String email = 'admin@nearle.in';
|
||||||
|
static const String password = 'nearle123';
|
||||||
|
|
||||||
|
static const String cashierEmail = 'cashier@nearle.in';
|
||||||
|
static const String cashierPassword = 'cashier123';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates store credentials and holds the signed-in session.
|
||||||
class AuthController extends StateNotifier<AuthState> {
|
class AuthController extends StateNotifier<AuthState> {
|
||||||
AuthController(this._ref) : super(const Unauthenticated());
|
AuthController(this._ref) : super(const Unauthenticated());
|
||||||
|
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
/// The back office's answer to the last sign-in, if there is one.
|
/// Whether signing out right now would wipe the products off this terminal.
|
||||||
///
|
///
|
||||||
/// Held so the outlet picker can offer a proprietor their other shops without
|
/// Read *before* [signOut] by anything that needs to warn the operator, since
|
||||||
/// asking for the password a second time.
|
/// the session is gone by the time it returns.
|
||||||
PosSession? _session;
|
bool get clearsCatalogueOnSignOut {
|
||||||
PosSession? get session => _session;
|
final current = state;
|
||||||
|
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
|
||||||
/// Restores a session saved on a previous run.
|
|
||||||
///
|
|
||||||
/// Called at start-up so a till that was rebooted mid-shift comes back
|
|
||||||
/// trading rather than showing a login screen to a queue of customers.
|
|
||||||
/// Returns false when there is nothing usable, which includes an expired
|
|
||||||
/// session — [SessionStore] treats those as absent.
|
|
||||||
Future<bool> restore() async {
|
|
||||||
final saved = await _ref.read(sessionStoreProvider).read();
|
|
||||||
if (saved == null) return false;
|
|
||||||
|
|
||||||
await _adopt(saved);
|
|
||||||
return state is Authenticated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> signIn({
|
Future<bool> signIn({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
int? locationId,
|
|
||||||
}) async {
|
}) async {
|
||||||
state = const Authenticating();
|
state = const Authenticating();
|
||||||
|
|
||||||
final terminal = _ref.read(terminalIdentityProvider);
|
// Stand-in for the network round trip.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||||
|
|
||||||
final PosSession session;
|
final login = TerminalLogin.byEmail(email);
|
||||||
try {
|
|
||||||
session = await _ref.read(posAuthApiProvider).login(
|
if (login == null) {
|
||||||
authname: email,
|
state = const AuthFailure('No account is registered against that email.');
|
||||||
password: password,
|
|
||||||
terminalId: terminal.code,
|
|
||||||
deviceId: terminal.deviceId,
|
|
||||||
locationId: locationId,
|
|
||||||
);
|
|
||||||
} on PosAuthException catch (e) {
|
|
||||||
state = AuthFailure(e.message);
|
|
||||||
return false;
|
|
||||||
} on Object {
|
|
||||||
state = const AuthFailure(
|
|
||||||
'Sign-in failed for an unexpected reason. Please try again.',
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _ref.read(sessionStoreProvider).write(session);
|
if (password != login.password) {
|
||||||
await _adopt(session);
|
state = const AuthFailure('Incorrect password. Please try again.');
|
||||||
|
return false;
|
||||||
return state is Authenticated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves this terminal to another of the signed-in account's outlets.
|
|
||||||
///
|
|
||||||
/// A fresh sign-in rather than a local switch, because the outlet is inside
|
|
||||||
/// the signed token: the back office has to issue a new one, and re-checking
|
|
||||||
/// entitlement at that moment is the point. Requires the password again,
|
|
||||||
/// which is correct — moving a till between shops changes whose books it
|
|
||||||
/// writes to.
|
|
||||||
Future<bool> switchOutlet({
|
|
||||||
required String password,
|
|
||||||
required int locationId,
|
|
||||||
}) async {
|
|
||||||
final current = _session;
|
|
||||||
if (current == null) return false;
|
|
||||||
|
|
||||||
return signIn(
|
|
||||||
email: current.email.isNotEmpty ? current.email : current.fullName,
|
|
||||||
password: password,
|
|
||||||
locationId: locationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adopts a session: points the terminal at its outlet, then opens it.
|
|
||||||
///
|
|
||||||
/// Order matters. The store id and token are written *before* the catalogue
|
|
||||||
/// or any uplink can run, so a terminal can never spend even one request
|
|
||||||
/// pointed at the outlet it had yesterday while claiming to be signed in as
|
|
||||||
/// today's.
|
|
||||||
Future<void> _adopt(PosSession session) async {
|
|
||||||
_session = session;
|
|
||||||
|
|
||||||
await _ref.read(localStoreProvider).identityStore.rename(
|
|
||||||
storeId: session.storeId,
|
|
||||||
);
|
|
||||||
_ref.invalidate(terminalIdentityProvider);
|
|
||||||
|
|
||||||
_ref.read(syncConfigProvider.notifier).state =
|
|
||||||
_ref.read(syncConfigProvider).copyWith(
|
|
||||||
storeId: session.storeId,
|
|
||||||
sessionToken: session.token,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store details for the receipt come from the back office now, not from
|
|
||||||
// constants compiled into the build. A GSTIN is a legal requirement on a
|
|
||||||
// tax invoice; it should not need a rebuild to correct.
|
|
||||||
await _ref.read(storeRepositoryProvider).save(
|
|
||||||
name: session.locationName.isNotEmpty
|
|
||||||
? session.locationName
|
|
||||||
: session.tenantName,
|
|
||||||
address: session.address,
|
|
||||||
gstin: session.gstin,
|
|
||||||
phone: session.phone,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Who may ring a bill here, per the back office.
|
|
||||||
//
|
|
||||||
// This is what retires the seeded logins. The till ships with three names
|
|
||||||
// and three PINs compiled into it — the same three on every install — and
|
|
||||||
// they exist only so a shop whose back office has no staff recorded can
|
|
||||||
// still trade on day one. The moment real staff arrive they are
|
|
||||||
// deactivated, which is the whole point of importing rather than merging.
|
|
||||||
//
|
|
||||||
// Empty is the common case rather than an error: most outlets have nobody
|
|
||||||
// recorded, including the one this build ships pointed at. The import
|
|
||||||
// no-ops, the seeds survive, and the shop keeps selling.
|
|
||||||
await _importStaff(session);
|
|
||||||
|
|
||||||
_ref.invalidate(storeAccountProvider);
|
|
||||||
final store = await _ref.read(storeAccountProvider.future);
|
final store = await _ref.read(storeAccountProvider.future);
|
||||||
final staff = store.staff;
|
final staff = store.staff;
|
||||||
|
|
||||||
@@ -185,61 +155,29 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
state = const AuthFailure(
|
state = const AuthFailure(
|
||||||
'This terminal has no staff accounts. Reinstall to seed them.',
|
'This terminal has no staff accounts. Reinstall to seed them.',
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first admin, or whoever is there. A person switches to their own
|
// Whoever on this terminal matches the role that just signed in. Falls
|
||||||
// account at the till.
|
// back rather than failing: the session's permissions come from [login],
|
||||||
|
// so a shop with no cashier row still gets a usable till — the bills are
|
||||||
|
// just stamped with the account that is there.
|
||||||
final opener = staff.firstWhere(
|
final opener = staff.firstWhere(
|
||||||
(s) => s.role == StaffRole.admin,
|
(s) => s.role == login.role,
|
||||||
orElse: () => staff.first,
|
orElse: () => staff.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = Authenticated(store: store, user: opener);
|
state = Authenticated(store: store, user: opener, login: login);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the back office's staff over this terminal's.
|
|
||||||
///
|
|
||||||
/// Failures are swallowed. A shop must be able to open its till even when the
|
|
||||||
/// staff import fails — the seeded or previously-synced accounts are still
|
|
||||||
/// there, and refusing the sign-in would trade a working counter for a
|
|
||||||
/// tidier database.
|
|
||||||
Future<void> _importStaff(PosSession session) async {
|
|
||||||
if (session.staff.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _ref.read(localStoreProvider).staff.replaceFromBackOffice([
|
|
||||||
for (final member in session.staff)
|
|
||||||
StaffImportRecord(
|
|
||||||
localId: member.localId,
|
|
||||||
name: member.fullName,
|
|
||||||
role: _roleFor(member.role),
|
|
||||||
pin: member.pin,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
} on Object {
|
|
||||||
// Deliberately silent — see above.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps the back office's role names onto the till's three.
|
|
||||||
///
|
|
||||||
/// `app_roles` holds six rows for four distinct roles — Admin and Manager are
|
|
||||||
/// each in there twice — and most accounts carry a `roleid` that is not in
|
|
||||||
/// the table at all. So this matches on the name and falls back to the least
|
|
||||||
/// privileged answer: an unrecognised role must not silently become an admin.
|
|
||||||
StaffRole _roleFor(String backOfficeRole) =>
|
|
||||||
switch (backOfficeRole.trim().toLowerCase()) {
|
|
||||||
'super admin' || 'admin' => StaffRole.admin,
|
|
||||||
'manager' || 'operations' => StaffRole.manager,
|
|
||||||
_ => StaffRole.cashier,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Switches the active operator, checking their PIN.
|
/// Switches the active operator, checking their PIN.
|
||||||
///
|
///
|
||||||
/// Every bill is stamped with whoever is active, so this is the boundary that
|
/// Every bill is stamped with whoever is active, so this is the boundary that
|
||||||
/// decides who a sale is attributed to — it cannot be a bare selection from a
|
/// decides who a sale is attributed to — it cannot be a bare selection from a
|
||||||
/// list.
|
/// list. It changes who the bill names, never what the session may open:
|
||||||
|
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
|
||||||
|
/// terminal.
|
||||||
Future<bool> switchUser(String pin) async {
|
Future<bool> switchUser(String pin) async {
|
||||||
final current = state;
|
final current = state;
|
||||||
if (current is! Authenticated) return false;
|
if (current is! Authenticated) return false;
|
||||||
@@ -248,7 +186,11 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
final user = await store.staff.authenticate(pin);
|
final user = await store.staff.authenticate(pin);
|
||||||
if (user == null) return false;
|
if (user == null) return false;
|
||||||
|
|
||||||
state = Authenticated(store: current.store, user: user);
|
state = Authenticated(
|
||||||
|
store: current.store,
|
||||||
|
user: user,
|
||||||
|
login: current.login,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,25 +208,25 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
// Signed out if the active operator was just deactivated — carrying on
|
// Signed out if the active operator was just deactivated — carrying on
|
||||||
// would keep stamping bills with an account the shop has revoked.
|
// would keep stamping bills with an account the shop has revoked.
|
||||||
user: me.isEmpty ? store.staff.first : me.first,
|
user: me.isEmpty ? store.staff.first : me.first,
|
||||||
|
login: current.login,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The next shift should only ever bill against what the back office
|
/// Ends the session, and — for a cashier only — the catalogue with it.
|
||||||
/// answers with, never a catalogue instance carried over from this
|
|
||||||
/// session — so the local product table is dropped before the session
|
|
||||||
/// itself is.
|
|
||||||
///
|
///
|
||||||
/// The token goes with it, from the keystore and from the live configuration
|
/// Every way out of a cashier session clears the products: ending a shift,
|
||||||
/// both. Leaving it in place would let a signed-out terminal keep uploading
|
/// and a temporary logout alike. There is no exception for stepping away for
|
||||||
/// as the shop that signed in this morning.
|
/// ten minutes, because the terminal is left unattended either way and the
|
||||||
|
/// next session should bill against what the back office answers with rather
|
||||||
|
/// than a catalogue carried over.
|
||||||
|
///
|
||||||
|
/// An admin signing out is the opposite case. They have just pulled the
|
||||||
|
/// products *so that* a cashier can pick the terminal up, so dropping the
|
||||||
|
/// table here would make the import pointless.
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
|
if (clearsCatalogueOnSignOut) {
|
||||||
await _ref.read(localStoreProvider).clearCatalogue();
|
await _ref.read(localStoreProvider).clearCatalogue();
|
||||||
await _ref.read(sessionStoreProvider).clear();
|
}
|
||||||
|
|
||||||
_ref.read(syncConfigProvider.notifier).state =
|
|
||||||
_ref.read(syncConfigProvider).copyWith(sessionToken: '');
|
|
||||||
|
|
||||||
_session = null;
|
|
||||||
state = const Unauthenticated();
|
state = const Unauthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +251,24 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
|
|||||||
return s is Authenticated ? s.user : null;
|
return s is Authenticated ? s.user : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Which credential is holding this session open, or null before sign-in.
|
||||||
|
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
|
||||||
|
final s = ref.watch(authControllerProvider);
|
||||||
|
return s is Authenticated ? s.login : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True when the terminal is locked down to the billing screen.
|
||||||
|
///
|
||||||
|
/// The one flag the shell reads: no sidebar, no back-office modules, sign-out
|
||||||
|
/// and events promoted to the header.
|
||||||
|
final isCashierModeProvider = Provider<bool>(
|
||||||
|
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier,
|
||||||
|
);
|
||||||
|
|
||||||
|
final isAdminModeProvider = Provider<bool>(
|
||||||
|
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin,
|
||||||
|
);
|
||||||
|
|
||||||
/// True while anyone is still on a seeded or admin-reset PIN.
|
/// True while anyone is still on a seeded or admin-reset PIN.
|
||||||
final mustChangePinProvider = Provider<bool>((ref) {
|
final mustChangePinProvider = Provider<bool>((ref) {
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -8,11 +10,18 @@ import '../../../core/router/app_router.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/validators.dart';
|
import '../../../core/utils/validators.dart';
|
||||||
|
import '../../../core/widgets/brand_mark.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../domain/entities/pos_session.dart';
|
|
||||||
import '../providers/auth_controller.dart';
|
import '../providers/auth_controller.dart';
|
||||||
|
|
||||||
/// Store sign-in. The terminal shows this until a valid account is entered.
|
/// Store sign-in. The terminal shows this until a valid account is entered.
|
||||||
|
///
|
||||||
|
/// One centred card on a plain background, at every width. The split-screen
|
||||||
|
/// version put a marketing panel beside the form, which meant the thing the
|
||||||
|
/// person came here to use was never in the middle of the screen, was a
|
||||||
|
/// different width on every monitor, and collapsed into a different layout
|
||||||
|
/// below 1000px. A till is signed into at the start of a shift by someone who
|
||||||
|
/// already bought the product; the pitch was costing the form its position.
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
@@ -22,12 +31,28 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _email = TextEditingController();
|
|
||||||
final _password = TextEditingController();
|
/// Which of the two default accounts the tabs are pointing at. Only a
|
||||||
|
/// convenience for filling the fields — [AuthController.signIn] decides the
|
||||||
|
/// role from the email that is actually submitted, so typing a different
|
||||||
|
/// address over the top still signs in as that account.
|
||||||
|
TerminalLogin _login = TerminalLogin.admin;
|
||||||
|
|
||||||
|
late final _email = TextEditingController(text: _login.email);
|
||||||
|
late final _password = TextEditingController(text: _login.password);
|
||||||
|
|
||||||
bool _obscure = true;
|
bool _obscure = true;
|
||||||
bool _rememberTerminal = true;
|
bool _rememberTerminal = true;
|
||||||
|
|
||||||
|
void _selectLogin(TerminalLogin login) {
|
||||||
|
setState(() {
|
||||||
|
_login = login;
|
||||||
|
_email.text = login.email;
|
||||||
|
_password.text = login.password;
|
||||||
|
});
|
||||||
|
ref.read(authControllerProvider.notifier).clearError();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_email.dispose();
|
_email.dispose();
|
||||||
@@ -39,233 +64,149 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
|
||||||
final auth = ref.read(authControllerProvider.notifier);
|
final ok = await ref.read(authControllerProvider.notifier).signIn(
|
||||||
|
|
||||||
var ok = await auth.signIn(
|
|
||||||
email: _email.text,
|
email: _email.text,
|
||||||
password: _password.text,
|
password: _password.text,
|
||||||
);
|
);
|
||||||
if (!ok || !mounted) return;
|
|
||||||
|
|
||||||
// A proprietor with several shops signs in once and then says which counter
|
if (ok && mounted) context.go(AppRoutes.pos);
|
||||||
// this terminal is standing at. The back office has already decided which
|
|
||||||
// outlets they may reach, so this is a choice among those — never a free
|
|
||||||
// text field, which is what the old Settings store id amounted to.
|
|
||||||
final session = auth.session;
|
|
||||||
if (session != null && session.hasChoiceOfOutlet) {
|
|
||||||
final chosen = await showDialog<PosOutlet>(
|
|
||||||
context: context,
|
|
||||||
// Not dismissable: a terminal has to be standing somewhere, and
|
|
||||||
// defaulting silently to the first outlet is how a day's takings end up
|
|
||||||
// filed against the wrong shop.
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (_) => _OutletPicker(session: session),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (chosen == null || !mounted) return;
|
|
||||||
|
|
||||||
if (chosen.locationId != session.locationId) {
|
|
||||||
ok = await auth.signIn(
|
|
||||||
email: _email.text,
|
|
||||||
password: _password.text,
|
|
||||||
locationId: chosen.locationId,
|
|
||||||
);
|
|
||||||
if (!ok || !mounted) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) context.go(AppRoutes.pos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
final auth = ref.watch(authControllerProvider);
|
||||||
backgroundColor: AppColors.background,
|
final session = ref.watch(cashierSessionProvider);
|
||||||
body: LayoutBuilder(
|
final busy = auth is Authenticating;
|
||||||
builder: (context, constraints) {
|
|
||||||
// Below this there isn't room for the brand panel beside the form.
|
|
||||||
final showBrandPanel = constraints.maxWidth >= 1000;
|
|
||||||
|
|
||||||
return Row(
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (showBrandPanel)
|
// Background image
|
||||||
const Expanded(flex: 5, child: _BrandPanel()),
|
Image.asset(
|
||||||
Expanded(
|
'assets/images/bg.webp',
|
||||||
flex: 4,
|
fit: BoxFit.cover,
|
||||||
child: _FormPanel(
|
),
|
||||||
|
|
||||||
|
// Light blur over the image
|
||||||
|
|
||||||
|
|
||||||
|
SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, box) {
|
||||||
|
final tight = box.maxHeight < 620;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.xl,
|
||||||
|
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
// Fills the viewport so the card is centred vertically, and
|
||||||
|
// scrolls the moment it cannot be.
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: (box.maxHeight - (tight ? 40 : 64))
|
||||||
|
.clamp(0.0, double.infinity),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_Masthead(tight: tight),
|
||||||
|
SizedBox(
|
||||||
|
height: tight ? AppSpacing.lg : AppSpacing.xl,),
|
||||||
|
_Card(
|
||||||
formKey: _formKey,
|
formKey: _formKey,
|
||||||
email: _email,
|
email: _email,
|
||||||
password: _password,
|
password: _password,
|
||||||
obscure: _obscure,
|
obscure: _obscure,
|
||||||
rememberTerminal: _rememberTerminal,
|
rememberTerminal: _rememberTerminal,
|
||||||
showCompactLogo: !showBrandPanel,
|
login: _login,
|
||||||
onToggleObscure: () => setState(() => _obscure = !_obscure),
|
busy: busy,
|
||||||
|
failure: auth is AuthFailure ? auth.message : null,
|
||||||
|
onSelectLogin: _selectLogin,
|
||||||
|
onToggleObscure: () =>
|
||||||
|
setState(() => _obscure = !_obscure),
|
||||||
onToggleRemember: (v) =>
|
onToggleRemember: (v) =>
|
||||||
setState(() => _rememberTerminal = v ?? true),
|
setState(() => _rememberTerminal = v ?? true),
|
||||||
onSubmit: _submit,
|
onSubmit: _submit,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'Terminal ${session.terminalId}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BrandPanel extends StatelessWidget {
|
/// Mark and product name, directly above the card.
|
||||||
const _BrandPanel();
|
class _Masthead extends StatelessWidget {
|
||||||
|
const _Masthead({required this.tight});
|
||||||
|
|
||||||
|
final bool tight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Column(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.primaryGradient),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.giant),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// BrandMark(size: tight ? 48 : 60),
|
||||||
children: [
|
const SizedBox(height: AppSpacing.md),
|
||||||
Container(
|
const Text(
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
const Flexible(
|
|
||||||
child: Text(
|
|
||||||
'Nearle POS',
|
'Nearle POS',
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
fontSize: 20,
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.4,
|
letterSpacing: -0.4,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.giant),
|
|
||||||
const Text(
|
|
||||||
'Billing that keeps up\nwith your counter.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 34,
|
height: 1.2,
|
||||||
height: 1.25,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
Text(
|
|
||||||
'Scanner-first billing, GST-ready invoices and loyalty '
|
|
||||||
'built in — for supermarkets, pharmacies and retail.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.78),
|
|
||||||
fontSize: 15,
|
|
||||||
height: 1.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.giant),
|
|
||||||
const _Feature(
|
|
||||||
icon: Icons.qr_code_scanner_rounded,
|
|
||||||
title: 'Scan and go',
|
|
||||||
body: 'No dialogs between items. Barcode to bill instantly.',
|
|
||||||
),
|
|
||||||
const _Feature(
|
|
||||||
icon: Icons.receipt_long_rounded,
|
|
||||||
title: 'GST compliant',
|
|
||||||
body: 'Per-slab tax split into CGST and SGST on every bill.',
|
|
||||||
),
|
|
||||||
const _Feature(
|
|
||||||
icon: Icons.stars_rounded,
|
|
||||||
title: 'Loyalty that runs itself',
|
|
||||||
body: 'Tiers and points applied without cashier input.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).animate().fadeIn(duration: 300.ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Feature extends StatelessWidget {
|
|
||||||
const _Feature({
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.body,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String body;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.16),
|
|
||||||
borderRadius: AppRadius.brSm,
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: Colors.white, size: 19),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.lg),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!tight) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
const Text(
|
||||||
body,
|
'Scanner-first billing for Indian retail',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.72),
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
height: 1.5,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
).animate().fadeIn(duration: 260.ms);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FormPanel extends ConsumerWidget {
|
class _Card extends StatelessWidget {
|
||||||
const _FormPanel({
|
const _Card({
|
||||||
required this.formKey,
|
required this.formKey,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.obscure,
|
required this.obscure,
|
||||||
required this.rememberTerminal,
|
required this.rememberTerminal,
|
||||||
required this.showCompactLogo,
|
required this.login,
|
||||||
|
required this.busy,
|
||||||
|
required this.failure,
|
||||||
|
required this.onSelectLogin,
|
||||||
required this.onToggleObscure,
|
required this.onToggleObscure,
|
||||||
required this.onToggleRemember,
|
required this.onToggleRemember,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
@@ -276,23 +217,30 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
final TextEditingController password;
|
final TextEditingController password;
|
||||||
final bool obscure;
|
final bool obscure;
|
||||||
final bool rememberTerminal;
|
final bool rememberTerminal;
|
||||||
final bool showCompactLogo;
|
final TerminalLogin login;
|
||||||
|
final bool busy;
|
||||||
|
final String? failure;
|
||||||
|
final ValueChanged<TerminalLogin> onSelectLogin;
|
||||||
final VoidCallback onToggleObscure;
|
final VoidCallback onToggleObscure;
|
||||||
final ValueChanged<bool?> onToggleRemember;
|
final ValueChanged<bool?> onToggleRemember;
|
||||||
final VoidCallback onSubmit;
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final auth = ref.watch(authControllerProvider);
|
return Container(
|
||||||
final session = ref.watch(cashierSessionProvider);
|
|
||||||
final busy = auth is Authenticating;
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
child: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||||
child: ConstrainedBox(
|
decoration: BoxDecoration(
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
color: AppColors.surface,
|
||||||
|
borderRadius: AppRadius.brXl,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x0F101828),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
@@ -300,44 +248,41 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (showCompactLogo) ...[
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: const Text(
|
|
||||||
'N',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
|
||||||
],
|
|
||||||
|
|
||||||
Text(
|
|
||||||
'Sign in to your store',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xs),
|
|
||||||
const Text(
|
const Text(
|
||||||
'Use the credentials issued when your outlet was '
|
'Sign in to your store',
|
||||||
'registered.',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.5,
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
const Text(
|
||||||
|
'Use the credentials issued when your outlet was registered.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxxl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
|
_RoleSwitch(
|
||||||
|
selected: login,
|
||||||
|
enabled: !busy,
|
||||||
|
onSelect: onSelectLogin,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
login.blurb,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
const _Label('Store email'),
|
const _Label('Store email'),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -364,9 +309,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
onFieldSubmitted: (_) => onSubmit(),
|
onFieldSubmitted: (_) => onSubmit(),
|
||||||
validator: (v) => (v ?? '').isEmpty
|
validator: (v) => (v ?? '').isEmpty
|
||||||
? 'Password is required'
|
? 'Password is required'
|
||||||
: ((v ?? '').length < 6
|
: ((v ?? '').length < 6 ? 'Password looks too short' : null),
|
||||||
? 'Password looks too short'
|
|
||||||
: null),
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter your password',
|
hintText: 'Enter your password',
|
||||||
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
||||||
@@ -390,9 +333,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: busy
|
onTap: busy ? null : () => onToggleRemember(!rememberTerminal),
|
||||||
? null
|
|
||||||
: () => onToggleRemember(!rememberTerminal),
|
|
||||||
borderRadius: AppRadius.brXs,
|
borderRadius: AppRadius.brXs,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
@@ -427,7 +368,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
if (auth is AuthFailure) ...[
|
if (failure != null) ...[
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
@@ -442,7 +383,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
auth.message,
|
failure!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -454,7 +395,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
).animate().shake(duration: 320.ms, hz: 3),
|
).animate().shake(duration: 320.ms, hz: 3),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: 'Sign in',
|
label: 'Sign in',
|
||||||
icon: Icons.login_rounded,
|
icon: Icons.login_rounded,
|
||||||
@@ -463,24 +404,15 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
onPressed: onSubmit,
|
onPressed: onSubmit,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
_DemoHint(
|
||||||
Center(
|
login: login,
|
||||||
child: Text(
|
onFill: busy ? null : () => onSelectLogin(login),
|
||||||
'Terminal ${session.terminalId}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11.5,
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.02, end: 0);
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,100 +437,167 @@ class _Label extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks which of the signed-in account's outlets this terminal is standing in.
|
/// Which of the two default accounts is being signed into.
|
||||||
///
|
///
|
||||||
/// Only shown when there is genuinely a choice. A shop manager pinned to one
|
/// The roles are not cosmetic — they decide whether the terminal opens the
|
||||||
/// location never sees it, which is the common case — this is for a proprietor
|
/// full shell or the billing screen alone, and whether signing out leaves the
|
||||||
/// whose account reaches several shops.
|
/// products behind — so the choice is made before the credentials rather than
|
||||||
///
|
/// inferred from them afterwards.
|
||||||
/// The list comes from the back office and cannot be typed into. That is the
|
class _RoleSwitch extends StatelessWidget {
|
||||||
/// whole difference from what this replaced: the outlet used to be a store id
|
const _RoleSwitch({
|
||||||
/// entered in Settings, so a till asserted which shop it belonged to. Here it
|
required this.selected,
|
||||||
/// picks from what the account is already entitled to, and the choice is
|
required this.enabled,
|
||||||
/// re-checked server-side when the new session is issued.
|
required this.onSelect,
|
||||||
class _OutletPicker extends StatelessWidget {
|
});
|
||||||
const _OutletPicker({required this.session});
|
|
||||||
|
|
||||||
final PosSession session;
|
final TerminalLogin selected;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<TerminalLogin> onSelect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return Container(
|
||||||
backgroundColor: AppColors.surface,
|
padding: const EdgeInsets.all(4),
|
||||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
decoration: BoxDecoration(
|
||||||
title: const Text(
|
color: AppColors.surfaceAlt,
|
||||||
'Which outlet is this terminal at?',
|
|
||||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 380,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
|
||||||
child: Text(
|
|
||||||
'Signed in as ${session.fullName.isNotEmpty ? session.fullName : session.email}'
|
|
||||||
' · ${session.tenantName}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12.5,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: ListView.separated(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: session.outlets.length,
|
|
||||||
separatorBuilder: (_, __) =>
|
|
||||||
const SizedBox(height: AppSpacing.xs),
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final outlet = session.outlets[i];
|
|
||||||
final isCurrent = outlet.locationId == session.locationId;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: AppRadius.brSm,
|
borderRadius: AppRadius.brSm,
|
||||||
side: BorderSide(
|
border: Border.all(color: AppColors.border),
|
||||||
color: isCurrent
|
),
|
||||||
? AppColors.primaryBorder
|
child: Row(
|
||||||
: AppColors.border,
|
children: [
|
||||||
|
for (final login in TerminalLogin.values)
|
||||||
|
Expanded(
|
||||||
|
child: _RoleTab(
|
||||||
|
login: login,
|
||||||
|
selected: login == selected,
|
||||||
|
enabled: enabled,
|
||||||
|
onTap: () => onSelect(login),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tileColor:
|
],
|
||||||
isCurrent ? AppColors.primarySurface : AppColors.surface,
|
|
||||||
title: Text(
|
|
||||||
outlet.locationName.isNotEmpty
|
|
||||||
? outlet.locationName
|
|
||||||
: 'Outlet ${outlet.locationId}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// The numeric id is shown deliberately. It is what appears
|
|
||||||
// in the back office, on a support call and in every log
|
|
||||||
// line, so a person can match what they are looking at.
|
|
||||||
subtitle: Text(
|
|
||||||
[
|
|
||||||
'ID ${outlet.locationId}',
|
|
||||||
if (outlet.city.isNotEmpty) outlet.city,
|
|
||||||
].join(' · '),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11.5,
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () => Navigator.of(context).pop(outlet),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoleTab extends StatelessWidget {
|
||||||
|
const _RoleTab({
|
||||||
|
required this.login,
|
||||||
|
required this.selected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TerminalLogin login;
|
||||||
|
final bool selected;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final icon = login == TerminalLogin.admin
|
||||||
|
? Icons.admin_panel_settings_outlined
|
||||||
|
: Icons.point_of_sale_rounded;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
borderRadius: AppRadius.brXs,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppColors.surface : Colors.transparent,
|
||||||
|
borderRadius: AppRadius.brXs,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.primaryBorder : Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 17,
|
||||||
|
color: selected ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
login.label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
color:
|
||||||
|
selected ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DemoHint extends StatelessWidget {
|
||||||
|
const _DemoHint({required this.login, this.onFill});
|
||||||
|
|
||||||
|
final TerminalLogin login;
|
||||||
|
final VoidCallback? onFill;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primarySurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
border: Border.all(color: AppColors.primaryBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline_rounded,
|
||||||
|
size: 17, color: AppColors.primary,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Default ${login.label.toLowerCase()} account',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
SelectableText(
|
||||||
|
'${login.email} \u00b7 ${login.password}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onFill,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 32),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||||
|
),
|
||||||
|
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,18 +6,22 @@ import '../../../app/providers.dart';
|
|||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
import '../../../core/widgets/numeric_keypad.dart';
|
import '../../../core/widgets/numeric_keypad.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../core/widgets/status_pill.dart';
|
|
||||||
import '../../../domain/entities/customer.dart';
|
import '../../../domain/entities/customer.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
import '../providers/customer_providers.dart';
|
|
||||||
|
|
||||||
/// Attaches a customer to the current bill using nothing but a mobile number.
|
/// Attaches a customer to the current bill: a mobile number, a name, or
|
||||||
|
/// neither.
|
||||||
///
|
///
|
||||||
/// Registration is deliberately minimal: an unknown number can be saved with
|
/// Deliberately not a lookup screen. There is no search against the customers
|
||||||
/// just a name, or the whole step skipped. Nothing here blocks the sale.
|
/// already on the terminal, no recent list, and no membership tier — a cashier
|
||||||
|
/// at a queue keys the number, keys the name if the shopper gives one, and
|
||||||
|
/// carries on. A number that turns out to be registered already is reused
|
||||||
|
/// silently rather than being turned into a decision the counter has to make.
|
||||||
|
///
|
||||||
|
/// Nothing here blocks the sale: Skip closes it with a walk-in bill.
|
||||||
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -35,13 +39,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget {
|
|||||||
_CustomerCaptureSheetState();
|
_CustomerCaptureSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomerCaptureSheetState
|
class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
|
||||||
extends ConsumerState<_CustomerCaptureSheet> {
|
|
||||||
String _digits = '';
|
String _digits = '';
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_name.dispose();
|
_name.dispose();
|
||||||
@@ -54,15 +59,14 @@ class _CustomerCaptureSheetState
|
|||||||
_digits += d;
|
_digits += d;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
if (_digits.length == AppConstants.mobileNumberLength) {
|
|
||||||
ref.read(customerLookupProvider.notifier).search(_digits);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _backspace() {
|
void _backspace() {
|
||||||
if (_digits.isEmpty) return;
|
if (_digits.isEmpty) return;
|
||||||
setState(() => _digits = _digits.substring(0, _digits.length - 1));
|
setState(() {
|
||||||
ref.read(customerLookupProvider.notifier).reset();
|
_digits = _digits.substring(0, _digits.length - 1);
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clear() {
|
void _clear() {
|
||||||
@@ -70,7 +74,6 @@ class _CustomerCaptureSheetState
|
|||||||
_digits = '';
|
_digits = '';
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
ref.read(customerLookupProvider.notifier).reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _attachAndClose(Customer? customer) {
|
void _attachAndClose(Customer? customer) {
|
||||||
@@ -78,9 +81,23 @@ class _CustomerCaptureSheetState
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves with whatever was given. Both fields are optional: a bare number
|
/// Saves the number under the name given, then puts it on the bill.
|
||||||
/// is still worth keeping, because it is what the WhatsApp bill is sent to.
|
///
|
||||||
Future<void> _quickRegister() async {
|
/// The name is optional — a bare number is still worth keeping, because it
|
||||||
|
/// is what the WhatsApp bill is sent to.
|
||||||
|
///
|
||||||
|
/// A number already on file is the ordinary case, not an error: the existing
|
||||||
|
/// record is picked up and attached, and a freshly typed name is written
|
||||||
|
/// over the stored one so a correction at the counter sticks. The cashier
|
||||||
|
/// sees the same thing either way, which is the point of not having a lookup
|
||||||
|
/// step.
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_complete) {
|
||||||
|
setState(() => _error = 'Enter all '
|
||||||
|
'${AppConstants.mobileNumberLength} digits of the mobile number.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final typed = _name.text.trim();
|
final typed = _name.text.trim();
|
||||||
final name = typed.isEmpty
|
final name = typed.isEmpty
|
||||||
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
||||||
@@ -91,24 +108,43 @@ class _CustomerCaptureSheetState
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final repo = ref.read(customerRepositoryProvider);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final created = await ref.read(customerRepositoryProvider).create(
|
final created = await repo.create(
|
||||||
Customer(id: '', name: name, mobile: _digits),
|
Customer(id: '', name: name, mobile: _digits),
|
||||||
);
|
);
|
||||||
ref.invalidate(recentCustomersProvider);
|
|
||||||
if (mounted) _attachAndClose(created);
|
if (mounted) _attachAndClose(created);
|
||||||
} catch (e) {
|
} on StateError {
|
||||||
|
// Already registered. Reuse the row rather than making the counter
|
||||||
|
// reconcile it.
|
||||||
|
try {
|
||||||
|
final existing = await repo.findByMobile(_digits);
|
||||||
|
if (existing == null) throw StateError('lookup failed');
|
||||||
|
|
||||||
|
final updated = typed.isEmpty || typed == existing.name
|
||||||
|
? existing
|
||||||
|
: await repo.update(existing.copyWith(name: typed));
|
||||||
|
|
||||||
|
if (mounted) _attachAndClose(updated);
|
||||||
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_saving = false;
|
_saving = false;
|
||||||
_error = e is StateError ? e.message : 'Could not save customer.';
|
_error = 'Could not save customer. Try again.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
_error = 'Could not save customer. Try again.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final lookup = ref.watch(customerLookupProvider);
|
|
||||||
final attached = ref.watch(
|
final attached = ref.watch(
|
||||||
cartControllerProvider.select((c) => c.customer),
|
cartControllerProvider.select((c) => c.customer),
|
||||||
);
|
);
|
||||||
@@ -130,33 +166,49 @@ class _CustomerCaptureSheetState
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_grabber(),
|
_grabber(),
|
||||||
|
// Everything inside is capped and centred. A modal sheet on a
|
||||||
|
// 27-inch till used to run the full width of the screen, which
|
||||||
|
// put the keypad and the fields at opposite ends of the desk.
|
||||||
|
Flexible(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 900),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
_header(attached),
|
_header(attached),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.xl,
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.xxl,
|
||||||
|
),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, box) {
|
||||||
// Side by side once there is room for both columns.
|
// Side by side once there is room for both.
|
||||||
final wide = constraints.maxWidth >= 720;
|
final wide = box.maxWidth >= 660;
|
||||||
final entry = _entryColumn();
|
final entry = _entryColumn();
|
||||||
final result = _resultColumn(lookup);
|
final details = _detailsColumn();
|
||||||
|
|
||||||
if (!wide) {
|
if (!wide) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
entry,
|
entry,
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
result,
|
details,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: entry),
|
SizedBox(width: 300, child: entry),
|
||||||
const SizedBox(width: AppSpacing.xxl),
|
const SizedBox(width: AppSpacing.xxl),
|
||||||
Expanded(child: result),
|
Expanded(child: details),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -167,6 +219,11 @@ class _CustomerCaptureSheetState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +241,23 @@ class _CustomerCaptureSheetState
|
|||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.xxl,
|
AppSpacing.xxl,
|
||||||
0,
|
0,
|
||||||
AppSpacing.md,
|
AppSpacing.lg,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primarySurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
border: Border.all(color: AppColors.primaryBorder),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.person_add_alt_1_outlined,
|
||||||
|
size: 20, color: AppColors.primary,),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -197,10 +266,16 @@ class _CustomerCaptureSheetState
|
|||||||
Text(
|
Text(
|
||||||
attached == null ? 'Add customer' : 'Change customer',
|
attached == null ? 'Add customer' : 'Change customer',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Text(
|
const Text(
|
||||||
'Optional — for loyalty points and tier discounts',
|
'Optional — number and name, or skip',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
@@ -212,30 +287,45 @@ class _CustomerCaptureSheetState
|
|||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _attachAndClose(null),
|
onPressed: _saving ? null : () => _attachAndClose(null),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: AppColors.textSecondary,
|
foregroundColor: AppColors.textSecondary,
|
||||||
|
minimumSize: const Size(0, 38),
|
||||||
),
|
),
|
||||||
child: const Text('Skip'),
|
child: const Text('Skip'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
icon: const Icon(Icons.close_rounded),
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
color: AppColors.textTertiary,
|
||||||
tooltip: 'Close',
|
tooltip: 'Close',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Entry
|
||||||
Widget _entryColumn() => Column(
|
Widget _entryColumn() => Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_display(),
|
_display(),
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
_complete
|
||||||
|
? 'Number complete'
|
||||||
|
: '${AppConstants.mobileNumberLength - _digits.length} more '
|
||||||
|
'digit(s)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
Center(
|
Center(
|
||||||
child: NumericKeypad(
|
child: NumericKeypad(
|
||||||
maxWidth: 340,
|
maxWidth: 300,
|
||||||
onKey: _append,
|
onKey: _append,
|
||||||
onBackspace: _backspace,
|
onBackspace: _backspace,
|
||||||
onClear: _clear,
|
onClear: _clear,
|
||||||
@@ -244,237 +334,96 @@ class _CustomerCaptureSheetState
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _display() => Container(
|
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
|
||||||
height: 68,
|
Widget _display() {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
final filled = _digits.isNotEmpty;
|
||||||
|
final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5);
|
||||||
|
final tail = _digits.length <= 5 ? '' : _digits.substring(5);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 64,
|
||||||
|
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primarySurface,
|
color: filled ? AppColors.surface : AppColors.surfaceAlt,
|
||||||
borderRadius: AppRadius.brLg,
|
borderRadius: AppRadius.brLg,
|
||||||
border: Border.all(color: AppColors.primaryBorder),
|
border: Border.all(
|
||||||
|
color: filled ? AppColors.primary : AppColors.border,
|
||||||
|
width: filled ? 1.4 : 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.sm,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surfaceAlt,
|
||||||
|
borderRadius: AppRadius.brXs,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
'+91',
|
'+91',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
// FittedBox guarantees ten digits fit at any sheet width.
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: filled
|
||||||
_digits.isEmpty
|
? Text(
|
||||||
? '– – – – – – – – – –'
|
tail.isEmpty ? head : '$head $tail',
|
||||||
: _digits.split('').join(' '),
|
style: AppTypography.money(23).copyWith(
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Mobile number',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
color: AppColors.textTertiary.withValues(alpha: 0.9),
|
||||||
letterSpacing: 1,
|
|
||||||
color: _digits.isEmpty
|
|
||||||
? AppColors.textTertiary
|
|
||||||
: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_digits.isNotEmpty)
|
if (filled)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _clear,
|
onPressed: _saving ? null : _clear,
|
||||||
icon: const Icon(Icons.close_rounded, size: 20),
|
icon: const Icon(Icons.backspace_outlined, size: 18),
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
tooltip: 'Clear',
|
tooltip: 'Clear',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _resultColumn(CustomerLookupState state) => switch (state) {
|
|
||||||
LookupIdle() => _idle(),
|
|
||||||
LookupSearching() => const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(color: AppColors.primary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
LookupFound(:final customer) => _found(customer),
|
|
||||||
LookupNotFound() => _notFound(),
|
|
||||||
LookupError(:final message) => _message(
|
|
||||||
Icons.error_outline_rounded,
|
|
||||||
AppColors.danger,
|
|
||||||
message,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
Widget _idle() {
|
|
||||||
final recent = ref.watch(recentCustomersProvider).value ?? const [];
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_message(
|
|
||||||
Icons.dialpad_rounded,
|
|
||||||
AppColors.textTertiary,
|
|
||||||
'Key in a 10-digit mobile number — the lookup runs automatically. '
|
|
||||||
'Both fields are optional.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_nameField(),
|
|
||||||
if (recent.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
|
||||||
const Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
'Recent',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12.5,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
Wrap(
|
|
||||||
spacing: AppSpacing.sm,
|
|
||||||
runSpacing: AppSpacing.sm,
|
|
||||||
children: [
|
|
||||||
for (final c in recent.take(4))
|
|
||||||
ActionChip(
|
|
||||||
avatar: CircleAvatar(
|
|
||||||
radius: 11,
|
|
||||||
backgroundColor: AppColors.primarySurface,
|
|
||||||
child: Text(
|
|
||||||
Formatters.initials(c.name),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
label: Text(
|
|
||||||
c.name,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 12.5),
|
|
||||||
),
|
|
||||||
onPressed: () => _attachAndClose(c),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _found(Customer c) => Column(
|
// --------------------------------------------------------------- Details
|
||||||
|
Widget _detailsColumn() => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_hint(),
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.successSurface,
|
|
||||||
borderRadius: AppRadius.brLg,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 22,
|
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
child: Text(
|
|
||||||
Formatters.initials(c.name),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
c.name,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
Formatters.mobile(c.mobile),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12.5,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
StatusPill.tier(c.tier, dense: true),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _miniStat(
|
|
||||||
'${c.loyaltyPoints}', 'points held',),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _miniStat(
|
|
||||||
Formatters.money(c.redeemableValue),
|
|
||||||
'redeemable',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (c.tier.discountRate > 0)
|
|
||||||
Expanded(
|
|
||||||
child: _miniStat(
|
|
||||||
Formatters.percent(c.tier.discountRate),
|
|
||||||
'auto discount',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
PrimaryButton(
|
TextField(
|
||||||
label: 'Use this customer',
|
controller: _name,
|
||||||
icon: Icons.check_rounded,
|
textCapitalization: TextCapitalization.words,
|
||||||
large: true,
|
enabled: !_saving,
|
||||||
onPressed: () => _attachAndClose(c),
|
onSubmitted: (_) => _save(),
|
||||||
|
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Customer name',
|
||||||
|
hintText: 'Optional',
|
||||||
|
prefixIcon: Icon(Icons.person_outline_rounded),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _notFound() => Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_message(
|
|
||||||
Icons.person_search_rounded,
|
|
||||||
AppColors.warning,
|
|
||||||
'New number. Add a name if you have it — the bill can be sent to '
|
|
||||||
'this number on WhatsApp either way.',
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_nameField(),
|
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
Text(
|
Text(
|
||||||
@@ -488,73 +437,37 @@ class _CustomerCaptureSheetState
|
|||||||
icon: Icons.person_add_alt_1_rounded,
|
icon: Icons.person_add_alt_1_rounded,
|
||||||
large: true,
|
large: true,
|
||||||
busy: _saving,
|
busy: _saving,
|
||||||
onPressed: _quickRegister,
|
onPressed: _complete && !_saving ? _save : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: 'Continue without customer',
|
label: 'Skip — no customer',
|
||||||
tone: ButtonTone.neutral,
|
tone: ButtonTone.neutral,
|
||||||
onPressed: _saving ? null : () => _attachAndClose(null),
|
onPressed: _saving ? null : () => _attachAndClose(null),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _nameField() => TextField(
|
Widget _hint() => Container(
|
||||||
controller: _name,
|
width: double.infinity,
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
enabled: !_saving,
|
|
||||||
onSubmitted: (_) => _quickRegister(),
|
|
||||||
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Customer name',
|
|
||||||
hintText: 'Optional',
|
|
||||||
prefixIcon: Icon(Icons.person_outline_rounded),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _miniStat(String value, String label) => Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.success,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _message(IconData icon, Color color, String text) => Container(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceAlt,
|
color: AppColors.surfaceAlt,
|
||||||
borderRadius: AppRadius.brMd,
|
borderRadius: AppRadius.brLg,
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: const Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 19, color: color),
|
Icon(Icons.dialpad_rounded,
|
||||||
const SizedBox(width: AppSpacing.md),
|
size: 19, color: AppColors.textTertiary,),
|
||||||
|
SizedBox(width: AppSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
'Key in the mobile number, add a name if the shopper gives '
|
||||||
style: const TextStyle(
|
'one, then save. The name is optional and the whole step can '
|
||||||
|
'be skipped — the sale is never held up by it.',
|
||||||
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import '../../../app/providers.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
|
||||||
import '../../../data/sync/sync_engine.dart';
|
import '../../../data/sync/sync_engine.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
|
||||||
import '../../../domain/repositories/sync_repository.dart';
|
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../widgets/module_widgets.dart';
|
import '../widgets/module_widgets.dart';
|
||||||
|
|
||||||
/// End-of-day sync.
|
/// What this terminal has traded, and what has reached the server.
|
||||||
///
|
///
|
||||||
/// Shows what the terminal produced today and uploads every bill still at
|
/// Read-only. Uploading is the sync engine's job — it pushes after every sale
|
||||||
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
|
/// and retries on its own — so this page reports rather than drives. The
|
||||||
/// and is retried on the next tap.
|
/// warning banner at the top is the exception: a bill the engine has given up
|
||||||
|
/// on is the one thing here that needs a person to notice it.
|
||||||
class EventsView extends ConsumerWidget {
|
class EventsView extends ConsumerWidget {
|
||||||
const EventsView({super.key});
|
const EventsView({super.key});
|
||||||
|
|
||||||
@@ -25,8 +23,6 @@ class EventsView extends ConsumerWidget {
|
|||||||
final report = ref.watch(todayReportProvider);
|
final report = ref.watch(todayReportProvider);
|
||||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
|
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
|
||||||
final syncState = ref.watch(orderSyncProvider);
|
|
||||||
final events = ref.watch(syncEventsProvider);
|
|
||||||
|
|
||||||
// The engine may not have emitted yet on a cold start, so fall back to
|
// The engine may not have emitted yet on a cold start, so fall back to
|
||||||
// its current value rather than showing nothing. This is the same state
|
// its current value rather than showing nothing. This is the same state
|
||||||
@@ -83,118 +79,30 @@ class EventsView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
PanelCard(
|
|
||||||
title: 'Upload bills to server',
|
|
||||||
subtitle: r == null
|
|
||||||
? 'Reading today\u2019s trading from SQLite\u2026'
|
|
||||||
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
|
|
||||||
'\u00b7 ${r.terminalId}',
|
|
||||||
action: TagChip(
|
|
||||||
pending > 0 ? '$pending pending' : 'All synced',
|
|
||||||
color: pending > 0 ? AppColors.warning : AppColors.success,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (r != null && !r.isEmpty) ...[
|
|
||||||
_row('Bills', '${r.billCount}'),
|
|
||||||
_row('Items sold', r.itemCount.toStringAsFixed(0)),
|
|
||||||
_row('Gross sales', Formatters.money(r.grossSales)),
|
|
||||||
_row('GST collected', Formatters.money(r.taxCollected)),
|
|
||||||
_row('Discount given', Formatters.money(r.discountGiven)),
|
|
||||||
_row('Average basket', Formatters.money(r.averageBasket)),
|
|
||||||
if (r.paymentBreakdown.isNotEmpty) ...[
|
|
||||||
const Divider(height: AppSpacing.xxl),
|
|
||||||
for (final e in r.paymentBreakdown.entries)
|
|
||||||
ProgressRow(
|
|
||||||
label: '${e.key.emoji} ${e.key.label}',
|
|
||||||
value: Formatters.money(e.value),
|
|
||||||
fraction:
|
|
||||||
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
|
|
||||||
color: _methodColor(e.key),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
],
|
|
||||||
|
|
||||||
if (syncState is SyncRunning) ...[
|
|
||||||
Text(
|
|
||||||
syncState.stage,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: AppRadius.brPill,
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: syncState.progress,
|
|
||||||
minHeight: 8,
|
|
||||||
backgroundColor: AppColors.divider,
|
|
||||||
valueColor:
|
|
||||||
const AlwaysStoppedAnimation<Color>(AppColors.primary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
],
|
|
||||||
|
|
||||||
if (syncState is SyncFinished)
|
|
||||||
_outcomeBanner(syncState.outcome),
|
|
||||||
|
|
||||||
PrimaryButton(
|
|
||||||
label: pending > 0
|
|
||||||
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
|
|
||||||
: 'Nothing to sync',
|
|
||||||
icon: Icons.cloud_upload_rounded,
|
|
||||||
large: true,
|
|
||||||
busy: syncState is SyncRunning,
|
|
||||||
onPressed: pending == 0
|
|
||||||
? null
|
|
||||||
: () => ref.read(orderSyncProvider.notifier).run(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
const Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.shield_outlined,
|
|
||||||
size: 15, color: AppColors.textTertiary,),
|
|
||||||
SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Bills are written to SQLite the moment a sale '
|
|
||||||
'completes. A failed upload changes nothing on disk — '
|
|
||||||
'every bill stays until the server confirms it.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
|
|
||||||
PanelCard(
|
PanelCard(
|
||||||
title: 'Orders',
|
title: 'Orders',
|
||||||
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
|
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
|
||||||
child: ResponsiveTable(
|
child: ResponsiveTable(
|
||||||
columns: const [
|
columns: const [
|
||||||
TableCol('Invoice', flex: 3),
|
TableCol('Invoice', flex: 3),
|
||||||
TableCol('Time', flex: 2, priority: 1),
|
TableCol('Date & time', flex: 3, priority: 1),
|
||||||
|
TableCol('Items', flex: 1, numeric: true, priority: 2),
|
||||||
TableCol('Total', flex: 2, numeric: true),
|
TableCol('Total', flex: 2, numeric: true),
|
||||||
TableCol('Sync', flex: 2, numeric: true),
|
TableCol('Sync', flex: 2, numeric: true),
|
||||||
],
|
],
|
||||||
rows: rows
|
rows: rows
|
||||||
.map((o) => [
|
.map((o) => [
|
||||||
Cell(o.invoiceNumber, bold: true, mono: true),
|
Cell(o.invoiceNumber, bold: true, mono: true),
|
||||||
Cell(Formatters.time(o.createdAt),
|
// Date sits with the time because this table outlives the
|
||||||
color: AppColors.textTertiary,),
|
// day it was rung on — bills stay on the terminal until
|
||||||
|
// they are purged, so a bare clock time is ambiguous the
|
||||||
|
// moment the shop opens again.
|
||||||
|
Cell(
|
||||||
|
'${Formatters.date(o.createdAt)} \u00b7 '
|
||||||
|
'${Formatters.time(o.createdAt)}',
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
Cell(_units(o.itemCount), mono: true),
|
||||||
Cell(Formatters.money(o.total), mono: true, bold: true),
|
Cell(Formatters.money(o.total), mono: true, bold: true),
|
||||||
TagChip(
|
TagChip(
|
||||||
o.isSynced ? 'Synced' : 'Pending',
|
o.isSynced ? 'Synced' : 'Pending',
|
||||||
@@ -205,127 +113,17 @@ class EventsView extends ConsumerWidget {
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (events.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
PanelCard(
|
|
||||||
title: 'Sync history',
|
|
||||||
subtitle: 'This session',
|
|
||||||
child: ResponsiveTable(
|
|
||||||
columns: const [
|
|
||||||
TableCol('Event', flex: 3),
|
|
||||||
TableCol('Detail', flex: 5, priority: 1),
|
|
||||||
TableCol('Time', flex: 2, numeric: true),
|
|
||||||
],
|
|
||||||
rows: events
|
|
||||||
.map((e) => [
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
e.type.isInbound
|
|
||||||
? Icons.cloud_download_rounded
|
|
||||||
: Icons.cloud_upload_rounded,
|
|
||||||
size: 15,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Flexible(child: Cell(e.type.label, bold: true)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Cell(
|
|
||||||
e.error ?? e.summary,
|
|
||||||
color: e.error != null
|
|
||||||
? AppColors.danger
|
|
||||||
: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
Cell(Formatters.time(e.createdAt),
|
|
||||||
color: AppColors.textTertiary,),
|
|
||||||
],)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _outcomeBanner(SyncOutcome outcome) {
|
/// Units on a bill. Whole where they are whole — loose goods are sold by
|
||||||
final ok = outcome.isSuccess;
|
/// weight, so "2.5" is a real answer here and rounding it would be a lie.
|
||||||
final uploaded = outcome.uploaded;
|
static String _units(double count) =>
|
||||||
final attempted = outcome.attempted;
|
count % 1 == 0 ? count.toStringAsFixed(0) : count.toStringAsFixed(2);
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
|
|
||||||
borderRadius: AppRadius.brSm,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
|
|
||||||
size: 18,
|
|
||||||
color: ok ? AppColors.success : AppColors.danger,
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
ok
|
|
||||||
? '$uploaded of $attempted bills uploaded and marked synced.'
|
|
||||||
: '${outcome.error}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.45,
|
|
||||||
color: ok ? AppColors.success : AppColors.danger,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Color _methodColor(PaymentMethod m) => switch (m) {
|
|
||||||
PaymentMethod.cash => AppColors.success,
|
|
||||||
PaymentMethod.card => AppColors.info,
|
|
||||||
PaymentMethod.upi => AppColors.primary,
|
|
||||||
PaymentMethod.wallet => AppColors.warning,
|
|
||||||
PaymentMethod.giftCard => AppColors.tierGold,
|
|
||||||
PaymentMethod.loyalty => AppColors.tierSilver,
|
|
||||||
};
|
|
||||||
|
|
||||||
Widget _row(String label, String value) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13.5,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13.5,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flags a bill that could not reach the server on its own — the automatic
|
/// Flags a bill that could not reach the server on its own.
|
||||||
/// push right after checkout, not the manual "Sync" button below.
|
|
||||||
///
|
///
|
||||||
/// Sits above everything else on the page because a bill stuck here is the
|
/// Sits above everything else on the page because a bill stuck here is the
|
||||||
/// one thing on this screen that needs a person to notice it, rather than
|
/// one thing on this screen that needs a person to notice it, rather than
|
||||||
@@ -343,12 +141,10 @@ class _EngineWarningBanner extends StatelessWidget {
|
|||||||
|
|
||||||
final retry = engine.nextAttemptAt;
|
final retry = engine.nextAttemptAt;
|
||||||
final retryNote = halted
|
final retryNote = halted
|
||||||
? 'Retrying will not help until this is fixed — press Sync below '
|
? 'Retrying will not help until this is fixed.'
|
||||||
'once it is sorted.'
|
|
||||||
: retry == null
|
: retry == null
|
||||||
? 'It will retry automatically, or press Sync below to try now.'
|
? 'It will retry automatically.'
|
||||||
: 'It will retry automatically at ${Formatters.time(retry)}, or '
|
: 'It will retry automatically at ${Formatters.time(retry)}.';
|
||||||
'press Sync below to try now.';
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/providers.dart';
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
@@ -25,14 +24,18 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final lastImport = ref.watch(lastImportAtProvider);
|
final lastImport = ref.watch(lastImportAtProvider);
|
||||||
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
|
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
|
||||||
final revision = ref.watch(syncRepositoryProvider).catalogueRevision;
|
|
||||||
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
if (!ready) _NotImportedBanner(state: state),
|
if (!ready) _NotImportedBanner(state: state),
|
||||||
|
|
||||||
if (ready) ...[
|
if (ready) ...[
|
||||||
|
// Start-aligned, so the tiles begin at the same left edge as the
|
||||||
|
// panels below them rather than drifting with the run's width.
|
||||||
Wrap(
|
Wrap(
|
||||||
|
alignment: WrapAlignment.start,
|
||||||
|
runAlignment: WrapAlignment.start,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.start,
|
||||||
spacing: AppSpacing.lg,
|
spacing: AppSpacing.lg,
|
||||||
runSpacing: AppSpacing.lg,
|
runSpacing: AppSpacing.lg,
|
||||||
children: [
|
children: [
|
||||||
@@ -43,13 +46,6 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
caption: 'available offline',
|
caption: 'available offline',
|
||||||
),
|
),
|
||||||
StatTile(
|
|
||||||
label: 'Catalogue Revision',
|
|
||||||
value: revision ?? '—',
|
|
||||||
icon: Icons.tag_rounded,
|
|
||||||
color: AppColors.info,
|
|
||||||
caption: 'server version',
|
|
||||||
),
|
|
||||||
StatTile(
|
StatTile(
|
||||||
label: 'Last Imported',
|
label: 'Last Imported',
|
||||||
value: lastImport == null
|
value: lastImport == null
|
||||||
@@ -91,7 +87,7 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
child: ResponsiveTable(
|
child: ResponsiveTable(
|
||||||
columns: const [
|
columns: const [
|
||||||
TableCol('Product', flex: 4),
|
TableCol('Product', flex: 4),
|
||||||
TableCol('SKU', flex: 3, priority: 1),
|
TableCol('Barcode', flex: 3, priority: 1),
|
||||||
TableCol('Category', flex: 2, priority: 1),
|
TableCol('Category', flex: 2, priority: 1),
|
||||||
TableCol('Price', flex: 2, numeric: true),
|
TableCol('Price', flex: 2, numeric: true),
|
||||||
TableCol('Stock', flex: 2, numeric: true),
|
TableCol('Stock', flex: 2, numeric: true),
|
||||||
@@ -107,7 +103,8 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
Flexible(child: Cell(p.name, bold: true)),
|
Flexible(child: Cell(p.name, bold: true)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Cell(p.sku, color: AppColors.textTertiary),
|
Cell(p.barcode,
|
||||||
|
color: AppColors.textTertiary, mono: true,),
|
||||||
TagChip(p.category.label,
|
TagChip(p.category.label,
|
||||||
color: AppColors.textSecondary,),
|
color: AppColors.textSecondary,),
|
||||||
Cell(Formatters.money(p.price), mono: true, bold: true),
|
Cell(Formatters.money(p.price), mono: true, bold: true),
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ class PromosView extends ConsumerWidget {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
error: (e, _) => Text('Could not load campaigns: $e'),
|
error: (e, _) => Text('Could not load campaigns: $e'),
|
||||||
|
// Stretch, not the Column default of centre. Centred, every card
|
||||||
|
// shrank to its own intrinsic width and floated in the middle of the
|
||||||
|
// page instead of starting at the left edge like every other module.
|
||||||
data: (promos) => Column(
|
data: (promos) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_summary(promos),
|
_summary(promos),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
@@ -55,6 +59,7 @@ class PromosView extends ConsumerWidget {
|
|||||||
.length;
|
.length;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StatTile(
|
child: StatTile(
|
||||||
@@ -119,6 +124,7 @@ class PromosView extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (final promo in promos)
|
for (final promo in promos)
|
||||||
_PromoRow(promo: promo, isAdmin: isAdmin),
|
_PromoRow(promo: promo, isAdmin: isAdmin),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../data/local/order_dao.dart';
|
import '../../../data/local/order_dao.dart';
|
||||||
|
import '../../../data/local/void_pin_store.dart';
|
||||||
import '../../../domain/entities/store_account.dart';
|
import '../../../domain/entities/store_account.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../providers/printer_settings.dart';
|
import '../providers/printer_settings.dart';
|
||||||
@@ -17,6 +18,7 @@ import '../widgets/staff_dialogs.dart';
|
|||||||
import '../widgets/store_details_dialog.dart';
|
import '../widgets/store_details_dialog.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../widgets/module_widgets.dart';
|
import '../widgets/module_widgets.dart';
|
||||||
|
import '../../../core/widgets/numeric_keypad.dart';
|
||||||
|
|
||||||
/// Terminal and store configuration.
|
/// Terminal and store configuration.
|
||||||
class SettingsView extends ConsumerStatefulWidget {
|
class SettingsView extends ConsumerStatefulWidget {
|
||||||
@@ -32,6 +34,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
bool _testingDrawer = false;
|
bool _testingDrawer = false;
|
||||||
bool _loadedDrawerFields = false;
|
bool _loadedDrawerFields = false;
|
||||||
|
|
||||||
|
/// Null until the first read comes back from the meta table.
|
||||||
|
bool? _hasRemovalPin;
|
||||||
|
bool _loadedRemovalPin = false;
|
||||||
|
|
||||||
bool _scannerSound = true;
|
bool _scannerSound = true;
|
||||||
bool _roundOff = true;
|
bool _roundOff = true;
|
||||||
bool _autoLoyalty = true;
|
bool _autoLoyalty = true;
|
||||||
@@ -50,6 +56,16 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
|
|
||||||
// Seeded once, from whatever was persisted. Assigning on every build would
|
// Seeded once, from whatever was persisted. Assigning on every build would
|
||||||
// fight the cashier for the cursor while they type.
|
// fight the cashier for the cursor while they type.
|
||||||
|
if (!_loadedRemovalPin) {
|
||||||
|
_loadedRemovalPin = true;
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
if (store.isReady) {
|
||||||
|
store.voidPin.isConfigured.then((has) {
|
||||||
|
if (mounted) setState(() => _hasRemovalPin = has);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final printer = ref.watch(printerSettingsProvider);
|
final printer = ref.watch(printerSettingsProvider);
|
||||||
if (!_loadedDrawerFields && printer.hasDrawer) {
|
if (!_loadedDrawerFields && printer.hasDrawer) {
|
||||||
_loadedDrawerFields = true;
|
_loadedDrawerFields = true;
|
||||||
@@ -80,6 +96,8 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
_staffCard(store, user),
|
_staffCard(store, user),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
_removalPinCard(user),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
_aboutCard(),
|
_aboutCard(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -430,6 +448,94 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Removal PIN
|
||||||
|
/// The PIN a cashier types to take a rung item back off a bill.
|
||||||
|
///
|
||||||
|
/// Admin-only, and deliberately not a staff PIN. A staff PIN identifies the
|
||||||
|
/// person a bill is stamped with; handing one out so the counter can void a
|
||||||
|
/// line would put the whole shift under the wrong name. This is a shared
|
||||||
|
/// secret for one specific action, and the admin's own staff PIN keeps
|
||||||
|
/// working whether or not it is set.
|
||||||
|
Widget _removalPinCard(StaffUser? user) {
|
||||||
|
final isAdmin = user?.role == StaffRole.admin;
|
||||||
|
|
||||||
|
return PanelCard(
|
||||||
|
title: 'Item removal PIN',
|
||||||
|
subtitle: 'Asked for when an item is taken off a bill',
|
||||||
|
action: isAdmin
|
||||||
|
? TextButton(
|
||||||
|
onPressed: () => _setRemovalPin(),
|
||||||
|
child: Text(_hasRemovalPin == true ? 'Change' : 'Set PIN'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_row(
|
||||||
|
'Status',
|
||||||
|
_hasRemovalPin == null
|
||||||
|
? 'Checking…'
|
||||||
|
: (_hasRemovalPin! ? 'Set' : 'Not set'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
_hasRemovalPin == true
|
||||||
|
? 'A cashier can remove an item using this PIN. An admin PIN '
|
||||||
|
'still works too.'
|
||||||
|
: 'No PIN is set, so a removal currently needs an admin PIN. '
|
||||||
|
'Set one and a cashier can void a line without an admin '
|
||||||
|
'walking over.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isAdmin) ...[
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
const Text(
|
||||||
|
'Only an admin can change it.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setRemovalPin() async {
|
||||||
|
final pin = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _RemovalPinDialog(),
|
||||||
|
);
|
||||||
|
if (pin == null) return;
|
||||||
|
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
try {
|
||||||
|
if (pin.isEmpty) {
|
||||||
|
await store.voidPin.clearPin();
|
||||||
|
} else {
|
||||||
|
await store.voidPin.setPin(pin);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _hasRemovalPin = pin.isNotEmpty);
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
content: Text(pin.isEmpty
|
||||||
|
? 'Removal PIN cleared. Removals now need an admin PIN.'
|
||||||
|
: 'Removal PIN saved.',),
|
||||||
|
),);
|
||||||
|
} on VoidPinException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _connectivityCard() {
|
Widget _connectivityCard() {
|
||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final lastImport = ref.watch(lastImportAtProvider);
|
final lastImport = ref.watch(lastImportAtProvider);
|
||||||
@@ -609,3 +715,96 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Four-to-eight digits, keyed on the same pad the till uses everywhere else.
|
||||||
|
class _RemovalPinDialog extends StatefulWidget {
|
||||||
|
const _RemovalPinDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_RemovalPinDialog> createState() => _RemovalPinDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RemovalPinDialogState extends State<_RemovalPinDialog> {
|
||||||
|
String _pin = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Set removal PIN'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: (MediaQuery.sizeOf(context).width - 96).clamp(260.0, 340.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Four digits or more. Give it to whoever is on the counter — it '
|
||||||
|
'authorises removing an item from a bill and nothing else.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
SizedBox(
|
||||||
|
height: 22,
|
||||||
|
child: Center(
|
||||||
|
child: _pin.isEmpty
|
||||||
|
? const Text(
|
||||||
|
'Enter PIN',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _pin.length; i++)
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
NumericKeypad(
|
||||||
|
onKey: (d) {
|
||||||
|
if (_pin.length >= 8) return;
|
||||||
|
setState(() => _pin += d);
|
||||||
|
},
|
||||||
|
onBackspace: () {
|
||||||
|
if (_pin.isEmpty) return;
|
||||||
|
setState(() => _pin = _pin.substring(0, _pin.length - 1));
|
||||||
|
},
|
||||||
|
onClear: () => setState(() => _pin = ''),
|
||||||
|
onSubmit: _pin.length >= 4
|
||||||
|
? () => Navigator.of(context).pop(_pin)
|
||||||
|
: null,
|
||||||
|
submitLabel: 'Save PIN',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(''),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||||
|
child: const Text('Remove PIN'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
@@ -12,6 +13,7 @@ import '../../../core/widgets/glass_card.dart';
|
|||||||
import '../../../core/widgets/numeric_keypad.dart';
|
import '../../../core/widgets/numeric_keypad.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../core/widgets/status_pill.dart';
|
import '../../../core/widgets/status_pill.dart';
|
||||||
|
import '../../../domain/entities/promo.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
@@ -97,7 +99,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_customerCard(),
|
_customerCard(),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
// Only builds anything when a campaign is on the bill or one is
|
||||||
|
// within reach, so a shop running none sees no empty card.
|
||||||
|
_offersCard(),
|
||||||
_methodsCard(controller, state),
|
_methodsCard(controller, state),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
// Carries the rest of the column's height, and answers the
|
||||||
|
// question a customer asks at the counter — what am I paying
|
||||||
|
// for — without going back to the bill.
|
||||||
|
_summaryCard(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,16 +122,37 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
// One scroll view over both columns, equal flex, centred and capped.
|
||||||
|
//
|
||||||
|
// Two independent scrollers with a 4:5 split were what made this read
|
||||||
|
// as lopsided: the columns started at different widths, ended at
|
||||||
|
// different heights, and the whole thing sat against the top-left of
|
||||||
|
// a much larger window. The minimum height fills the viewport so the
|
||||||
|
// pair sits in the middle of the screen instead of clinging to the
|
||||||
|
// top edge, and the cap stops the cards stretching into bands on a
|
||||||
|
// wide till display.
|
||||||
|
final minHeight = constraints.maxHeight.isFinite
|
||||||
|
? (constraints.maxHeight - pad * 2).clamp(0.0, double.infinity)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
padding: EdgeInsets.all(pad),
|
padding: EdgeInsets.all(pad),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: minHeight),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1340),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
|
Expanded(child: left),
|
||||||
const SizedBox(width: AppSpacing.lg),
|
const SizedBox(width: AppSpacing.lg),
|
||||||
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
|
Expanded(child: right),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -289,57 +320,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (state.splits.isNotEmpty) ...[
|
|
||||||
const Divider(height: AppSpacing.xxl),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Split tenders',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: controller.clearSplits,
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.danger,
|
|
||||||
minimumSize: const Size(0, 32),
|
|
||||||
),
|
|
||||||
child: const Text('Clear'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
for (final e in state.splits.asMap().entries)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(e.value.method.emoji,
|
|
||||||
style: const TextStyle(fontSize: 15),),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
e.value.method.label,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 13.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(Formatters.money(e.value.amount),
|
|
||||||
style: AppTypography.money(13.5),),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => controller.removeSplit(e.key),
|
|
||||||
icon: const Icon(Icons.close_rounded, size: 16),
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
constraints:
|
|
||||||
const BoxConstraints(minWidth: 30, minHeight: 30),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
tooltip: 'Remove tender',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -354,23 +334,32 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Amount entry for whichever method is active. Every method works the
|
/// Amount entry for whichever method is active.
|
||||||
/// same way now — a keypad, an Exact shortcut, and an explicit amount —
|
///
|
||||||
/// rather than cash alone asking what was received while card/UPI/wallet
|
/// Every method works the same way — a keypad, an Exact shortcut and an
|
||||||
/// silently assumed the full balance. Cash additionally gets denomination
|
/// explicit amount — rather than cash alone asking what was received while
|
||||||
/// chips and a change-due row, since only cash can be over-tendered; a
|
/// card/UPI/wallet silently assumed the full balance. Cash additionally gets
|
||||||
/// method that captures a reference (card, UPI, gift card) additionally
|
/// denomination shortcuts and a change-due row, since only cash can be
|
||||||
|
/// over-tendered; a method that captures a reference (card, UPI, gift card)
|
||||||
/// gets that field below the keypad.
|
/// gets that field below the keypad.
|
||||||
Widget _amountTender(PaymentController controller, PaymentState state) {
|
Widget _amountTender(PaymentController controller, PaymentState state) {
|
||||||
final cash = state.activeMethod.needsChange;
|
final cash = state.activeMethod.needsChange;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, box) {
|
||||||
|
// Wide enough to stand the shortcuts beside the keypad instead of
|
||||||
|
// above it. A centred 330px keypad in a 560px column was the other
|
||||||
|
// half of the lopsided look — the space beside it did nothing.
|
||||||
|
final sideBySide = cash && box.maxWidth >= 500;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)),
|
Icon(methodIcon(state.activeMethod),
|
||||||
|
size: 20, color: AppColors.primary,),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -387,68 +376,31 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Container(
|
_amountField(),
|
||||||
padding: const EdgeInsets.symmetric(
|
const SizedBox(height: AppSpacing.md),
|
||||||
horizontal: AppSpacing.lg,
|
|
||||||
vertical: AppSpacing.md,
|
if (!sideBySide) ...[
|
||||||
),
|
_shortcutWrap(controller, cash),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surfaceAlt,
|
|
||||||
borderRadius: AppRadius.brLg,
|
|
||||||
border: Border.all(color: AppColors.border),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Text('₹',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
|
||||||
style: AppTypography.money(28),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Wrap(
|
|
||||||
spacing: AppSpacing.sm,
|
|
||||||
runSpacing: AppSpacing.sm,
|
|
||||||
children: [
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.done_all_rounded, size: 15),
|
|
||||||
label: const Text('Exact'),
|
|
||||||
onPressed: () => _setCash(controller.balanceDue),
|
|
||||||
),
|
|
||||||
// Denomination shortcuts only make sense for physical notes.
|
|
||||||
if (cash)
|
|
||||||
for (final note in const [50, 100, 200, 500, 2000])
|
|
||||||
ActionChip(
|
|
||||||
label: Text('₹$note'),
|
|
||||||
onPressed: () =>
|
|
||||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
if (cash) ...[
|
if (cash) ...[
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
_changeRow(controller.changeDue),
|
_changeRow(controller.changeDue),
|
||||||
],
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Center(
|
],
|
||||||
child: NumericKeypad(
|
|
||||||
allowDecimal: true,
|
if (sideBySide)
|
||||||
maxWidth: 330,
|
Row(
|
||||||
onKey: _appendCash,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onBackspace: _backspaceCash,
|
children: [
|
||||||
),
|
Expanded(child: _shortcutColumn(controller)),
|
||||||
),
|
const SizedBox(width: AppSpacing.lg),
|
||||||
|
SizedBox(width: 296, child: _keypad()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Center(child: _keypad()),
|
||||||
|
|
||||||
if (state.activeMethod.needsReference) ...[
|
if (state.activeMethod.needsReference) ...[
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -464,8 +416,109 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
_partPaymentAction(controller, state),
|
||||||
|
_receivedSoFar(controller, state),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _keypad() => NumericKeypad(
|
||||||
|
allowDecimal: true,
|
||||||
|
maxWidth: 330,
|
||||||
|
onKey: _appendCash,
|
||||||
|
onBackspace: _backspaceCash,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The typed amount.
|
||||||
|
///
|
||||||
|
/// The symbol sits in the same run as the digits and in the same style. It
|
||||||
|
/// used to be a separate, smaller, grey glyph, which rendered as a mismatched
|
||||||
|
/// mark floating beside the number rather than part of it.
|
||||||
|
Widget _amountField() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.lg,
|
||||||
|
vertical: AppSpacing.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceAlt,
|
||||||
|
borderRadius: AppRadius.brLg,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'₹${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
|
||||||
|
style: AppTypography.money(30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const List<int> _notes = [50, 100, 200, 500, 2000];
|
||||||
|
|
||||||
|
/// Shortcuts above the keypad, for the narrow layout.
|
||||||
|
Widget _shortcutWrap(PaymentController controller, bool cash) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: AppSpacing.sm,
|
||||||
|
runSpacing: AppSpacing.sm,
|
||||||
|
children: [
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(Icons.done_all_rounded, size: 15),
|
||||||
|
label: const Text('Exact'),
|
||||||
|
onPressed: () => _setCash(controller.balanceDue),
|
||||||
|
),
|
||||||
|
// Denomination shortcuts only make sense for physical notes.
|
||||||
|
if (cash)
|
||||||
|
for (final note in _notes)
|
||||||
|
ActionChip(
|
||||||
|
label: Text('₹$note'),
|
||||||
|
onPressed: () =>
|
||||||
|
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shortcuts beside the keypad, for the wide layout.
|
||||||
|
Widget _shortcutColumn(PaymentController controller) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _setCash(controller.balanceDue),
|
||||||
|
icon: const Icon(Icons.done_all_rounded, size: 17),
|
||||||
|
label: Text('Exact ${Formatters.money(controller.balanceDue)}'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
backgroundColor: AppColors.primarySurface,
|
||||||
|
foregroundColor: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
for (final note in _notes) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
child: Text('+ ₹$note'),
|
||||||
|
),
|
||||||
|
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -513,22 +566,380 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _splitButton(PaymentController controller, {double? amount}) {
|
/// Takes part of the bill on the current method.
|
||||||
return OutlinedButton.icon(
|
///
|
||||||
onPressed: controller.balanceDue > 0
|
/// This is the old "Add as split payment" button, and it stages exactly the
|
||||||
? () {
|
/// same tender — but it no longer asks the cashier to know what a split is,
|
||||||
controller.addSplit(
|
/// or to press it for a payment that is not one. It appears only when the
|
||||||
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
|
/// typed amount is genuinely short of the balance, and says what it will do
|
||||||
);
|
/// in the customer's terms: take this much now, leave that much to pay.
|
||||||
|
Widget _partPaymentAction(
|
||||||
|
PaymentController controller,
|
||||||
|
PaymentState state,
|
||||||
|
) {
|
||||||
|
final entered = double.tryParse(_cashBuffer) ?? 0;
|
||||||
|
final due = controller.balanceDue;
|
||||||
|
final short = entered > 0.009 && entered < due - 0.009;
|
||||||
|
|
||||||
|
if (!short) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final rest = due - entered;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () {
|
||||||
|
controller.addSplit(amount: entered);
|
||||||
setState(() => _cashBuffer = '');
|
setState(() => _cashBuffer = '');
|
||||||
}
|
},
|
||||||
: null,
|
icon: const Icon(Icons.add_rounded, size: 18),
|
||||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
label: Text(
|
||||||
label: const Text('Add as split payment'),
|
'Take ${Formatters.money(entered)} by '
|
||||||
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
|
'${state.activeMethod.label}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size(0, 48)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(
|
||||||
|
'${Formatters.money(rest)} left to pay — pick another method for '
|
||||||
|
'the rest.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tenders already staged against this bill, and what is still outstanding.
|
||||||
|
Widget _receivedSoFar(PaymentController controller, PaymentState state) {
|
||||||
|
if (state.splits.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: AppSpacing.lg),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Received so far',
|
||||||
|
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: controller.clearSplits,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.danger,
|
||||||
|
minimumSize: const Size(0, 32),
|
||||||
|
),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
for (final e in state.splits.asMap().entries)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(methodIcon(e.value.method),
|
||||||
|
size: 17, color: AppColors.textSecondary,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
e.value.method.label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 13.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(Formatters.money(e.value.amount),
|
||||||
|
style: AppTypography.money(13.5),),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => controller.removeSplit(e.key),
|
||||||
|
icon: const Icon(Icons.close_rounded, size: 16),
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
constraints:
|
||||||
|
const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tooltip: 'Remove tender',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Still to pay',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
Formatters.money(controller.balanceDue),
|
||||||
|
style: AppTypography.money(
|
||||||
|
15,
|
||||||
|
color: controller.balanceDue > 0
|
||||||
|
? AppColors.warning
|
||||||
|
: AppColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Offers
|
||||||
|
/// Campaigns on this bill, and the nearest one that is not on it yet.
|
||||||
|
///
|
||||||
|
/// The engine already applies everything that qualifies, silently — the
|
||||||
|
/// shopper only ever saw a discount line. Two things were missing at the
|
||||||
|
/// counter: a cashier could not answer "did the weekend offer come off?"
|
||||||
|
/// without opening the promo module, and nobody could see that a bill was a
|
||||||
|
/// few rupees short of one. The near-miss rows are the point of this card:
|
||||||
|
/// a minimum-bill campaign is worth nothing if the person paying is never
|
||||||
|
/// told they are close to it.
|
||||||
|
///
|
||||||
|
/// Read-only. Nothing here applies or removes a campaign — that stays with
|
||||||
|
/// [PromoEngine], so the till cannot be talked into a discount by hand.
|
||||||
|
Widget _offersCard() {
|
||||||
|
final cart = ref.watch(cartControllerProvider);
|
||||||
|
final promos = ref.watch(activePromosProvider).value ?? const <Promo>[];
|
||||||
|
|
||||||
|
final applied = cart.appliedPromos;
|
||||||
|
final appliedIds = applied.map((a) => a.promo.id).toSet();
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Live today, not already firing, and gated only by a bill minimum this
|
||||||
|
// cart has not reached. A campaign that fails for any other reason —
|
||||||
|
// wrong category, wrong product, wrong day — is not "nearly earned" and
|
||||||
|
// saying so would be a false promise.
|
||||||
|
final withinReach = promos
|
||||||
|
.where((p) =>
|
||||||
|
p.isLiveAt(now) &&
|
||||||
|
!appliedIds.contains(p.id) &&
|
||||||
|
p.minBillValue > 0 &&
|
||||||
|
cart.subtotal < p.minBillValue,)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.minBillValue.compareTo(b.minBillValue));
|
||||||
|
|
||||||
|
if (applied.isEmpty && withinReach.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||||
|
child: GlassCard(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
|
radius: AppRadius.xl,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sell_outlined,
|
||||||
|
size: 18, color: AppColors.primary,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Offers',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (applied.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'− ${Formatters.money(
|
||||||
|
applied.fold<double>(0, (sum, a) => sum + a.amount),
|
||||||
|
)}',
|
||||||
|
style: AppTypography.money(14, color: AppColors.success),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
for (final a in applied)
|
||||||
|
_offerRow(
|
||||||
|
icon: Icons.check_circle_rounded,
|
||||||
|
tone: AppColors.success,
|
||||||
|
title: a.promo.name,
|
||||||
|
subtitle: a.promo.summary,
|
||||||
|
trailing: '− ${Formatters.money(a.amount)}',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Two is the useful number: the next one to reach and the one
|
||||||
|
// after it. A full list turns a payment screen into a catalogue
|
||||||
|
// of things the shopper is not getting.
|
||||||
|
for (final p in withinReach.take(2))
|
||||||
|
_offerRow(
|
||||||
|
icon: Icons.lock_open_rounded,
|
||||||
|
tone: AppColors.warning,
|
||||||
|
title: p.name,
|
||||||
|
subtitle: '${p.summary} \u00b7 add '
|
||||||
|
'${Formatters.money(p.minBillValue - cart.subtotal)} more '
|
||||||
|
'to reach ${Formatters.money(p.minBillValue)}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _offerRow({
|
||||||
|
required IconData icon,
|
||||||
|
required Color tone,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
String? trailing,
|
||||||
|
}) =>
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 17, color: tone),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Text(trailing, style: AppTypography.money(13.5, color: tone)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --------------------------------------------------------- Bill summary
|
||||||
|
/// What the amount due is made of.
|
||||||
|
Widget _summaryCard() {
|
||||||
|
final cart = ref.watch(cartControllerProvider);
|
||||||
|
final discounts = cart.lineDiscountTotal + cart.billDiscountTotal;
|
||||||
|
|
||||||
|
return GlassCard(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
|
radius: AppRadius.xl,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Bill summary',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_summaryRow('Subtotal', Formatters.money(cart.subtotal)),
|
||||||
|
if (discounts > 0)
|
||||||
|
_summaryRow(
|
||||||
|
'Discounts',
|
||||||
|
'− ${Formatters.money(discounts)}',
|
||||||
|
tone: AppColors.success,
|
||||||
|
),
|
||||||
|
if (cart.loyaltyRedemptionValue > 0)
|
||||||
|
_summaryRow(
|
||||||
|
'Points redeemed',
|
||||||
|
'− ${Formatters.money(cart.loyaltyRedemptionValue)}',
|
||||||
|
tone: AppColors.success,
|
||||||
|
),
|
||||||
|
if (cart.roundOff != 0)
|
||||||
|
_summaryRow('Round off', Formatters.money(cart.roundOff)),
|
||||||
|
const Divider(height: AppSpacing.xl),
|
||||||
|
_summaryRow(
|
||||||
|
'Total',
|
||||||
|
Formatters.money(cart.grandTotal),
|
||||||
|
strong: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
// Prices are GST-inclusive, so this is a breakdown of the total
|
||||||
|
// rather than another line added to it — said plainly, because a
|
||||||
|
// customer reading a tax figure will otherwise try to add it on.
|
||||||
|
Text(
|
||||||
|
'Includes GST ${Formatters.money(cart.taxAmount)} '
|
||||||
|
'(CGST ${Formatters.money(cart.cgst)} + '
|
||||||
|
'SGST ${Formatters.money(cart.sgst)})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _summaryRow(
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
Color? tone,
|
||||||
|
bool strong = false,
|
||||||
|
}) =>
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: strong ? 14.5 : 13.5,
|
||||||
|
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
color: strong
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: (tone ?? AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: AppTypography.money(
|
||||||
|
strong ? 17 : 13.5,
|
||||||
|
color: tone ?? (strong ? AppColors.primary : null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------------ Bottom bar
|
// ------------------------------------------------------------ Bottom bar
|
||||||
Widget _bottomBar(
|
Widget _bottomBar(
|
||||||
PaymentController controller,
|
PaymentController controller,
|
||||||
@@ -679,7 +1090,11 @@ class _MethodTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(method.emoji, style: const TextStyle(fontSize: 22)),
|
Icon(
|
||||||
|
methodIcon(method),
|
||||||
|
size: 22,
|
||||||
|
color: selected ? Colors.white : AppColors.textSecondary,
|
||||||
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Text(
|
||||||
method.label,
|
method.label,
|
||||||
@@ -699,3 +1114,19 @@ class _MethodTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A flat icon per tender type.
|
||||||
|
///
|
||||||
|
/// These were emoji — 💵 for cash, 💳 for card — which render as small
|
||||||
|
/// photographic pictures on most platforms and as a fallback box on some. Next
|
||||||
|
/// to Material iconography everywhere else on the screen they read as clip
|
||||||
|
/// art pasted into the UI rather than part of it, and the cash one in
|
||||||
|
/// particular looked like a picture of American banknotes on a rupee till.
|
||||||
|
IconData methodIcon(PaymentMethod method) => switch (method) {
|
||||||
|
PaymentMethod.cash => Icons.payments_outlined,
|
||||||
|
PaymentMethod.card => Icons.credit_card_rounded,
|
||||||
|
PaymentMethod.upi => Icons.qr_code_2_rounded,
|
||||||
|
PaymentMethod.wallet => Icons.account_balance_wallet_outlined,
|
||||||
|
PaymentMethod.giftCard => Icons.card_giftcard_rounded,
|
||||||
|
PaymentMethod.loyalty => Icons.stars_rounded,
|
||||||
|
};
|
||||||
|
|||||||
@@ -208,10 +208,19 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
setQuantity(productId, line.quantity + by);
|
setQuantity(productId, line.quantity + by);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Steps a line down, never off the bill.
|
||||||
|
///
|
||||||
|
/// Floors at one deliberately. Taking the last unit away is a removal, and
|
||||||
|
/// removals go through the PIN gate on the close button — a stepper that
|
||||||
|
/// quietly reached zero was a way around it.
|
||||||
void decrement(String productId, {double by = 1}) {
|
void decrement(String productId, {double by = 1}) {
|
||||||
final line = state.lineFor(productId);
|
final line = state.lineFor(productId);
|
||||||
if (line == null) return;
|
if (line == null) return;
|
||||||
setQuantity(productId, line.quantity - by);
|
|
||||||
|
final next = line.quantity - by;
|
||||||
|
if (next < 1) return;
|
||||||
|
|
||||||
|
setQuantity(productId, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeLine(String productId) {
|
void removeLine(String productId) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
|
||||||
/// The modules a cashier needs. Deliberately excludes analytics — this
|
/// The modules a cashier needs. Deliberately excludes analytics — this
|
||||||
/// terminal is for billing, not back-office reporting.
|
/// terminal is for billing, not back-office reporting.
|
||||||
enum PosModule {
|
enum PosModule {
|
||||||
@@ -41,4 +43,37 @@ enum NavSection {
|
|||||||
PosModule.values.where((m) => m.section == this).toList();
|
PosModule.values.where((m) => m.section == this).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a cashier session may open.
|
||||||
|
///
|
||||||
|
/// Billing, and nothing else — not the catalogue, the promos, the sync log or
|
||||||
|
/// the terminal's configuration. The sidebar is hidden in cashier mode anyway,
|
||||||
|
/// so this is the belt to that braces: a module reached some other way (a scan
|
||||||
|
/// handler, a deep link, a stale value left in [activeModuleProvider] from the
|
||||||
|
/// admin's session) still cannot render.
|
||||||
|
const cashierModules = <PosModule>[PosModule.pos];
|
||||||
|
|
||||||
final activeModuleProvider = StateProvider<PosModule>((ref) => PosModule.pos);
|
final activeModuleProvider = StateProvider<PosModule>((ref) => PosModule.pos);
|
||||||
|
|
||||||
|
/// The modules the current session is allowed to reach.
|
||||||
|
final visibleModulesProvider = Provider<List<PosModule>>((ref) {
|
||||||
|
return ref.watch(isCashierModeProvider) ? cashierModules : PosModule.values;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// [activeModuleProvider], clamped to what this session may open.
|
||||||
|
///
|
||||||
|
/// Read this rather than the raw value anywhere a module decides what gets
|
||||||
|
/// built. An admin who leaves the shell on Settings and hands the till to a
|
||||||
|
/// cashier would otherwise reopen it on Settings.
|
||||||
|
final resolvedModuleProvider = Provider<PosModule>((ref) {
|
||||||
|
final active = ref.watch(activeModuleProvider);
|
||||||
|
final visible = ref.watch(visibleModulesProvider);
|
||||||
|
return visible.contains(active) ? active : PosModule.pos;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Sections that still have at least one module this session may open.
|
||||||
|
final visibleSectionsProvider = Provider<List<NavSection>>((ref) {
|
||||||
|
final visible = ref.watch(visibleModulesProvider);
|
||||||
|
return NavSection.values
|
||||||
|
.where((s) => s.modules.any(visible.contains))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ import 'pos_view.dart';
|
|||||||
/// * `1120–1300` sidebar as an icon rail, docked bill
|
/// * `1120–1300` sidebar as an icon rail, docked bill
|
||||||
/// * `920–1120` icon rail, bill becomes a bottom sheet
|
/// * `920–1120` icon rail, bill becomes a bottom sheet
|
||||||
/// * `< 920` sidebar goes off-canvas behind a menu button
|
/// * `< 920` sidebar goes off-canvas behind a menu button
|
||||||
|
///
|
||||||
|
/// In cashier mode the sidebar is not rendered at any width. That session has
|
||||||
|
/// exactly two destinations, and both are reachable from the header — a rail
|
||||||
|
/// holding one live tile is chrome for its own sake. Sign-out and the sync log
|
||||||
|
/// move up with it, since the sidebar was the only place they lived.
|
||||||
class PosDashboardScreen extends ConsumerStatefulWidget {
|
class PosDashboardScreen extends ConsumerStatefulWidget {
|
||||||
const PosDashboardScreen({super.key});
|
const PosDashboardScreen({super.key});
|
||||||
|
|
||||||
@@ -119,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final layout = PosLayout.of(context);
|
final layout = PosLayout.of(context);
|
||||||
final module = ref.watch(activeModuleProvider);
|
// Clamped, not raw: a module the admin left active must not carry into a
|
||||||
|
// cashier session.
|
||||||
|
final module = ref.watch(resolvedModuleProvider);
|
||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final isPos = module == PosModule.pos && ready;
|
final isPos = module == PosModule.pos && ready;
|
||||||
|
|
||||||
|
final cashierMode = ref.watch(isCashierModeProvider);
|
||||||
|
final showSidebar = !cashierMode;
|
||||||
|
|
||||||
// Only the terminal itself needs the bill docked beside it.
|
// Only the terminal itself needs the bill docked beside it.
|
||||||
final showDockedBill = isPos && !layout.billingIsSheet;
|
final showDockedBill = isPos && !layout.billingIsSheet;
|
||||||
final showCartFab = isPos && layout.billingIsSheet;
|
final showCartFab = isPos && layout.billingIsSheet;
|
||||||
@@ -130,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: _scaffoldKey,
|
key: _scaffoldKey,
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
drawer: layout.sidebarIsDrawer
|
drawer: showSidebar && layout.sidebarIsDrawer
|
||||||
? Drawer(
|
? Drawer(
|
||||||
width: PosLayout.expandedWidth,
|
width: PosLayout.expandedWidth,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
@@ -157,13 +167,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
|
if (showSidebar && !layout.sidebarIsDrawer)
|
||||||
|
AppSidebar(mode: layout.sidebar),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
PageHeader(
|
PageHeader(
|
||||||
layout: layout,
|
layout: layout,
|
||||||
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
|
// Nothing to open in cashier mode, so the button is not
|
||||||
|
// offered rather than opening an empty drawer.
|
||||||
|
onMenuTap: showSidebar
|
||||||
|
? () => _scaffoldKey.currentState?.openDrawer()
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../widgets/category_chips.dart';
|
import '../widgets/category_chips.dart';
|
||||||
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
final state = ref.watch(catalogueImportProvider);
|
final state = ref.watch(catalogueImportProvider);
|
||||||
final running = state is ImportRunning;
|
final running = state is ImportRunning;
|
||||||
|
|
||||||
|
// Pulling the catalogue is an admin job, and a cashier has no Product
|
||||||
|
// Import module to be sent to. Offering them a button that opens a screen
|
||||||
|
// they cannot reach is worse than telling them who to ask.
|
||||||
|
final cashier = ref.watch(isCashierModeProvider);
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||||
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
Text(
|
Text(
|
||||||
'Import products to start billing',
|
cashier
|
||||||
|
? 'No products on this terminal'
|
||||||
|
: 'Import products to start billing',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
const Text(
|
Text(
|
||||||
'This terminal has no catalogue yet. Pull the current products '
|
cashier
|
||||||
'once at the start of your shift — after that everything runs '
|
? 'Nothing has been imported for this shift yet. Ask an '
|
||||||
'offline.',
|
'admin to sign in and pull the catalogue — once they '
|
||||||
|
'have, sign in again and everything runs offline.'
|
||||||
|
: 'This terminal has no catalogue yet. Pull the current '
|
||||||
|
'products once at the start of your shift — after that '
|
||||||
|
'everything runs offline.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
|
|
||||||
if (running) ...[
|
if (!cashier && state is ImportRunning) ...[
|
||||||
Text(
|
Text(
|
||||||
state.stage,
|
state.stage,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (state is ImportFailed) ...[
|
if (!cashier && state is ImportFailed) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||||
@@ -171,6 +183,34 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
if (cashier)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.infoSurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.admin_panel_settings_outlined,
|
||||||
|
size: 18, color: AppColors.info,),
|
||||||
|
SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Importing the catalogue is an admin job. Nothing '
|
||||||
|
'can be billed here until it has been done.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.info,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: 'Import catalogue now',
|
label: 'Import catalogue now',
|
||||||
icon: Icons.cloud_download_rounded,
|
icon: Icons.cloud_download_rounded,
|
||||||
@@ -189,6 +229,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
label: const Text('Open Product Import'),
|
label: const Text('Open Product Import'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import '../../../app/providers.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/widgets/numeric_keypad.dart';
|
import '../../../core/widgets/numeric_keypad.dart';
|
||||||
import '../../../domain/entities/store_account.dart';
|
|
||||||
|
|
||||||
/// Prompts for an admin PIN before a theft-sensitive cart action — taking a
|
/// Prompts for the removal PIN before taking a rung item back off a bill, and
|
||||||
/// scanned item back out of the bill, or clearing it — and resolves `true`
|
/// resolves `true` only once it verifies.
|
||||||
/// only once a PIN belonging to an [StaffRole.admin] account is verified.
|
///
|
||||||
|
/// The PIN is the one an admin sets in Settings, and an admin's own staff PIN
|
||||||
|
/// always works too — so a cashier can void a line at the counter without an
|
||||||
|
/// admin walking over, and the owner is never locked out of their own till.
|
||||||
///
|
///
|
||||||
/// A cashier can always start a brand new sale; what this exists to stop is
|
/// A cashier can always start a brand new sale; what this exists to stop is
|
||||||
/// quietly taking something back out of a bill a customer has already been
|
/// quietly taking something back out of a bill a customer has already been
|
||||||
/// shown, after it was rung up.
|
/// shown, after it was rung up.
|
||||||
Future<bool> requireAdminPin(
|
Future<bool> requireVoidPin(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref, {
|
WidgetRef ref, {
|
||||||
required String reason,
|
required String reason,
|
||||||
@@ -67,11 +69,11 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final store = ref.read(localStoreProvider);
|
final store = ref.read(localStoreProvider);
|
||||||
final user = await store.staff.authenticate(_pin);
|
final ok = await store.voidPin.verify(_pin);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (user == null) {
|
if (!ok) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_checking = false;
|
_checking = false;
|
||||||
_error = 'Incorrect PIN.';
|
_error = 'Incorrect PIN.';
|
||||||
@@ -80,16 +82,6 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role != StaffRole.admin) {
|
|
||||||
setState(() {
|
|
||||||
_checking = false;
|
|
||||||
_error = "${user.name}'s PIN is ${user.role.label.toLowerCase()} — "
|
|
||||||
'this needs an admin.';
|
|
||||||
_pin = '';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +104,7 @@ class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
|
|||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Admin PIN required',
|
'Removal PIN required',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
|
import '../../../core/widgets/brand_mark.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../sync/widgets/sign_out_dialog.dart';
|
|
||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
@@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final section in NavSection.values)
|
// Sections with nothing this session may open are not
|
||||||
|
// rendered as empty headings.
|
||||||
|
for (final section in ref.watch(visibleSectionsProvider))
|
||||||
_Section(
|
_Section(
|
||||||
section: section,
|
section: section,
|
||||||
expanded: expanded,
|
expanded: expanded,
|
||||||
@@ -69,8 +71,6 @@ class AppSidebar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
|
||||||
_LogoutTile(expanded: expanded),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -94,23 +94,7 @@ class _Brand extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
const BrandMark(size: 36),
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: const Text(
|
|
||||||
'N',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (expanded) ...[
|
if (expanded) ...[
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -228,7 +212,8 @@ class _Section extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final active = ref.watch(activeModuleProvider);
|
final active = ref.watch(resolvedModuleProvider);
|
||||||
|
final visible = ref.watch(visibleModulesProvider);
|
||||||
final cartCount = ref.watch(cartItemCountProvider);
|
final cartCount = ref.watch(cartItemCountProvider);
|
||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
@@ -255,7 +240,7 @@ class _Section extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Divider(height: 1),
|
child: Divider(height: 1),
|
||||||
),
|
),
|
||||||
for (final module in section.modules)
|
for (final module in section.modules.where(visible.contains))
|
||||||
_NavTile(
|
_NavTile(
|
||||||
module: module,
|
module: module,
|
||||||
expanded: expanded,
|
expanded: expanded,
|
||||||
@@ -434,48 +419,3 @@ class _Badge extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LogoutTile extends ConsumerWidget {
|
|
||||||
const _LogoutTile({required this.expanded});
|
|
||||||
|
|
||||||
final bool expanded;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => showSignOutDialog(context, ref),
|
|
||||||
borderRadius: AppRadius.brSm,
|
|
||||||
child: Container(
|
|
||||||
height: AppSizes.navItemHeight,
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: expanded ? AppSpacing.md : 0,
|
|
||||||
),
|
|
||||||
alignment: expanded ? Alignment.centerLeft : Alignment.center,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.logout_rounded,
|
|
||||||
size: 19, color: AppColors.danger,),
|
|
||||||
if (expanded) ...[
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
const Text(
|
|
||||||
'Logout',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.danger,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import '../../customer/widgets/customer_capture_sheet.dart';
|
|||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
import 'admin_pin_dialog.dart';
|
import 'admin_pin_dialog.dart';
|
||||||
import 'cart_line_tile.dart';
|
import 'cart_line_tile.dart';
|
||||||
import 'discount_sheet.dart';
|
|
||||||
|
|
||||||
/// Always-visible bill on the right of the dashboard.
|
/// Always-visible bill on the right of the dashboard.
|
||||||
class BillingPanel extends ConsumerWidget {
|
class BillingPanel extends ConsumerWidget {
|
||||||
@@ -66,8 +65,6 @@ class BillingPanel extends ConsumerWidget {
|
|||||||
controller,
|
controller,
|
||||||
line.product.id,
|
line.product.id,
|
||||||
),
|
),
|
||||||
onDiscount: () =>
|
|
||||||
showLineDiscountSheet(context, ref, line),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -79,37 +76,24 @@ class BillingPanel extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Once an item is on the bill, taking it back off needs an admin's
|
/// Once an item is on the bill, taking it back off needs the removal PIN an
|
||||||
/// approval — a cashier can always start an entirely new sale instead. This
|
/// admin sets in Settings — a cashier can always start an entirely new sale
|
||||||
/// is the one gate both removal paths (a single line, or the whole cart) go
|
/// instead. This is the one gate every removal path goes through, so the
|
||||||
/// through, so they can never drift out of sync with each other.
|
/// close button and the swipe can never drift apart.
|
||||||
Future<void> _removeLine(
|
Future<void> _removeLine(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
CartController controller,
|
CartController controller,
|
||||||
String productId,
|
String productId,
|
||||||
) async {
|
) async {
|
||||||
final ok = await requireAdminPin(
|
final ok = await requireVoidPin(
|
||||||
context,
|
context,
|
||||||
ref,
|
ref,
|
||||||
reason: 'Removing a scanned item from the bill needs admin approval.',
|
reason: 'Removing a scanned item from the bill needs the removal PIN.',
|
||||||
);
|
);
|
||||||
if (ok) controller.removeLine(productId);
|
if (ok) controller.removeLine(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearCart(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
CartController controller,
|
|
||||||
) async {
|
|
||||||
final ok = await requireAdminPin(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
reason: 'Clearing the whole bill needs admin approval.',
|
|
||||||
);
|
|
||||||
if (ok) controller.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Header extends ConsumerWidget {
|
class _Header extends ConsumerWidget {
|
||||||
const _Header({required this.cart, required this.inSheet});
|
const _Header({required this.cart, required this.inSheet});
|
||||||
|
|
||||||
@@ -118,7 +102,6 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final controller = ref.read(cartControllerProvider.notifier);
|
|
||||||
// Same fixed height as the page header on the left, so the two bars
|
// Same fixed height as the page header on the left, so the two bars
|
||||||
// line up on one visual line instead of the cart title floating lower.
|
// line up on one visual line instead of the cart title floating lower.
|
||||||
final contentPadding = PosLayout.of(context).contentPadding;
|
final contentPadding = PosLayout.of(context).contentPadding;
|
||||||
@@ -127,8 +110,9 @@ class _Header extends ConsumerWidget {
|
|||||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||||
padding: EdgeInsets.symmetric(horizontal: contentPadding),
|
padding: EdgeInsets.symmetric(horizontal: contentPadding),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
|
// Title and count read as one label, so they sit together rather
|
||||||
|
// than being pushed to opposite ends by a space-between row.
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Cart',
|
'Cart',
|
||||||
@@ -137,9 +121,12 @@ class _Header extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (cart.isNotEmpty) ...[
|
if (cart.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.sm,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.primarySurface,
|
color: AppColors.primarySurface,
|
||||||
borderRadius: AppRadius.brPill,
|
borderRadius: AppRadius.brPill,
|
||||||
@@ -154,43 +141,14 @@ class _Header extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
SizedBox(),
|
|
||||||
SizedBox(),
|
|
||||||
|
|
||||||
Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
|
// Undo, Park and Clear used to sit here as three icon buttons. They
|
||||||
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
// are the least-pressed controls on the panel and they were the
|
||||||
// Grouped tight with even spacing, flush against the same right
|
// first thing the eye landed on, above the bill itself. Undo is on
|
||||||
// edge the header buttons on the left use.
|
// F8; Park and Clear moved down beside the total, next to the button
|
||||||
Row(
|
// a cashier is already reaching for.
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (controller.canUndo)
|
|
||||||
_IconAction(
|
|
||||||
icon: Icons.undo_rounded,
|
|
||||||
tooltip: 'Undo (F8)',
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
onTap: controller.undo,
|
|
||||||
),
|
|
||||||
if (cart.isNotEmpty) ...[
|
|
||||||
_IconAction(
|
|
||||||
icon: Icons.pause_circle_outline_rounded,
|
|
||||||
tooltip: 'Park bill',
|
|
||||||
color: AppColors.warning,
|
|
||||||
onTap: () async {
|
|
||||||
await controller.park();
|
|
||||||
ref.invalidate(parkedBillsProvider);
|
|
||||||
if (context.mounted) context.showSnack('Bill parked');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_IconAction(
|
|
||||||
icon: Icons.delete_outline_rounded,
|
|
||||||
tooltip: 'Clear bill',
|
|
||||||
color: AppColors.danger,
|
|
||||||
onTap: () => _clearCart(context, ref, controller),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (inSheet)
|
if (inSheet)
|
||||||
_IconAction(
|
_IconAction(
|
||||||
icon: Icons.close_rounded,
|
icon: Icons.close_rounded,
|
||||||
@@ -200,8 +158,6 @@ class _Header extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,21 +269,6 @@ class _Summary extends ConsumerWidget {
|
|||||||
.join(', '),
|
.join(', '),
|
||||||
),
|
),
|
||||||
|
|
||||||
InkWell(
|
|
||||||
onTap: () => showBillDiscountSheet(context, ref),
|
|
||||||
borderRadius: AppRadius.brXs,
|
|
||||||
child: _Row(
|
|
||||||
label: 'Discount',
|
|
||||||
value: cart.manualBillDiscountAmount > 0
|
|
||||||
? '-${Formatters.money(cart.manualBillDiscountAmount)}'
|
|
||||||
: '-${Formatters.money(0)}',
|
|
||||||
valueColor: cart.manualBillDiscountAmount > 0
|
|
||||||
? AppColors.success
|
|
||||||
: null,
|
|
||||||
trailingIcon: Icons.edit_outlined,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
|
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => cart.pointsRedeemed > 0
|
onTap: () => cart.pointsRedeemed > 0
|
||||||
@@ -466,18 +407,23 @@ class _Actions extends ConsumerWidget {
|
|||||||
AppSpacing.xl,
|
AppSpacing.xl,
|
||||||
AppSpacing.xl,
|
AppSpacing.xl,
|
||||||
),
|
),
|
||||||
child: PrimaryButton(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
PrimaryButton(
|
||||||
label: 'CHARGE',
|
label: 'CHARGE',
|
||||||
large: true,
|
large: true,
|
||||||
onPressed: enabled
|
onPressed: enabled
|
||||||
? () async {
|
? () async {
|
||||||
// Ask once per bill, before payment. Skipping is one tap and
|
// Ask once per bill, before payment. Skipping is one tap
|
||||||
// leaves the sale as walk-in.
|
// and leaves the sale as walk-in.
|
||||||
if (ref.read(cartControllerProvider).customer == null) {
|
if (ref.read(cartControllerProvider).customer == null) {
|
||||||
await showCustomerCaptureSheet(context);
|
await showCustomerCaptureSheet(context);
|
||||||
}
|
}
|
||||||
// Navigation result is not needed here.
|
// Navigation result is not needed here.
|
||||||
if (context.mounted) unawaited(context.push(AppRoutes.payment));
|
if (context.mounted) {
|
||||||
|
unawaited(context.push(AppRoutes.payment));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
trailing: enabled
|
trailing: enabled
|
||||||
@@ -487,6 +433,8 @@ class _Actions extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget {
|
|||||||
required this.onIncrement,
|
required this.onIncrement,
|
||||||
required this.onDecrement,
|
required this.onDecrement,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
this.onDiscount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final CartLine line;
|
final CartLine line;
|
||||||
final VoidCallback onIncrement;
|
final VoidCallback onIncrement;
|
||||||
final VoidCallback onDecrement;
|
final VoidCallback onDecrement;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
final VoidCallback? onDiscount;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget {
|
|||||||
return Dismissible(
|
return Dismissible(
|
||||||
key: ValueKey('dismiss_${p.id}'),
|
key: ValueKey('dismiss_${p.id}'),
|
||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
onDismissed: (_) => onRemove(),
|
// Confirm rather than dismiss: [onRemove] opens the PIN dialog, and a
|
||||||
|
// refused PIN must leave the line exactly where it was. Dismissing first
|
||||||
|
// and asking after left the row gone from the screen but still in the
|
||||||
|
// cart — and Flutter asserting about a dismissed widget still in the
|
||||||
|
// tree. Returning false always is correct: when the PIN is accepted the
|
||||||
|
// line disappears because the cart changed, not because of the swipe.
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
onRemove();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
background: Container(
|
background: Container(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: AppSpacing.xl),
|
padding: const EdgeInsets.only(right: AppSpacing.xl),
|
||||||
@@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget {
|
|||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
tooltip: 'Remove',
|
tooltip: 'Remove from bill (needs the removal PIN)',
|
||||||
),
|
),
|
||||||
],),
|
],),
|
||||||
|
|
||||||
@@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget {
|
|||||||
onIncrement: onIncrement,
|
onIncrement: onIncrement,
|
||||||
onDecrement: onDecrement,
|
onDecrement: onDecrement,
|
||||||
),
|
),
|
||||||
if (onDiscount != null) ...[
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
IconButton(
|
|
||||||
onPressed: onDiscount,
|
|
||||||
icon: const Icon(Icons.local_offer_outlined, size: 17),
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
tooltip: 'Line discount',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
@@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget {
|
|||||||
borderRadius: AppRadius.brSm,
|
borderRadius: AppRadius.brSm,
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
|
// Minus stops at one rather than emptying the line. Dropping to zero
|
||||||
|
// was a silent removal that skipped the PIN the close button asks for —
|
||||||
|
// two taps of a stepper should not be a way around the till's only
|
||||||
|
// theft control.
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
_btn(Icons.remove_rounded, onDecrement),
|
_btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null),
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(minWidth: 42),
|
constraints: const BoxConstraints(minWidth: 42),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
@@ -226,7 +226,7 @@ class _Stepper extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _btn(IconData icon, VoidCallback onTap) => Material(
|
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
child: Icon(icon, size: 17, color: AppColors.primary),
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 17,
|
||||||
|
color: onTap == null
|
||||||
|
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||||||
|
: AppColors.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../../../core/theme/app_dimens.dart';
|
|
||||||
import '../../../core/utils/formatters.dart';
|
|
||||||
import '../../../core/widgets/primary_button.dart';
|
|
||||||
import '../../../domain/entities/cart.dart';
|
|
||||||
import '../providers/cart_controller.dart';
|
|
||||||
|
|
||||||
Future<void> showLineDiscountSheet(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
CartLine line,
|
|
||||||
) {
|
|
||||||
return _show(
|
|
||||||
context: context,
|
|
||||||
title: line.product.name,
|
|
||||||
subtitle: 'Line value ${Formatters.money(line.grossAmount)}',
|
|
||||||
current: line.discount,
|
|
||||||
onApply: (d) => ref
|
|
||||||
.read(cartControllerProvider.notifier)
|
|
||||||
.applyLineDiscount(line.product.id, d),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> showBillDiscountSheet(BuildContext context, WidgetRef ref) {
|
|
||||||
final cart = ref.read(cartControllerProvider);
|
|
||||||
return _show(
|
|
||||||
context: context,
|
|
||||||
title: 'Bill discount',
|
|
||||||
subtitle: 'Subtotal ${Formatters.money(cart.subtotal)}',
|
|
||||||
current: cart.billDiscount,
|
|
||||||
onApply: (d) =>
|
|
||||||
ref.read(cartControllerProvider.notifier).applyBillDiscount(d),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _show({
|
|
||||||
required BuildContext context,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required Discount current,
|
|
||||||
required ValueChanged<Discount> onApply,
|
|
||||||
}) {
|
|
||||||
return showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (_) => _DiscountSheet(
|
|
||||||
title: title,
|
|
||||||
subtitle: subtitle,
|
|
||||||
current: current,
|
|
||||||
onApply: onApply,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DiscountSheet extends StatefulWidget {
|
|
||||||
const _DiscountSheet({
|
|
||||||
required this.title,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.current,
|
|
||||||
required this.onApply,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final Discount current;
|
|
||||||
final ValueChanged<Discount> onApply;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_DiscountSheet> createState() => _DiscountSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DiscountSheetState extends State<_DiscountSheet> {
|
|
||||||
late DiscountType _type =
|
|
||||||
widget.current.type == DiscountType.none
|
|
||||||
? DiscountType.percentage
|
|
||||||
: widget.current.type;
|
|
||||||
late final TextEditingController _value = TextEditingController(
|
|
||||||
text: widget.current.isActive
|
|
||||||
? widget.current.value.toStringAsFixed(0)
|
|
||||||
: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_value.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _apply() {
|
|
||||||
final v = double.tryParse(_value.text.trim()) ?? 0;
|
|
||||||
widget.onApply(
|
|
||||||
v <= 0 ? Discount.none : Discount(type: _type, value: v),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.surface,
|
|
||||||
borderRadius:
|
|
||||||
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
|
||||||
),
|
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.border,
|
|
||||||
borderRadius: AppRadius.brPill,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
|
||||||
|
|
||||||
Text(widget.title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(widget.subtitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),),
|
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
|
||||||
|
|
||||||
SegmentedButton<DiscountType>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: DiscountType.percentage,
|
|
||||||
label: Text('Percent'),
|
|
||||||
icon: Icon(Icons.percent_rounded, size: 17),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: DiscountType.flat,
|
|
||||||
label: Text('Flat'),
|
|
||||||
icon: Icon(Icons.currency_rupee_rounded, size: 17),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_type},
|
|
||||||
onSelectionChanged: (s) => setState(() => _type = s.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
controller: _value,
|
|
||||||
autofocus: true,
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [
|
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
|
|
||||||
],
|
|
||||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: '0',
|
|
||||||
prefixText: _type == DiscountType.flat ? '₹ ' : null,
|
|
||||||
suffixText: _type == DiscountType.percentage ? '%' : null,
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _apply(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
|
|
||||||
Wrap(
|
|
||||||
spacing: AppSpacing.sm,
|
|
||||||
children: (_type == DiscountType.percentage
|
|
||||||
? const [5, 10, 15, 20, 25]
|
|
||||||
: const [10, 20, 50, 100, 200])
|
|
||||||
.map((v) => ActionChip(
|
|
||||||
label: Text(_type == DiscountType.percentage
|
|
||||||
? '$v%'
|
|
||||||
: '₹$v',),
|
|
||||||
onPressed: () =>
|
|
||||||
setState(() => _value.text = v.toString()),
|
|
||||||
),)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
|
||||||
|
|
||||||
Row(children: [
|
|
||||||
Expanded(
|
|
||||||
child: PrimaryButton(
|
|
||||||
label: 'Remove',
|
|
||||||
tone: ButtonTone.neutral,
|
|
||||||
onPressed: () {
|
|
||||||
widget.onApply(Discount.none);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: PrimaryButton(
|
|
||||||
label: 'Apply discount',
|
|
||||||
icon: Icons.check_rounded,
|
|
||||||
onPressed: _apply,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],),
|
|
||||||
],),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
|
import '../../../core/widgets/brand_mark.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
import '../../shift/widgets/session_end_sheet.dart';
|
||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
|
|
||||||
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, box) {
|
builder: (context, box) {
|
||||||
final showStatus = box.maxWidth >= 720;
|
final showStatus = box.maxWidth >= 720;
|
||||||
return _bar(context, ref, compact, showStatus);
|
// The brand block only earns its space once the bar is genuinely wide;
|
||||||
|
// below that it would push the actions off the end.
|
||||||
|
final showBrand = box.maxWidth >= 1000;
|
||||||
|
return _bar(context, ref, compact, showStatus, showBrand);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,10 +48,14 @@ class PageHeader extends ConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
bool compact,
|
bool compact,
|
||||||
bool showStatus,
|
bool showStatus,
|
||||||
|
bool showBrand,
|
||||||
) {
|
) {
|
||||||
final module = ref.watch(activeModuleProvider);
|
|
||||||
final now = ref.watch(clockProvider).value ?? DateTime.now();
|
final now = ref.watch(clockProvider).value ?? DateTime.now();
|
||||||
|
|
||||||
|
// With no sidebar there is nothing else on screen carrying the brand, the
|
||||||
|
// sync log or the way out — so all three are promoted into this bar.
|
||||||
|
final cashierMode = ref.watch(isCashierModeProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (compact) ...[
|
if (compact && onMenuTap != null) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onMenuTap,
|
onPressed: onMenuTap,
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
@@ -68,7 +78,12 @@ class PageHeader extends ConsumerWidget {
|
|||||||
const SizedBox(width: AppSpacing.xs),
|
const SizedBox(width: AppSpacing.xs),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Dropped first when the bar gets tight: the actions are what the
|
||||||
|
// counter actually presses.
|
||||||
|
if (cashierMode && showBrand) ...[
|
||||||
|
const _CashierBrand(),
|
||||||
|
const SizedBox(width: AppSpacing.lg),
|
||||||
|
],
|
||||||
|
|
||||||
if (showStatus) ...[
|
if (showStatus) ...[
|
||||||
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
||||||
@@ -82,14 +97,26 @@ class PageHeader extends ConsumerWidget {
|
|||||||
fontFeatures: [FontFeature.tabularFigures()],
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.lg),
|
|
||||||
Container(width: 1, height: 26, color: AppColors.border),
|
|
||||||
const SizedBox(width: AppSpacing.lg),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// The bar is always the same shape: status on the left, actions
|
||||||
|
// pinned to the right, for admin and cashier alike. Packed left with
|
||||||
|
// a divider between them, the actions landed in a different place on
|
||||||
|
// every screen — mid-bar on a wide admin window, hard left on a
|
||||||
|
// narrow one — and the two roles never agreed with each other.
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
_ParkedBillsButton(compact: compact),
|
_ParkedBillsButton(compact: compact),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
_NewSaleButton(compact: compact),
|
_NewSaleButton(compact: compact),
|
||||||
|
|
||||||
|
// Every role signs out from here. It used to sit at the foot of the
|
||||||
|
// sidebar for admins and up here for cashiers, which meant the same
|
||||||
|
// action lived in two places depending on who was holding the till.
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Container(width: 1, height: 26, color: AppColors.border),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
const _LogoutButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -366,3 +393,77 @@ class _NewSaleButton extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Brand and outlet name, shown only in cashier mode.
|
||||||
|
///
|
||||||
|
/// The sidebar normally carries these; without it the bar reads as a fragment
|
||||||
|
/// of an app rather than the top of one.
|
||||||
|
class _CashierBrand extends ConsumerWidget {
|
||||||
|
const _CashierBrand();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final store = ref.watch(currentStoreProvider);
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const BrandMark(size: 32),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 170),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
store?.name ?? 'Nearle POS',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
user == null ? 'Cashier' : '${user.name} · Cashier',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
height: 1.25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signing out, for both roles.
|
||||||
|
///
|
||||||
|
/// Opens the session-end chooser rather than signing out directly: a cashier
|
||||||
|
/// stepping away for ten minutes and a cashier finishing for the day want two
|
||||||
|
/// very different things to happen to the drawer and the catalogue.
|
||||||
|
class _LogoutButton extends ConsumerWidget {
|
||||||
|
const _LogoutButton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Sign out',
|
||||||
|
onPressed: () => showSessionEndSheet(context, ref),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.danger,
|
||||||
|
backgroundColor: AppColors.dangerSurface,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.logout_rounded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
823
lib/presentation/shift/screens/end_shift_screen.dart
Normal file
823
lib/presentation/shift/screens/end_shift_screen.dart
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/router/app_router.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/theme/app_typography.dart';
|
||||||
|
import '../../../core/utils/formatters.dart';
|
||||||
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../domain/entities/shift_report.dart';
|
||||||
|
import '../../../domain/entities/transaction.dart';
|
||||||
|
import '../../../domain/repositories/sync_repository.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
import '../../payment/screens/payment_screen.dart' show methodIcon;
|
||||||
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
|
import '../../pos/providers/navigation_provider.dart';
|
||||||
|
import '../../sync/providers/sync_controller.dart';
|
||||||
|
|
||||||
|
/// Closing the till.
|
||||||
|
///
|
||||||
|
/// Three things have to happen at the end of a shift and they have to happen
|
||||||
|
/// in this order: count what is physically in the drawer, compare it against
|
||||||
|
/// what the terminal says was taken in cash, then push the day up and hand the
|
||||||
|
/// terminal back. Doing it as a dialog meant the count was a single guessed
|
||||||
|
/// number typed into a box; a shift is worth its own screen.
|
||||||
|
///
|
||||||
|
/// The variance is the whole point. A till that is short is worth knowing
|
||||||
|
/// about while the person who worked it is still standing there.
|
||||||
|
class EndShiftScreen extends ConsumerStatefulWidget {
|
||||||
|
const EndShiftScreen({super.key});
|
||||||
|
|
||||||
|
/// Below this the two columns stack.
|
||||||
|
static const double twoColumnAbove = 1000;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<EndShiftScreen> createState() => _EndShiftScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a till drawer actually holds, largest first.
|
||||||
|
const _denominations = <int>[2000, 500, 200, 100, 50, 20, 10, 5, 2, 1];
|
||||||
|
|
||||||
|
class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
|
||||||
|
/// Note or coin value → how many were counted.
|
||||||
|
final Map<int, int> _counted = {};
|
||||||
|
|
||||||
|
final _openingFloat = TextEditingController(text: '0');
|
||||||
|
bool _pushing = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_openingFloat.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _countedTotal => _counted.entries
|
||||||
|
.fold(0.0, (sum, e) => sum + e.key * e.value);
|
||||||
|
|
||||||
|
double get _float => double.tryParse(_openingFloat.text.trim()) ?? 0;
|
||||||
|
|
||||||
|
int get _noteCount => _counted.values.fold(0, (sum, n) => sum + n);
|
||||||
|
|
||||||
|
void _set(int denomination, int count) {
|
||||||
|
setState(() {
|
||||||
|
if (count <= 0) {
|
||||||
|
_counted.remove(denomination);
|
||||||
|
} else {
|
||||||
|
_counted[denomination] = count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cash the terminal believes was taken, from the tender records — not from
|
||||||
|
/// the bill totals, which include card and UPI.
|
||||||
|
double _cashTaken(ShiftReport? report) =>
|
||||||
|
report?.paymentBreakdown[PaymentMethod.cash] ?? 0;
|
||||||
|
|
||||||
|
/// Pushes what is still held, then ends the session and clears the terminal.
|
||||||
|
Future<void> _finish({required bool sync}) async {
|
||||||
|
setState(() {
|
||||||
|
_pushing = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
final outcome = await ref.read(orderSyncProvider.notifier).run();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!outcome.isSuccess) {
|
||||||
|
setState(() {
|
||||||
|
_pushing = false;
|
||||||
|
_error = outcome.error ??
|
||||||
|
'Upload failed. Every bill is still stored on this terminal.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(cartControllerProvider.notifier).reset();
|
||||||
|
|
||||||
|
// The real end of shift: the catalogue goes with it, so the next person
|
||||||
|
// bills against a fresh import rather than this morning's prices.
|
||||||
|
await ref.read(authControllerProvider.notifier).signOut();
|
||||||
|
|
||||||
|
ref.read(catalogueVersionProvider.notifier).state++;
|
||||||
|
ref.invalidate(allProductsProvider);
|
||||||
|
ref.invalidate(visibleProductsProvider);
|
||||||
|
ref.invalidate(categoryCountsProvider);
|
||||||
|
ref.invalidate(lowStockProductsProvider);
|
||||||
|
|
||||||
|
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||||
|
ref.read(searchQueryProvider.notifier).state = '';
|
||||||
|
ref.read(selectedCategoryProvider.notifier).state = null;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go(AppRoutes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final report = ref.watch(myShiftReportProvider).value;
|
||||||
|
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
final cashTaken = _cashTaken(report);
|
||||||
|
final expected = _float + cashTaken;
|
||||||
|
final variance = _countedTotal - expected;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('End shift'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: _pushing ? null : () => context.pop(),
|
||||||
|
tooltip: 'Back to the till',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: LayoutBuilder(
|
||||||
|
builder: (context, box) {
|
||||||
|
final twoColumn = box.maxWidth >= EndShiftScreen.twoColumnAbove;
|
||||||
|
final pad = box.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
|
||||||
|
|
||||||
|
final count = _CountPanel(
|
||||||
|
counted: _counted,
|
||||||
|
openingFloat: _openingFloat,
|
||||||
|
enabled: !_pushing,
|
||||||
|
total: _countedTotal,
|
||||||
|
noteCount: _noteCount,
|
||||||
|
onChanged: _set,
|
||||||
|
onFloatChanged: () => setState(() {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final review = _ReviewPanel(
|
||||||
|
report: report,
|
||||||
|
user: user?.name,
|
||||||
|
openingFloat: _float,
|
||||||
|
cashTaken: cashTaken,
|
||||||
|
expected: expected,
|
||||||
|
counted: _countedTotal,
|
||||||
|
variance: variance,
|
||||||
|
pending: pending,
|
||||||
|
error: _error,
|
||||||
|
);
|
||||||
|
|
||||||
|
final minHeight = box.maxHeight.isFinite
|
||||||
|
? (box.maxHeight - pad * 2).clamp(0.0, double.infinity)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(pad),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: minHeight),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1240),
|
||||||
|
child: twoColumn
|
||||||
|
? Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: count),
|
||||||
|
const SizedBox(width: AppSpacing.lg),
|
||||||
|
Expanded(child: review),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
count,
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
review,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _bottomBar(pending, variance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _bottomBar(int pending, double variance) {
|
||||||
|
final counted = _noteCount > 0;
|
||||||
|
final short = variance < -0.5;
|
||||||
|
final over = variance > 0.5;
|
||||||
|
|
||||||
|
// The gate. A shift closes on a drawer that reconciles and on nothing
|
||||||
|
// else — an uncounted or mismatched till is settled at the counter, with
|
||||||
|
// the person who worked it still there, not discovered by the back office
|
||||||
|
// the next morning.
|
||||||
|
final balanced = counted && !short && !over;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.lg,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
border: Border(top: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
counted
|
||||||
|
? (short || over
|
||||||
|
? Icons.error_outline_rounded
|
||||||
|
: Icons.check_circle_outline_rounded)
|
||||||
|
: Icons.info_outline_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: !counted
|
||||||
|
? AppColors.textTertiary
|
||||||
|
: (short
|
||||||
|
? AppColors.danger
|
||||||
|
: (over ? AppColors.warning : AppColors.success)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
!counted
|
||||||
|
? 'Count the drawer. The shift cannot be ended until '
|
||||||
|
'it matches the expected amount.'
|
||||||
|
: short
|
||||||
|
? 'The drawer is '
|
||||||
|
'${Formatters.money(variance.abs())} short. '
|
||||||
|
'Recount, or settle the difference — the '
|
||||||
|
'shift cannot be ended while it is off.'
|
||||||
|
: over
|
||||||
|
? 'The drawer is '
|
||||||
|
'${Formatters.money(variance)} over. '
|
||||||
|
'Recount — the shift cannot be ended '
|
||||||
|
'while it is off.'
|
||||||
|
: 'The drawer matches what was rung. Ready to '
|
||||||
|
'end the shift.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
PrimaryButton(
|
||||||
|
label: !balanced
|
||||||
|
? 'Drawer must match to end shift'
|
||||||
|
: pending > 0
|
||||||
|
? 'Upload $pending bill(s) & end shift'
|
||||||
|
: 'End shift',
|
||||||
|
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
|
||||||
|
large: true,
|
||||||
|
busy: _pushing,
|
||||||
|
onPressed: (_pushing || !balanced)
|
||||||
|
? null
|
||||||
|
: () => _finish(sync: pending > 0),
|
||||||
|
),
|
||||||
|
if (pending > 0) ...[
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
TextButton(
|
||||||
|
// Skipping the upload is still allowed; skipping the count is
|
||||||
|
// not, so this is gated on the same condition.
|
||||||
|
onPressed:
|
||||||
|
(_pushing || !balanced) ? null : () => _finish(sync: false),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
child: const Text('End shift without uploading'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Count
|
||||||
|
class _CountPanel extends StatelessWidget {
|
||||||
|
const _CountPanel({
|
||||||
|
required this.counted,
|
||||||
|
required this.openingFloat,
|
||||||
|
required this.enabled,
|
||||||
|
required this.total,
|
||||||
|
required this.noteCount,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onFloatChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<int, int> counted;
|
||||||
|
final TextEditingController openingFloat;
|
||||||
|
final bool enabled;
|
||||||
|
final double total;
|
||||||
|
final int noteCount;
|
||||||
|
final void Function(int denomination, int count) onChanged;
|
||||||
|
final VoidCallback onFloatChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _Panel(
|
||||||
|
title: 'Count the drawer',
|
||||||
|
subtitle: 'Tap the notes and coins you are holding. Nothing is '
|
||||||
|
'submitted until you end the shift.',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: openingFloat,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
onChanged: (_) => onFloatChanged(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Opening float',
|
||||||
|
helperText: 'What was in the drawer before trading started',
|
||||||
|
prefixText: '₹ ',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
|
||||||
|
for (final value in _denominations)
|
||||||
|
_DenominationRow(
|
||||||
|
value: value,
|
||||||
|
count: counted[value] ?? 0,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (n) => onChanged(value, n),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
noteCount == 0
|
||||||
|
? 'Counted so far'
|
||||||
|
: 'Counted so far · $noteCount piece(s)',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
Formatters.money(total),
|
||||||
|
style: AppTypography.money(20, color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DenominationRow extends StatelessWidget {
|
||||||
|
const _DenominationRow({
|
||||||
|
required this.value,
|
||||||
|
required this.count,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
final int count;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final subtotal = value * count;
|
||||||
|
final active = count > 0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 74,
|
||||||
|
child: Text(
|
||||||
|
'₹$value',
|
||||||
|
style: AppTypography.money(
|
||||||
|
15,
|
||||||
|
color: active ? AppColors.textPrimary : AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'×',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
_Stepper(
|
||||||
|
count: count,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
active ? Formatters.money(subtotal.toDouble()) : '—',
|
||||||
|
style: AppTypography.money(
|
||||||
|
14,
|
||||||
|
color: active ? AppColors.textPrimary : AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Stepper extends StatelessWidget {
|
||||||
|
const _Stepper({
|
||||||
|
required this.count,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_btn(
|
||||||
|
Icons.remove_rounded,
|
||||||
|
enabled && count > 0 ? () => onChanged(count - 1) : null,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 38),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text('$count', style: AppTypography.money(14.5)),
|
||||||
|
),
|
||||||
|
_btn(
|
||||||
|
Icons.add_rounded,
|
||||||
|
enabled ? () => onChanged(count + 1) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 16,
|
||||||
|
color: onTap == null
|
||||||
|
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||||||
|
: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Review
|
||||||
|
class _ReviewPanel extends StatelessWidget {
|
||||||
|
const _ReviewPanel({
|
||||||
|
required this.report,
|
||||||
|
required this.user,
|
||||||
|
required this.openingFloat,
|
||||||
|
required this.cashTaken,
|
||||||
|
required this.expected,
|
||||||
|
required this.counted,
|
||||||
|
required this.variance,
|
||||||
|
required this.pending,
|
||||||
|
required this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ShiftReport? report;
|
||||||
|
final String? user;
|
||||||
|
final double openingFloat;
|
||||||
|
final double cashTaken;
|
||||||
|
final double expected;
|
||||||
|
final double counted;
|
||||||
|
final double variance;
|
||||||
|
final int pending;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final short = variance < -0.5;
|
||||||
|
final over = variance > 0.5;
|
||||||
|
final tone = counted == 0
|
||||||
|
? AppColors.textTertiary
|
||||||
|
: (short ? AppColors.danger : (over ? AppColors.warning
|
||||||
|
: AppColors.success));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_Panel(
|
||||||
|
title: 'Cash drawer',
|
||||||
|
subtitle: 'What the terminal expects, against what you counted.',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_row('Opening float', Formatters.money(openingFloat)),
|
||||||
|
_row('Cash sales today', Formatters.money(cashTaken)),
|
||||||
|
const Divider(height: AppSpacing.xl),
|
||||||
|
_row(
|
||||||
|
'Expected in drawer',
|
||||||
|
Formatters.money(expected),
|
||||||
|
strong: true,
|
||||||
|
),
|
||||||
|
_row('You counted', Formatters.money(counted)),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: counted == 0
|
||||||
|
? AppColors.surfaceAlt
|
||||||
|
: (short
|
||||||
|
? AppColors.dangerSurface
|
||||||
|
: (over
|
||||||
|
? AppColors.warningSurface
|
||||||
|
: AppColors.successSurface)),
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
counted == 0
|
||||||
|
? 'Not counted yet'
|
||||||
|
: (short
|
||||||
|
? 'Short'
|
||||||
|
: (over ? 'Over' : 'Balanced')),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: tone,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
counted == 0
|
||||||
|
? '—'
|
||||||
|
: '${variance >= 0 ? '+' : '−'}'
|
||||||
|
'${Formatters.money(variance.abs())}',
|
||||||
|
style: AppTypography.money(20, color: tone),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
_Panel(
|
||||||
|
title: 'Today at this till',
|
||||||
|
subtitle: user == null ? null : 'Rung by $user',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_row('Bills', '${report?.billCount ?? 0}'),
|
||||||
|
_row('Items sold',
|
||||||
|
(report?.itemCount ?? 0).toStringAsFixed(0),),
|
||||||
|
_row('Gross sales',
|
||||||
|
Formatters.money(report?.grossSales ?? 0),),
|
||||||
|
_row('GST collected',
|
||||||
|
Formatters.money(report?.taxCollected ?? 0),),
|
||||||
|
if ((report?.paymentBreakdown ?? const {}).isNotEmpty) ...[
|
||||||
|
const Divider(height: AppSpacing.xl),
|
||||||
|
for (final e in report!.paymentBreakdown.entries)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(methodIcon(e.key),
|
||||||
|
size: 16, color: AppColors.textSecondary,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
e.key.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(Formatters.money(e.value),
|
||||||
|
style: AppTypography.money(13.5),),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
_Panel(
|
||||||
|
title: 'Before you hand it over',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_checkRow(
|
||||||
|
pending == 0,
|
||||||
|
pending == 0
|
||||||
|
? 'Every bill has been uploaded.'
|
||||||
|
: '$pending bill(s) still on this terminal — they upload '
|
||||||
|
'when you end the shift.',
|
||||||
|
),
|
||||||
|
_checkRow(
|
||||||
|
!short && !over && counted > 0,
|
||||||
|
counted == 0
|
||||||
|
? 'Drawer not counted yet — required before the shift can '
|
||||||
|
'be ended.'
|
||||||
|
: (short || over)
|
||||||
|
? 'Drawer does not match the expected amount. The '
|
||||||
|
'shift stays open until it does.'
|
||||||
|
: 'Drawer matches the expected amount.',
|
||||||
|
),
|
||||||
|
_checkRow(
|
||||||
|
false,
|
||||||
|
'Products are removed from this terminal at the end of a '
|
||||||
|
'shift. An admin imports them again tomorrow.',
|
||||||
|
neutral: true,
|
||||||
|
),
|
||||||
|
if (error != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.dangerSurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.wifi_off_rounded,
|
||||||
|
size: 18, color: AppColors.danger,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
error!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.danger,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(String label, String value, {bool strong = false}) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: strong ? 14 : 13.5,
|
||||||
|
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
color: strong
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: AppTypography.money(
|
||||||
|
strong ? 16 : 13.5,
|
||||||
|
color: strong ? AppColors.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _checkRow(bool done, String text, {bool neutral = false}) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
neutral
|
||||||
|
? Icons.info_outline_rounded
|
||||||
|
: (done
|
||||||
|
? Icons.check_circle_outline_rounded
|
||||||
|
: Icons.radio_button_unchecked_rounded),
|
||||||
|
size: 16,
|
||||||
|
color: neutral
|
||||||
|
? AppColors.textTertiary
|
||||||
|
: (done ? AppColors.success : AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One card shape for every block on this screen, so the two columns line up
|
||||||
|
/// row for row instead of each panel inventing its own padding.
|
||||||
|
class _Panel extends StatelessWidget {
|
||||||
|
const _Panel({
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: AppRadius.brXl,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
283
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
283
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/router/app_router.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
|
import '../../pos/providers/navigation_provider.dart';
|
||||||
|
import '../../sync/providers/sync_controller.dart';
|
||||||
|
import '../../sync/widgets/sign_out_dialog.dart';
|
||||||
|
|
||||||
|
/// Asks what "signing out" means before doing it.
|
||||||
|
///
|
||||||
|
/// A cashier stepping off the counter for a few minutes and a cashier
|
||||||
|
/// finishing for the day want different things from the *shift*, not from the
|
||||||
|
/// terminal. A temporary logout leaves the shift open — bills stay queued and
|
||||||
|
/// today's totals keep accumulating — and simply locks the screen. Ending the
|
||||||
|
/// shift means counting the drawer, reconciling it, and handing the till back.
|
||||||
|
///
|
||||||
|
/// What both do identically: the products come off this terminal. A cashier
|
||||||
|
/// session never leaves a catalogue sitting on an unattended screen, so an
|
||||||
|
/// admin re-imports it before the counter is worked again.
|
||||||
|
///
|
||||||
|
/// Admins see the plain sign-out dialog: they have no drawer to settle.
|
||||||
|
Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
|
||||||
|
if (!ref.read(isCashierModeProvider)) {
|
||||||
|
return showSignOutDialog(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (_) => const _SessionEndDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionEndDialog extends ConsumerWidget {
|
||||||
|
const _SessionEndDialog();
|
||||||
|
|
||||||
|
/// Closes the session without closing the shift.
|
||||||
|
///
|
||||||
|
/// Explicitly *not* a shift end: unsynced bills stay queued and today's
|
||||||
|
/// totals keep accumulating against the same day, so the drawer is still
|
||||||
|
/// settled once, at the end. What it does not leave behind is the
|
||||||
|
/// catalogue — every cashier sign-out takes the products with it, this one
|
||||||
|
/// included, so an admin imports them again before billing resumes.
|
||||||
|
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
|
||||||
|
ref.read(cartControllerProvider.notifier).reset();
|
||||||
|
await ref.read(authControllerProvider.notifier).signOut();
|
||||||
|
|
||||||
|
// Bump the version so catalogueReadyProvider re-reads hasCatalogue, and
|
||||||
|
// drop the cached lists so the next session's grid does not flash this
|
||||||
|
// session's products before it re-checks what is on disk.
|
||||||
|
ref.read(catalogueVersionProvider.notifier).state++;
|
||||||
|
ref.invalidate(allProductsProvider);
|
||||||
|
ref.invalidate(visibleProductsProvider);
|
||||||
|
ref.invalidate(categoryCountsProvider);
|
||||||
|
ref.invalidate(lowStockProductsProvider);
|
||||||
|
|
||||||
|
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||||
|
ref.read(searchQueryProvider.notifier).state = '';
|
||||||
|
ref.read(selectedCategoryProvider.notifier).state = null;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Resolved while this context is still mounted — the messenger lives above
|
||||||
|
// the router, so the bar survives the route change below.
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.go(AppRoutes.login);
|
||||||
|
|
||||||
|
messenger
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Logged out. The shift is still open, and the product catalogue has '
|
||||||
|
'been removed from this terminal.',
|
||||||
|
),
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
|
final cart = ref.watch(cartControllerProvider);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Leaving the till'),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.lg,
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.sm,
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 460.0),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (cart.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.warningSurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber_rounded,
|
||||||
|
size: 18, color: AppColors.warning,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'The current bill has ${cart.lineCount} item(s) and '
|
||||||
|
'will be cleared either way.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.warning,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// The one thing both choices do, said once here rather than
|
||||||
|
// repeated in each card and discovered at the next login.
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.infoSurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete_sweep_outlined,
|
||||||
|
size: 18, color: AppColors.info,),
|
||||||
|
SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Either way, the product catalogue is removed from '
|
||||||
|
'this terminal. An admin imports it again before the '
|
||||||
|
'counter is worked.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.info,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_Choice(
|
||||||
|
icon: Icons.lock_clock_outlined,
|
||||||
|
tone: AppColors.info,
|
||||||
|
title: 'Temporary logout',
|
||||||
|
body: 'Locks the terminal without closing the shift. Bills '
|
||||||
|
'stay queued and today’s totals keep running, so the '
|
||||||
|
'drawer is still counted once at the end.',
|
||||||
|
onTap: () => _temporaryLogout(context, ref),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_Choice(
|
||||||
|
icon: Icons.point_of_sale_rounded,
|
||||||
|
tone: AppColors.primary,
|
||||||
|
title: 'End shift',
|
||||||
|
body: pending == 0
|
||||||
|
? 'Count the drawer, check it against what was rung, then '
|
||||||
|
'hand the till over.'
|
||||||
|
: 'Count the drawer, check it against what was rung, then '
|
||||||
|
'upload the $pending bill(s) still held here.',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.push(AppRoutes.endShift);
|
||||||
|
},
|
||||||
|
emphasised: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.xxl,
|
||||||
|
0,
|
||||||
|
AppSpacing.xxl,
|
||||||
|
AppSpacing.lg,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Stay signed in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Choice extends StatelessWidget {
|
||||||
|
const _Choice({
|
||||||
|
required this.icon,
|
||||||
|
required this.tone,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.onTap,
|
||||||
|
this.emphasised = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color tone;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool emphasised;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: emphasised ? AppColors.primarySurface : AppColors.surfaceAlt,
|
||||||
|
borderRadius: AppRadius.brLg,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: AppRadius.brLg,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: AppRadius.brLg,
|
||||||
|
border: Border.all(
|
||||||
|
color: emphasised ? AppColors.primaryBorder : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 22, color: tone),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
const Icon(Icons.chevron_right_rounded,
|
||||||
|
size: 20, color: AppColors.textTertiary,),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,18 +222,6 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
|||||||
await store.syncConfig.load(ref.read(syncConfigProvider));
|
await store.syncConfig.load(ref.read(syncConfigProvider));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bring back the session this terminal was signed in under.
|
|
||||||
//
|
|
||||||
// Runs before the engine starts, and that ordering is load-bearing: the
|
|
||||||
// session carries both the bearer token and the outlet, so a drain that began
|
|
||||||
// first would upload the day's bills unauthenticated — and, once the backend
|
|
||||||
// is enforcing, have them refused.
|
|
||||||
//
|
|
||||||
// A till signs in when a shop opens and trades all day. Without this a reboot
|
|
||||||
// mid-shift would put a login screen in front of a queue of customers, which
|
|
||||||
// is a worse outage than the one it protects against.
|
|
||||||
await ref.read(authControllerProvider.notifier).restore();
|
|
||||||
|
|
||||||
await ref.read(connectivityServiceProvider).start();
|
await ref.read(connectivityServiceProvider).start();
|
||||||
|
|
||||||
final engine = ref.read(syncEngineProvider);
|
final engine = ref.read(syncEngineProvider);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../../core/widgets/primary_button.dart';
|
|||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
import '../../pos/providers/catalog_providers.dart';
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
|
import '../../pos/providers/navigation_provider.dart';
|
||||||
import '../../../domain/repositories/sync_repository.dart';
|
import '../../../domain/repositories/sync_repository.dart';
|
||||||
import '../providers/sync_controller.dart';
|
import '../providers/sync_controller.dart';
|
||||||
|
|
||||||
@@ -38,21 +39,52 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
|||||||
|
|
||||||
Future<void> _finish() async {
|
Future<void> _finish() async {
|
||||||
ref.read(cartControllerProvider.notifier).reset();
|
ref.read(cartControllerProvider.notifier).reset();
|
||||||
|
|
||||||
|
// Read before signing out — the session that decides this is gone by the
|
||||||
|
// time signOut returns.
|
||||||
|
final cleared =
|
||||||
|
ref.read(authControllerProvider.notifier).clearsCatalogueOnSignOut;
|
||||||
|
|
||||||
await ref.read(authControllerProvider.notifier).signOut();
|
await ref.read(authControllerProvider.notifier).signOut();
|
||||||
|
|
||||||
// Mirrors what a successful import does on the way in: bump the version
|
// Mirrors what a successful import does on the way in: bump the version so
|
||||||
// so catalogueReadyProvider re-reads hasCatalogue as false, and drop the
|
// catalogueReadyProvider re-reads hasCatalogue, and drop the cached product
|
||||||
// cached product lists so the next session's grid doesn't flash this
|
// lists so the next session's grid doesn't flash this session's data before
|
||||||
// session's now-cleared data before it re-fetches.
|
// it re-fetches.
|
||||||
|
//
|
||||||
|
// Run either way. After an admin sign-out the catalogue is still there and
|
||||||
|
// these simply re-read it — which is the point: the next session must see
|
||||||
|
// what is on disk now, not what this one had in memory.
|
||||||
ref.read(catalogueVersionProvider.notifier).state++;
|
ref.read(catalogueVersionProvider.notifier).state++;
|
||||||
ref.invalidate(allProductsProvider);
|
ref.invalidate(allProductsProvider);
|
||||||
ref.invalidate(visibleProductsProvider);
|
ref.invalidate(visibleProductsProvider);
|
||||||
ref.invalidate(categoryCountsProvider);
|
ref.invalidate(categoryCountsProvider);
|
||||||
ref.invalidate(lowStockProductsProvider);
|
ref.invalidate(lowStockProductsProvider);
|
||||||
|
|
||||||
|
// The next session starts on the till, never wherever this one left off.
|
||||||
|
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||||
|
ref.read(searchQueryProvider.notifier).state = '';
|
||||||
|
ref.read(selectedCategoryProvider.notifier).state = null;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Resolved while this context is still mounted. The messenger itself lives
|
||||||
|
// above the router, so the bar survives the route change below.
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
context.go(AppRoutes.login);
|
context.go(AppRoutes.login);
|
||||||
|
|
||||||
|
if (cleared) {
|
||||||
|
messenger
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Signed out. The product catalogue has been removed from this '
|
||||||
|
'terminal.',
|
||||||
|
),
|
||||||
|
),);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pushThenFinish() async {
|
Future<void> _pushThenFinish() async {
|
||||||
@@ -76,8 +108,10 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
|||||||
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
||||||
final failed = _result != null && !_result!.isSuccess;
|
final failed = _result != null && !_result!.isSuccess;
|
||||||
|
|
||||||
|
final cashier = ref.watch(isCashierModeProvider);
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('End shift'),
|
title: Text(cashier ? 'End shift' : 'Sign out'),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(
|
contentPadding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.xxl,
|
AppSpacing.xxl,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
@@ -92,6 +126,26 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// What happens to the products is the difference between the two
|
||||||
|
// sign-outs, so it is said plainly rather than left to be
|
||||||
|
// discovered at the next login.
|
||||||
|
_Banner(
|
||||||
|
icon: cashier
|
||||||
|
? Icons.delete_sweep_outlined
|
||||||
|
: Icons.inventory_2_outlined,
|
||||||
|
color: cashier ? AppColors.warning : AppColors.info,
|
||||||
|
background: cashier
|
||||||
|
? AppColors.warningSurface
|
||||||
|
: AppColors.infoSurface,
|
||||||
|
message: cashier
|
||||||
|
? 'The product catalogue will be removed from this '
|
||||||
|
'terminal. An admin imports it again for the next '
|
||||||
|
'shift.'
|
||||||
|
: 'The imported products stay on this terminal, so a '
|
||||||
|
'cashier can sign in and start billing without a '
|
||||||
|
'connection.',
|
||||||
|
),
|
||||||
|
|
||||||
if (cart.isNotEmpty)
|
if (cart.isNotEmpty)
|
||||||
_Banner(
|
_Banner(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
|
|||||||
@@ -274,10 +274,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_linux
|
name: flutter_secure_storage_linux
|
||||||
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
flutter_secure_storage_platform_interface:
|
flutter_secure_storage_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
30
test/widget_test.dart
Normal file
30
test/widget_test.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:nearle_pos/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
|
// Build our app and trigger a frame.
|
||||||
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
|
// Verify that our counter starts at 0.
|
||||||
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
|
// Tap the '+' icon and trigger a frame.
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify that our counter has incremented.
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user