added local db

This commit is contained in:
2026-07-29 13:06:39 +05:30
parent d72522e737
commit d9886880cc
38 changed files with 2895 additions and 2261 deletions

View File

@@ -15,24 +15,9 @@ migration:
- platform: root - platform: root
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: android
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: ios
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: linux
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: macos - platform: macos
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: web
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: windows
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
# User provided section # User provided section

View File

@@ -35,14 +35,14 @@ final transactionRepositoryProvider = Provider<TransactionRepository>(
final remoteCatalogueProvider = final remoteCatalogueProvider =
Provider<RemoteCatalogueSource>((ref) => RemoteCatalogueSource()); Provider<RemoteCatalogueSource>((ref) => RemoteCatalogueSource());
final remoteReportSinkProvider = final remoteOrderSinkProvider =
Provider<RemoteReportSink>((ref) => RemoteReportSink()); Provider<RemoteOrderSink>((ref) => RemoteOrderSink());
final syncRepositoryProvider = Provider<SyncRepository>( final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl( (ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider), ref.watch(localStoreProvider),
ref.watch(remoteCatalogueProvider), ref.watch(remoteCatalogueProvider),
ref.watch(remoteReportSinkProvider), ref.watch(remoteOrderSinkProvider),
), ),
); );

View File

@@ -5,23 +5,20 @@ import 'package:go_router/go_router.dart';
import '../../domain/entities/transaction.dart'; import '../../domain/entities/transaction.dart';
import '../../presentation/auth/providers/auth_controller.dart'; import '../../presentation/auth/providers/auth_controller.dart';
import '../../presentation/auth/screens/login_screen.dart'; import '../../presentation/auth/screens/login_screen.dart';
import '../../presentation/customer/screens/customer_registration_screen.dart';
import '../../presentation/customer/screens/existing_customer_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/welcome/screens/welcome_screen.dart';
class AppRoutes { class AppRoutes {
const AppRoutes._(); const AppRoutes._();
static const String login = '/login'; static const String login = '/login';
static const String welcome = '/';
static const String registerCustomer = '/customer/new'; /// The terminal itself. Signing in lands here directly — customer capture
static const String existingCustomer = '/customer/find'; /// happens at checkout, not before the sale.
static const String pos = '/pos'; static const String pos = '/';
static const String payment = '/pos/payment'; static const String payment = '/payment';
static const String receipt = '/pos/receipt'; static const String receipt = '/receipt';
} }
/// Router with an authentication guard. /// Router with an authentication guard.
@@ -48,7 +45,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final atLogin = state.matchedLocation == AppRoutes.login; final atLogin = state.matchedLocation == AppRoutes.login;
if (!signedIn) return atLogin ? null : AppRoutes.login; if (!signedIn) return atLogin ? null : AppRoutes.login;
if (atLogin) return AppRoutes.welcome; if (atLogin) return AppRoutes.pos;
return null; return null;
}, },
routes: [ routes: [
@@ -57,41 +54,19 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'login', name: 'login',
pageBuilder: (context, state) => _fade(state, const LoginScreen()), pageBuilder: (context, state) => _fade(state, const LoginScreen()),
), ),
GoRoute(
path: AppRoutes.welcome,
name: 'welcome',
pageBuilder: (context, state) => _fade(state, const WelcomeScreen()),
),
GoRoute(
path: AppRoutes.registerCustomer,
name: 'registerCustomer',
pageBuilder: (context, state) => _slide(
state,
CustomerRegistrationScreen(
prefillMobile: state.uri.queryParameters['mobile'],
),
),
),
GoRoute(
path: AppRoutes.existingCustomer,
name: 'existingCustomer',
pageBuilder: (context, state) =>
_slide(state, const ExistingCustomerScreen()),
),
GoRoute( GoRoute(
path: AppRoutes.pos, path: AppRoutes.pos,
name: 'pos', name: 'pos',
pageBuilder: (context, state) => pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()), _fade(state, const PosDashboardScreen()),
routes: [
GoRoute(
path: 'payment',
name: 'payment',
pageBuilder: (context, state) =>
_slide(state, const PaymentScreen()),
), ),
GoRoute( GoRoute(
path: 'receipt', path: AppRoutes.payment,
name: 'payment',
pageBuilder: (context, state) => _slide(state, const PaymentScreen()),
),
GoRoute(
path: AppRoutes.receipt,
name: 'receipt', name: 'receipt',
pageBuilder: (context, state) => _fade( pageBuilder: (context, state) => _fade(
state, state,
@@ -99,8 +74,6 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
), ),
], ],
),
],
errorBuilder: (context, state) => Scaffold( errorBuilder: (context, state) => Scaffold(
body: Center(child: Text('Route not found: ${state.uri}')), body: Center(child: Text('Route not found: ${state.uri}')),
), ),

View File

@@ -1,136 +1,172 @@
import '../../domain/entities/customer.dart'; import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../../domain/entities/sync_event.dart'; import '../local/app_database.dart';
import '../../domain/entities/transaction.dart'; import '../local/catalogue_dao.dart';
import 'seed_data.dart'; import '../local/order_dao.dart';
/// On-terminal storage. /// Terminal-side storage facade.
/// ///
/// The terminal starts with an **empty catalogue**: nothing can be billed /// SQLite is the source of truth. The catalogue is additionally held in memory
/// until the cashier imports products. Everything after that — sales, parked /// because barcode resolution happens on every scan and the product grid reads
/// bills, queued events — lives here and survives without a connection. /// it constantly — but every write goes to disk first, so nothing depends on
/// /// the process staying alive.
/// Swapping this for Hive or SQLite changes nothing above the data layer.
class LocalStore { class LocalStore {
LocalStore._(); LocalStore._();
static final LocalStore instance = LocalStore._(); static final LocalStore instance = LocalStore._();
late CatalogueDao catalogue;
late OrderDao orders;
final Map<String, Product> _products = {}; final Map<String, Product> _products = {};
final Map<String, Customer> _customers = {}; final Map<String, Customer> _customers = {};
final List<SaleTransaction> _transactions = [];
final List<ParkedBill> _parked = [];
final List<SyncEvent> _events = [];
int _invoiceSequence = 0;
DateTime? _lastImportAt; DateTime? _lastImportAt;
String? _catalogueRevision; String? _catalogueRevision;
int _unsyncedOrders = 0;
/// Nothing to seed — the catalogue arrives via import. bool _ready = false;
Future<void> init() async {}
/// Test helper. Clears everything and optionally loads the demo catalogue bool get isReady => _ready;
/// so fixtures don't have to run an import first.
/// Opens the database and loads the catalogue into memory.
Future<void> init({String? databasePath, bool inMemory = false}) async {
if (inMemory) {
await AppDatabase.instance.openInMemory();
} else {
await AppDatabase.instance.open(overridePath: databasePath);
}
catalogue = CatalogueDao(AppDatabase.instance.db);
orders = OrderDao(AppDatabase.instance.db);
await hydrate();
_ready = true;
}
/// Re-reads cached state from disk. Called on start and after an import.
Future<void> hydrate() async {
_products
..clear()
..addEntries(
(await catalogue.allProducts()).map((p) => MapEntry(p.id, p)),
);
_customers
..clear()
..addEntries(
(await catalogue.allCustomers()).map((c) => MapEntry(c.id, c)),
);
final stamp = await catalogue.meta(MetaKeys.lastImportAt);
_lastImportAt = stamp == null
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
_unsyncedOrders = await orders.unsyncedCount();
}
/// Test helper: wipes every table and reloads.
Future<void> reset({bool withCatalogue = false}) async { Future<void> reset({bool withCatalogue = false}) async {
_products.clear(); if (!_ready) await init(inMemory: true);
_customers.clear(); await AppDatabase.instance.clear();
_transactions.clear();
_parked.clear();
_events.clear();
_invoiceSequence = 0;
_lastImportAt = null;
_catalogueRevision = null;
if (withCatalogue) { if (withCatalogue) {
importCatalogue( // Imported here rather than at the top of the file so the seed data is
products: SeedData.products(), // only pulled in by tests and the simulated remote source.
customers: SeedData.customers(), await catalogue.replaceCatalogue(
revision: 'seed', products: _seedProducts(),
at: DateTime.now(), customers: _seedCustomers(),
); );
await catalogue.setMeta(
MetaKeys.lastImportAt,
'${DateTime.now().millisecondsSinceEpoch}',
);
await catalogue.setMeta(MetaKeys.catalogueRevision, 'seed');
} }
await hydrate();
} }
// -------------------------------------------------------------- Catalogue // -------------------------------------------------------------- Catalogue
/// True once a catalogue has been pulled. The POS refuses to bill until so. /// True once products exist on this terminal. Billing is gated on it.
bool get hasCatalogue => _products.isNotEmpty; bool get hasCatalogue => _products.isNotEmpty;
DateTime? get lastImportAt => _lastImportAt; DateTime? get lastImportAt => _lastImportAt;
String? get catalogueRevision => _catalogueRevision; String? get catalogueRevision => _catalogueRevision;
int get unsyncedOrders => _unsyncedOrders;
/// Replaces the catalogue wholesale. Future<void> importCatalogue({
///
/// Stock levels already adjusted by local sales are preserved for products
/// that survive the re-import, so importing mid-shift does not resurrect
/// stock that has been sold.
void importCatalogue({
required List<Product> products, required List<Product> products,
required List<Customer> customers, required List<Customer> customers,
required String revision, required String revision,
required DateTime at, required DateTime at,
}) { }) async {
final priorStock = { await catalogue.replaceCatalogue(products: products, customers: customers);
for (final p in _products.values) p.id: p.stock, await catalogue.setMeta(
}; MetaKeys.lastImportAt,
'${at.millisecondsSinceEpoch}',
_products );
..clear() await catalogue.setMeta(MetaKeys.catalogueRevision, revision);
..addEntries(products.map((p) { await hydrate();
final held = priorStock[p.id];
return MapEntry(p.id, held == null ? p : p.copyWith(stock: held));
}));
// Locally registered customers must not be wiped by a server pull.
for (final c in customers) {
_customers.putIfAbsent(c.id, () => c);
}
_lastImportAt = at;
_catalogueRevision = revision;
} }
// --------------------------------------------------------------- Products // --------------------------------------------------------------- Products
List<Product> get products => _products.values.toList(growable: false); List<Product> get products => _products.values.toList(growable: false);
void putProduct(Product p) => _products[p.id] = p;
Product? productById(String id) => _products[id]; Product? productById(String id) => _products[id];
Product? productByBarcode(String barcode) {
final needle = barcode.trim();
for (final p in _products.values) {
if (p.barcode == needle && p.isActive) return p;
}
return null;
}
/// Write-through: disk first, then the cache.
Future<void> putProduct(Product p) async {
await catalogue.upsertProduct(p);
_products[p.id] = p;
}
Future<void> applyStockMovement(Map<String, double> quantities) async {
await catalogue.decrementStock(quantities);
quantities.forEach((id, qty) {
final p = _products[id];
if (p == null) return;
_products[id] =
p.copyWith(stock: (p.stock - qty).clamp(0, double.infinity));
});
}
// -------------------------------------------------------------- Customers // -------------------------------------------------------------- Customers
List<Customer> get customers => _customers.values.toList(growable: false); List<Customer> get customers => _customers.values.toList(growable: false);
void putCustomer(Customer c) => _customers[c.id] = c;
Customer? customerById(String id) => _customers[id]; Customer? customerById(String id) => _customers[id];
// ----------------------------------------------------------- Transactions Future<void> putCustomer(Customer c) async {
List<SaleTransaction> get transactions => await catalogue.upsertCustomer(c);
List.unmodifiable(_transactions.reversed); _customers[c.id] = c;
void addTransaction(SaleTransaction t) => _transactions.add(t);
int nextInvoiceSequence() => ++_invoiceSequence;
// ------------------------------------------------------------ Parked bills
List<ParkedBill> get parked => List.unmodifiable(_parked);
void addParked(ParkedBill b) => _parked.add(b);
void removeParked(String id) => _parked.removeWhere((b) => b.id == id);
// ------------------------------------------------------------ Sync events
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
void addEvent(SyncEvent e) => _events.add(e);
/// Updates in place. Events are never removed, so a failed push stays
/// visible and retryable.
void updateEvent(SyncEvent e) {
final i = _events.indexWhere((x) => x.id == e.id);
if (i >= 0) _events[i] = e;
} }
bool get hasUnsyncedEvents => // ----------------------------------------------------------------- Orders
_events.any((e) => e.status != SyncStatus.synced); /// Refreshes the cached unsynced tally after a write or a sync.
Future<int> refreshUnsyncedCount() async {
_unsyncedOrders = await orders.unsyncedCount();
return _unsyncedOrders;
}
// ------------------------------------------------------------- Seed data
// Kept behind these hooks so production code never reaches for them.
static List<Product> Function() _seedProducts = () => const [];
static List<Customer> Function() _seedCustomers = () => const [];
/// Lets the simulated remote source and tests supply demo rows.
static void registerSeed({
required List<Product> Function() products,
required List<Customer> Function() customers,
}) {
_seedProducts = products;
_seedCustomers = customers;
}
} }

View File

@@ -1,5 +1,6 @@
import '../../domain/entities/customer.dart'; import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import 'local_store.dart';
import 'seed_data.dart'; import 'seed_data.dart';
/// What one catalogue pull returns. /// What one catalogue pull returns.
@@ -35,7 +36,12 @@ class CatalogueSyncException implements Exception {
/// The real implementation would issue an HTTP request; the contract is the /// The real implementation would issue an HTTP request; the contract is the
/// same, so only this class changes. /// same, so only this class changes.
class RemoteCatalogueSource { class RemoteCatalogueSource {
RemoteCatalogueSource(); RemoteCatalogueSource() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
}
/// Flipped from Settings to exercise the offline path. /// Flipped from Settings to exercise the offline path.
bool simulateOffline = false; bool simulateOffline = false;
@@ -75,24 +81,28 @@ class RemoteCatalogueSource {
} }
} }
/// Stands in for the back-office reporting API. /// Stands in for the back-office order intake API.
class RemoteReportSink { class RemoteOrderSink {
RemoteReportSink(); RemoteOrderSink();
bool simulateOffline = false; bool simulateOffline = false;
/// Pushes one payload. Throws on failure so the caller can keep the event /// Uploads a batch of orders and returns the ids the server accepted.
/// queued rather than marking it sent. ///
Future<String> push(Map<String, Object?> payload) async { /// Throws on transport failure so the caller leaves every row at
await Future<void>.delayed(const Duration(milliseconds: 900)); /// `sync_status = 0` rather than marking anything sent.
Future<List<String>> pushOrders(List<Map<String, Object?>> orders) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + orders.length * 60),
);
if (simulateOffline) { if (simulateOffline) {
throw const CatalogueSyncException( throw const CatalogueSyncException(
'Could not reach the reporting server. ' 'Could not reach the order server. Every bill is still stored on '
'The report is still saved on this terminal.', 'this terminal and will upload on the next attempt.',
); );
} }
return 'ACK-${DateTime.now().millisecondsSinceEpoch % 1000000}'; return orders.map((o) => o['id']! as String).toList();
} }
} }

View File

@@ -0,0 +1,263 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// SQLite database for the terminal.
///
/// This is the source of truth for everything the cashier produces. Orders are
/// written here the moment a sale completes, so a crash, a power cut or a
/// closed app cannot lose a bill that has not yet reached the server.
class AppDatabase {
AppDatabase._();
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 1;
Database? _db;
Database get db {
final database = _db;
if (database == null) {
throw StateError('AppDatabase.open() must be called before use.');
}
return database;
}
bool get isOpen => _db != null;
/// Opens the database, creating the schema on first run.
///
/// Desktop needs the FFI factory installed first; Android and iOS use the
/// platform implementation that ships with sqflite.
Future<void> open({String? overridePath}) async {
if (_db != null) return;
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
final path = overridePath ??
p.join(await databaseFactory.getDatabasesPath(), _fileName);
_db = await databaseFactory.openDatabase(
path,
options: OpenDatabaseOptions(
version: _version,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, version) async => _createSchema(db),
onUpgrade: (db, from, to) async {
// Single version so far; migrations land here as the schema grows.
},
),
);
}
/// In-memory database for tests.
Future<void> openInMemory() async {
if (_db != null) await close();
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
_db = await databaseFactory.openDatabase(
inMemoryDatabasePath,
options: OpenDatabaseOptions(
version: _version,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, version) async => _createSchema(db),
),
);
}
Future<void> close() async {
await _db?.close();
_db = null;
}
/// Drops every row without touching the schema. Used by tests.
Future<void> clear() async {
final batch = db.batch();
for (final t in const [
Tables.orderItems,
Tables.orders,
Tables.products,
Tables.customers,
Tables.parkedBills,
Tables.syncLog,
Tables.meta,
]) {
batch.delete(t);
}
await batch.commit(noResult: true);
}
Future<void> _createSchema(Database db) async {
// ------------------------------------------------------------ products
await db.execute('''
CREATE TABLE ${Tables.products} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
barcode TEXT NOT NULL,
sku TEXT NOT NULL,
category TEXT NOT NULL,
price REAL NOT NULL,
mrp REAL,
stock REAL NOT NULL DEFAULT 0,
emoji TEXT,
image_url TEXT,
unit TEXT NOT NULL DEFAULT 'piece',
gst_rate REAL NOT NULL DEFAULT 0.18,
brand TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
)
''');
// Barcode lookup is the hot path during scanning.
await db.execute(
'CREATE UNIQUE INDEX idx_products_barcode ON ${Tables.products}(barcode)',
);
await db.execute(
'CREATE INDEX idx_products_category ON ${Tables.products}(category)',
);
// ----------------------------------------------------------- customers
await db.execute('''
CREATE TABLE ${Tables.customers} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
mobile TEXT NOT NULL,
email TEXT,
gender TEXT NOT NULL DEFAULT 'unspecified',
date_of_birth INTEGER,
loyalty_points INTEGER NOT NULL DEFAULT 0,
lifetime_spend REAL NOT NULL DEFAULT 0,
visit_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
last_visit_at INTEGER
)
''');
await db.execute(
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
);
// -------------------------------------------------------------- orders
await db.execute('''
CREATE TABLE ${Tables.orders} (
id TEXT PRIMARY KEY,
invoice_number TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
business_date TEXT NOT NULL,
cashier_name TEXT NOT NULL,
terminal_id TEXT NOT NULL,
customer_id TEXT,
customer_mobile TEXT,
customer_name TEXT,
subtotal REAL NOT NULL,
line_discount REAL NOT NULL DEFAULT 0,
bill_discount REAL NOT NULL DEFAULT 0,
loyalty_value REAL NOT NULL DEFAULT 0,
taxable_amount REAL NOT NULL DEFAULT 0,
tax_amount REAL NOT NULL DEFAULT 0,
round_off REAL NOT NULL DEFAULT 0,
total REAL NOT NULL,
points_earned INTEGER NOT NULL DEFAULT 0,
points_redeemed INTEGER NOT NULL DEFAULT 0,
payments_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
-- 0 = held on this terminal, 1 = accepted by the server
sync_status INTEGER NOT NULL DEFAULT 0,
synced_at INTEGER,
sync_attempts INTEGER NOT NULL DEFAULT 0,
sync_error TEXT
)
''');
// The end-of-day upload selects on this, so it must be indexed.
await db.execute(
'CREATE INDEX idx_orders_sync ON ${Tables.orders}(sync_status)',
);
await db.execute(
'CREATE INDEX idx_orders_date ON ${Tables.orders}(business_date)',
);
// --------------------------------------------------------- order items
await db.execute('''
CREATE TABLE ${Tables.orderItems} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
product_id TEXT NOT NULL,
name TEXT NOT NULL,
barcode TEXT NOT NULL,
sku TEXT NOT NULL,
unit TEXT NOT NULL,
unit_price REAL NOT NULL,
quantity REAL NOT NULL,
discount REAL NOT NULL DEFAULT 0,
gst_rate REAL NOT NULL DEFAULT 0,
tax_amount REAL NOT NULL DEFAULT 0,
line_total REAL NOT NULL,
FOREIGN KEY (order_id) REFERENCES ${Tables.orders}(id)
ON DELETE CASCADE
)
''');
await db.execute(
'CREATE INDEX idx_items_order ON ${Tables.orderItems}(order_id)',
);
// --------------------------------------------------------- parked bills
await db.execute('''
CREATE TABLE ${Tables.parkedBills} (
id TEXT PRIMARY KEY,
label TEXT,
parked_at INTEGER NOT NULL,
cart_json TEXT NOT NULL
)
''');
// -------------------------------------------------------------- syncLog
await db.execute('''
CREATE TABLE ${Tables.syncLog} (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
synced_at INTEGER,
summary TEXT NOT NULL,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0
)
''');
// ----------------------------------------------------------------- meta
await db.execute('''
CREATE TABLE ${Tables.meta} (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
}
}
class Tables {
const Tables._();
static const String products = 'products';
static const String customers = 'customers';
static const String orders = 'orders';
static const String orderItems = 'order_items';
static const String parkedBills = 'parked_bills';
static const String syncLog = 'sync_log';
static const String meta = 'app_meta';
}
class MetaKeys {
const MetaKeys._();
static const String lastImportAt = 'last_import_at';
static const String catalogueRevision = 'catalogue_revision';
static const String invoiceSequence = 'invoice_sequence';
}

View File

@@ -0,0 +1,248 @@
import 'package:sqflite/sqflite.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import 'app_database.dart';
/// Reads and writes the imported catalogue.
class CatalogueDao {
const CatalogueDao(this._db);
final Database _db;
// --------------------------------------------------------------- Mapping
static Map<String, Object?> productToRow(Product p) => {
'id': p.id,
'name': p.name,
'barcode': p.barcode,
'sku': p.sku,
'category': p.category.name,
'price': p.price,
'mrp': p.mrp,
'stock': p.stock,
'emoji': p.emoji,
'image_url': p.imageUrl,
'unit': p.unit.name,
'gst_rate': p.gstRate,
'brand': p.brand,
'is_active': p.isActive ? 1 : 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
};
static Product productFromRow(Map<String, Object?> r) => Product(
id: r['id']! as String,
name: r['name']! as String,
barcode: r['barcode']! as String,
sku: r['sku']! as String,
category: ProductCategory.values.byName(r['category']! as String),
price: (r['price']! as num).toDouble(),
mrp: (r['mrp'] as num?)?.toDouble(),
stock: (r['stock']! as num).toDouble(),
emoji: (r['emoji'] as String?) ?? '📦',
imageUrl: r['image_url'] as String?,
unit: UnitOfMeasure.values.byName((r['unit'] as String?) ?? 'piece'),
gstRate: (r['gst_rate']! as num).toDouble(),
brand: r['brand'] as String?,
isActive: (r['is_active']! as int) == 1,
);
static Map<String, Object?> customerToRow(Customer c) => {
'id': c.id,
'name': c.name,
'mobile': c.mobile,
'email': c.email,
'gender': c.gender.name,
'date_of_birth': c.dateOfBirth?.millisecondsSinceEpoch,
'loyalty_points': c.loyaltyPoints,
'lifetime_spend': c.lifetimeSpend,
'visit_count': c.visitCount,
'created_at': c.createdAt?.millisecondsSinceEpoch,
'last_visit_at': c.lastVisitAt?.millisecondsSinceEpoch,
};
static Customer customerFromRow(Map<String, Object?> r) => Customer(
id: r['id']! as String,
name: r['name']! as String,
mobile: r['mobile']! as String,
email: r['email'] as String?,
gender: Gender.values.byName((r['gender'] as String?) ?? 'unspecified'),
dateOfBirth: _date(r['date_of_birth']),
loyaltyPoints: (r['loyalty_points'] as int?) ?? 0,
lifetimeSpend: (r['lifetime_spend'] as num?)?.toDouble() ?? 0,
visitCount: (r['visit_count'] as int?) ?? 0,
createdAt: _date(r['created_at']),
lastVisitAt: _date(r['last_visit_at']),
);
static DateTime? _date(Object? millis) => millis == null
? null
: DateTime.fromMillisecondsSinceEpoch(millis! as int);
// -------------------------------------------------------------- Products
Future<List<Product>> allProducts() async {
final rows = await _db.query(
Tables.products,
where: 'is_active = 1',
orderBy: 'category, name',
);
return rows.map(productFromRow).toList();
}
Future<Product?> productByBarcode(String barcode) async {
final rows = await _db.query(
Tables.products,
where: 'barcode = ? AND is_active = 1',
whereArgs: [barcode.trim()],
limit: 1,
);
return rows.isEmpty ? null : productFromRow(rows.first);
}
Future<Product?> productById(String id) async {
final rows = await _db.query(
Tables.products,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : productFromRow(rows.first);
}
Future<int> productCount() async {
final r = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.products} WHERE is_active = 1',
);
return (r.first['c']! as int);
}
Future<void> upsertProduct(Product p) async {
await _db.insert(
Tables.products,
productToRow(p),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Writes the pulled catalogue in one transaction.
///
/// Stock already decremented by local sales is preserved for products that
/// survive the import, so re-importing mid-shift cannot resurrect sold units.
Future<void> replaceCatalogue({
required List<Product> products,
required List<Customer> customers,
}) async {
await _db.transaction((txn) async {
final existing = await txn.query(
Tables.products,
columns: ['id', 'stock'],
);
final heldStock = {
for (final r in existing) r['id']! as String: (r['stock']! as num).toDouble(),
};
final batch = txn.batch();
for (final p in products) {
final held = heldStock[p.id];
batch.insert(
Tables.products,
productToRow(held == null ? p : p.copyWith(stock: held)),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// Locally registered customers must survive a server pull, so this
// ignores rows that already exist rather than replacing them.
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
await batch.commit(noResult: true);
});
}
/// Applies stock movement after a sale, clamped at zero.
Future<void> decrementStock(Map<String, double> quantities) async {
if (quantities.isEmpty) return;
await _db.transaction((txn) async {
final batch = txn.batch();
quantities.forEach((id, qty) {
batch.rawUpdate(
'UPDATE ${Tables.products} '
'SET stock = MAX(0, stock - ?), updated_at = ? WHERE id = ?',
[qty, DateTime.now().millisecondsSinceEpoch, id],
);
});
await batch.commit(noResult: true);
});
}
// ------------------------------------------------------------- Customers
Future<List<Customer>> allCustomers({int limit = 500}) async {
final rows = await _db.query(
Tables.customers,
orderBy: 'last_visit_at DESC',
limit: limit,
);
return rows.map(customerFromRow).toList();
}
Future<Customer?> customerByMobile(String mobile) async {
final digits = mobile.replaceAll(RegExp(r'\D'), '');
final rows = await _db.query(
Tables.customers,
where: 'mobile = ?',
whereArgs: [digits],
limit: 1,
);
return rows.isEmpty ? null : customerFromRow(rows.first);
}
Future<Customer?> customerById(String id) async {
final rows = await _db.query(
Tables.customers,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : customerFromRow(rows.first);
}
Future<void> upsertCustomer(Customer c) async {
await _db.insert(
Tables.customers,
customerToRow(c),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// ------------------------------------------------------------------ Meta
Future<String?> meta(String key) async {
final rows = await _db.query(
Tables.meta,
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
return rows.isEmpty ? null : rows.first['value'] as String?;
}
Future<void> setMeta(String key, String value) async {
await _db.insert(
Tables.meta,
{'key': key, 'value': value},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Monotonic invoice counter held in the meta table.
Future<int> nextInvoiceSequence() async {
final current = int.tryParse(await meta(MetaKeys.invoiceSequence) ?? '0') ?? 0;
final next = current + 1;
await setMeta(MetaKeys.invoiceSequence, '$next');
return next;
}
}

View File

@@ -0,0 +1,367 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import '../../domain/entities/cart.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import '../../domain/entities/transaction.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
/// Persists bills.
///
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
/// upload selects those rows, sends them, and flips the accepted ones to 1.
/// Nothing is ever deleted as part of syncing.
class OrderDao {
const OrderDao(this._db);
final Database _db;
static const int pending = 0;
static const int synced = 1;
static String businessDateOf(DateTime dt) =>
'${dt.year.toString().padLeft(4, '0')}-'
'${dt.month.toString().padLeft(2, '0')}-'
'${dt.day.toString().padLeft(2, '0')}';
// ----------------------------------------------------------------- Write
/// Writes the bill and its lines atomically.
Future<void> insertOrder(SaleTransaction t) async {
final cart = t.cart;
await _db.transaction((txn) async {
await txn.insert(
Tables.orders,
{
'id': t.id,
'invoice_number': t.invoiceNumber,
'created_at': t.createdAt.millisecondsSinceEpoch,
'business_date': businessDateOf(t.createdAt),
'cashier_name': t.cashierName,
'terminal_id': t.terminalId,
'customer_id': cart.customer?.id,
'customer_mobile': cart.customer?.mobile,
'customer_name': cart.customer?.name,
'subtotal': cart.subtotal,
'line_discount': cart.lineDiscountTotal,
'bill_discount': cart.billDiscountTotal,
'loyalty_value': cart.loyaltyRedemptionValue,
'taxable_amount': cart.taxableAmount,
'tax_amount': cart.taxAmount,
'round_off': cart.roundOff,
'total': t.total,
'points_earned': cart.pointsEarned,
'points_redeemed': cart.pointsRedeemed,
'payments_json': jsonEncode([
for (final p in t.payments)
{
'method': p.method.name,
'amount': p.amount,
'tendered': p.tendered,
'reference': p.reference,
},
]),
'status': t.status.name,
'sync_status': pending,
'sync_attempts': 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
final batch = txn.batch();
for (final line in cart.lines) {
batch.insert(Tables.orderItems, {
'order_id': t.id,
'product_id': line.product.id,
'name': line.product.name,
'barcode': line.product.barcode,
'sku': line.product.sku,
'unit': line.product.unit.name,
'unit_price': line.product.price,
'quantity': line.quantity,
'discount': line.discountAmount,
'gst_rate': line.product.gstRate,
'tax_amount': line.taxAmount,
'line_total': line.payable,
});
}
await batch.commit(noResult: true);
});
}
// ------------------------------------------------------------------ Read
Future<List<SaleTransaction>> recent({int limit = 100}) =>
_query(orderBy: 'created_at DESC', limit: limit);
Future<List<SaleTransaction>> forBusinessDate(DateTime day) =>
_query(where: 'business_date = ?', whereArgs: [businessDateOf(day)]);
/// The end-of-day upload set.
Future<List<SaleTransaction>> unsynced({int limit = 500}) => _query(
where: 'sync_status = ?',
whereArgs: [pending],
orderBy: 'created_at ASC',
limit: limit,
);
Future<SaleTransaction?> byInvoice(String invoiceNumber) async {
final rows = await _query(
where: 'invoice_number = ?',
whereArgs: [invoiceNumber],
limit: 1,
);
return rows.isEmpty ? null : rows.first;
}
Future<int> unsyncedCount() async {
final r = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.orders} WHERE sync_status = ?',
[pending],
);
return r.first['c']! as int;
}
Future<double> salesTotalForDay(DateTime day) async {
final r = await _db.rawQuery(
'SELECT COALESCE(SUM(total), 0) AS t FROM ${Tables.orders} '
"WHERE business_date = ? AND status = 'completed'",
[businessDateOf(day)],
);
return (r.first['t']! as num).toDouble();
}
/// Sync state of an order, for display in the events log.
Future<Map<String, Object?>?> syncRow(String orderId) async {
final rows = await _db.query(
Tables.orders,
columns: [
'id',
'invoice_number',
'total',
'created_at',
'sync_status',
'synced_at',
'sync_attempts',
'sync_error',
],
where: 'id = ?',
whereArgs: [orderId],
limit: 1,
);
return rows.isEmpty ? null : rows.first;
}
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query(
Tables.orders,
columns: [
'id',
'invoice_number',
'total',
'created_at',
'sync_status',
'synced_at',
'sync_attempts',
'sync_error',
],
orderBy: 'created_at DESC',
limit: limit,
);
// ----------------------------------------------------------------- Sync
/// Flips accepted orders to `sync_status = 1`.
Future<void> markSynced(List<String> orderIds) async {
if (orderIds.isEmpty) return;
final now = DateTime.now().millisecondsSinceEpoch;
final placeholders = List.filled(orderIds.length, '?').join(',');
await _db.rawUpdate(
'UPDATE ${Tables.orders} SET sync_status = ?, synced_at = ?, '
'sync_error = NULL WHERE id IN ($placeholders)',
[synced, now, ...orderIds],
);
}
/// Records a failed attempt. The rows stay at `sync_status = 0`.
Future<void> markFailed(List<String> orderIds, String error) async {
if (orderIds.isEmpty) return;
final placeholders = List.filled(orderIds.length, '?').join(',');
await _db.rawUpdate(
'UPDATE ${Tables.orders} SET sync_attempts = sync_attempts + 1, '
'sync_error = ? WHERE id IN ($placeholders)',
[error, ...orderIds],
);
}
// ---------------------------------------------------------- Parked bills
Future<void> park(ParkedBill bill) async {
await _db.insert(
Tables.parkedBills,
{
'id': bill.id,
'label': bill.label,
'parked_at': bill.parkedAt.millisecondsSinceEpoch,
'cart_json': jsonEncode(_cartToJson(bill.cart)),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<ParkedBill>> parkedBills() async {
final rows =
await _db.query(Tables.parkedBills, orderBy: 'parked_at DESC');
return rows
.map((r) => ParkedBill(
id: r['id']! as String,
label: r['label'] as String?,
parkedAt:
DateTime.fromMillisecondsSinceEpoch(r['parked_at']! as int),
cart: _cartFromJson(
jsonDecode(r['cart_json']! as String) as Map<String, Object?>,
),
))
.toList();
}
Future<void> removeParked(String id) async {
await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]);
}
// -------------------------------------------------------------- Internals
Future<List<SaleTransaction>> _query({
String? where,
List<Object?>? whereArgs,
String orderBy = 'created_at DESC',
int? limit,
}) async {
final orders = await _db.query(
Tables.orders,
where: where,
whereArgs: whereArgs,
orderBy: orderBy,
limit: limit,
);
if (orders.isEmpty) return const [];
final ids = orders.map((o) => o['id']! as String).toList();
final placeholders = List.filled(ids.length, '?').join(',');
final items = await _db.query(
Tables.orderItems,
where: 'order_id IN ($placeholders)',
whereArgs: ids,
);
final byOrder = <String, List<Map<String, Object?>>>{};
for (final i in items) {
byOrder.putIfAbsent(i['order_id']! as String, () => []).add(i);
}
return orders
.map((o) => _transactionFromRows(o, byOrder[o['id']] ?? const []))
.toList();
}
SaleTransaction _transactionFromRows(
Map<String, Object?> o,
List<Map<String, Object?>> items,
) {
// Lines are rebuilt from the snapshot taken at sale time, not from the
// current catalogue, so a reprint always shows what was actually charged.
final lines = items.map((i) {
final product = Product(
id: i['product_id']! as String,
name: i['name']! as String,
barcode: i['barcode']! as String,
sku: i['sku']! as String,
category: ProductCategory.grocery,
price: (i['unit_price']! as num).toDouble(),
stock: 0,
unit: UnitOfMeasure.values.byName(i['unit']! as String),
gstRate: (i['gst_rate']! as num).toDouble(),
);
return CartLine(
product: product,
quantity: (i['quantity']! as num).toDouble(),
discount: (i['discount']! as num) > 0
? Discount(
type: DiscountType.flat,
value: (i['discount']! as num).toDouble(),
)
: Discount.none,
);
}).toList();
final customerId = o['customer_id'] as String?;
final customer = customerId == null
? null
: Customer(
id: customerId,
name: (o['customer_name'] as String?) ?? 'Customer',
mobile: (o['customer_mobile'] as String?) ?? '',
);
final payments = (jsonDecode(o['payments_json']! as String) as List)
.cast<Map<String, Object?>>()
.map((p) => PaymentSplit(
method: PaymentMethod.values.byName(p['method']! as String),
amount: (p['amount']! as num).toDouble(),
tendered: (p['tendered'] as num?)?.toDouble(),
reference: p['reference'] as String?,
))
.toList();
return SaleTransaction(
id: o['id']! as String,
invoiceNumber: o['invoice_number']! as String,
cart: Cart(lines: lines, customer: customer),
payments: payments,
createdAt: DateTime.fromMillisecondsSinceEpoch(o['created_at']! as int),
cashierName: o['cashier_name']! as String,
terminalId: o['terminal_id']! as String,
status: TransactionStatus.values.byName(o['status']! as String),
);
}
Map<String, Object?> _cartToJson(Cart cart) => {
'customer': cart.customer == null
? null
: CatalogueDao.customerToRow(cart.customer!),
'points_redeemed': cart.pointsRedeemed,
'note': cart.note,
'lines': [
for (final l in cart.lines)
{
'product': CatalogueDao.productToRow(l.product),
'quantity': l.quantity,
'discount_type': l.discount.type.name,
'discount_value': l.discount.value,
},
],
};
Cart _cartFromJson(Map<String, Object?> j) {
final customerRow = j['customer'] as Map<String, Object?>?;
return Cart(
customer: customerRow == null
? null
: CatalogueDao.customerFromRow(customerRow),
pointsRedeemed: (j['points_redeemed'] as int?) ?? 0,
note: j['note'] as String?,
lines: (j['lines'] as List).cast<Map<String, Object?>>().map((l) {
return CartLine(
product: CatalogueDao.productFromRow(
l['product']! as Map<String, Object?>,
),
quantity: (l['quantity']! as num).toDouble(),
discount: Discount(
type: DiscountType.values.byName(l['discount_type']! as String),
value: (l['discount_value']! as num).toDouble(),
),
);
}).toList(),
);
}
}

View File

@@ -43,13 +43,13 @@ class CustomerRepositoryImpl implements CustomerRepository {
visitCount: 0, visitCount: 0,
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
_store.putCustomer(created); await _store.putCustomer(created);
return created; return created;
} }
@override @override
Future<Customer> update(Customer customer) async { Future<Customer> update(Customer customer) async {
_store.putCustomer(customer); await _store.putCustomer(customer);
return customer; return customer;
} }
@@ -72,7 +72,7 @@ class CustomerRepositoryImpl implements CustomerRepository {
visitCount: current.visitCount + 1, visitCount: current.visitCount + 1,
lastVisitAt: DateTime.now(), lastVisitAt: DateTime.now(),
); );
_store.putCustomer(updated); await _store.putCustomer(updated);
return updated; return updated;
} }

View File

@@ -1,8 +1,9 @@
import '../../core/utils/extensions.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart'; import '../../domain/repositories/product_repository.dart';
import '../datasources/local_store.dart'; import '../datasources/local_store.dart';
/// Reads come from the in-memory catalogue for speed; writes go through the
/// store, which persists to SQLite before touching the cache.
class ProductRepositoryImpl implements ProductRepository { class ProductRepositoryImpl implements ProductRepository {
ProductRepositoryImpl(this._store); ProductRepositoryImpl(this._store);
@@ -19,12 +20,8 @@ class ProductRepositoryImpl implements ProductRepository {
.toList(); .toList();
@override @override
Future<Product?> findByBarcode(String barcode) async { Future<Product?> findByBarcode(String barcode) async =>
final needle = barcode.trim(); _store.productByBarcode(barcode);
return _store.products.firstWhereOrNull(
(p) => p.barcode == needle && p.isActive,
);
}
@override @override
Future<Product?> findById(String id) async => _store.productById(id); Future<Product?> findById(String id) async => _store.productById(id);
@@ -34,15 +31,13 @@ class ProductRepositoryImpl implements ProductRepository {
final q = query.trim(); final q = query.trim();
if (q.isEmpty) return getAll(); if (q.isEmpty) return getAll();
final results = _store.products.where((p) => p.isActive && p.matches(q)); final ranked = _store.products.where((p) => p.isActive && p.matches(q)).toList()
// Rank exact barcode and SKU hits above fuzzy name matches so the top
// result is the one the cashier almost certainly meant.
final ranked = results.toList()
..sort((a, b) => _score(b, q).compareTo(_score(a, q))); ..sort((a, b) => _score(b, q).compareTo(_score(a, q)));
return ranked; return ranked;
} }
/// Exact barcode and SKU hits rank above fuzzy name matches, so the top
/// result is the one the cashier almost certainly meant.
int _score(Product p, String q) { int _score(Product p, String q) {
final lq = q.toLowerCase(); final lq = q.toLowerCase();
if (p.barcode == q) return 100; if (p.barcode == q) return 100;
@@ -54,15 +49,9 @@ class ProductRepositoryImpl implements ProductRepository {
} }
@override @override
Future<void> decrementStock(Map<String, double> quantities) async { Future<void> decrementStock(Map<String, double> quantities) =>
quantities.forEach((id, qty) { _store.applyStockMovement(quantities);
final p = _store.productById(id);
if (p == null) return;
final next = (p.stock - qty).clamp(0, double.infinity).toDouble();
_store.putProduct(p.copyWith(stock: next));
});
}
@override @override
Future<void> upsert(Product product) async => _store.putProduct(product); Future<void> upsert(Product product) => _store.putProduct(product);
} }

View File

@@ -3,19 +3,25 @@ import 'package:uuid/uuid.dart';
import '../../core/utils/formatters.dart'; import '../../core/utils/formatters.dart';
import '../../domain/entities/shift_report.dart'; import '../../domain/entities/shift_report.dart';
import '../../domain/entities/sync_event.dart'; import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
import '../../domain/repositories/sync_repository.dart'; import '../../domain/repositories/sync_repository.dart';
import '../datasources/local_store.dart'; import '../datasources/local_store.dart';
import '../datasources/remote_catalogue_source.dart'; import '../datasources/remote_catalogue_source.dart';
import '../local/order_dao.dart';
class SyncRepositoryImpl implements SyncRepository { class SyncRepositoryImpl implements SyncRepository {
SyncRepositoryImpl(this._store, this._catalogue, this._reports); SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
final LocalStore _store; final LocalStore _store;
final RemoteCatalogueSource _catalogue; final RemoteCatalogueSource _catalogue;
final RemoteReportSink _reports; final RemoteOrderSink _orderSink;
static const _uuid = Uuid(); static const _uuid = Uuid();
/// In-session event log. Order sync state itself lives on the order rows,
/// so this is only a human-readable history of attempts.
final List<SyncEvent> _events = [];
@override @override
bool get hasCatalogue => _store.hasCatalogue; bool get hasCatalogue => _store.hasCatalogue;
@@ -26,134 +32,216 @@ class SyncRepositoryImpl implements SyncRepository {
String? get catalogueRevision => _store.catalogueRevision; String? get catalogueRevision => _store.catalogueRevision;
@override @override
List<SyncEvent> get events => _store.events; List<SyncEvent> get events => List.unmodifiable(_events.reversed);
@override void _log(SyncEvent e) => _events.add(e);
bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
// ------------------------------------------------------- Morning: import
@override @override
Future<SyncEvent> importCatalogue({ Future<SyncEvent> importCatalogue({
void Function(double progress, String stage)? onProgress, void Function(double progress, String stage)? onProgress,
}) async { }) async {
final event = SyncEvent( final id = _uuid.v4();
id: _uuid.v4(), final started = DateTime.now();
type: SyncEventType.catalogueImport,
status: SyncStatus.syncing,
createdAt: DateTime.now(),
summary: 'Catalogue import started',
);
_store.addEvent(event);
try { try {
final snapshot = await _catalogue.fetch(onProgress: onProgress); final snapshot = await _catalogue.fetch(onProgress: onProgress);
_store.importCatalogue( await _store.importCatalogue(
products: snapshot.products, products: snapshot.products,
customers: snapshot.customers, customers: snapshot.customers,
revision: snapshot.revision, revision: snapshot.revision,
at: snapshot.fetchedAt, at: snapshot.fetchedAt,
); );
final done = event.copyWith( final event = SyncEvent(
id: id,
type: SyncEventType.catalogueImport,
status: SyncStatus.synced, status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(), syncedAt: DateTime.now(),
attempts: 1, summary: '${snapshot.products.length} products saved to SQLite '
); '· ${snapshot.revision}',
final settled = SyncEvent(
id: done.id,
type: done.type,
status: done.status,
createdAt: done.createdAt,
summary: '${snapshot.products.length} products, '
'${snapshot.customers.length} customers · ${snapshot.revision}',
payload: { payload: {
'products': snapshot.products.length, 'products': snapshot.products.length,
'customers': snapshot.customers.length, 'customers': snapshot.customers.length,
'revision': snapshot.revision, 'revision': snapshot.revision,
}, },
syncedAt: done.syncedAt,
attempts: 1, attempts: 1,
); );
_store.updateEvent(settled); _log(event);
return settled; return event;
} catch (e) { } catch (e) {
final failed = event.copyWith( final event = SyncEvent(
id: id,
type: SyncEventType.catalogueImport,
status: SyncStatus.failed, status: SyncStatus.failed,
createdAt: started,
summary: 'Catalogue import failed',
error: e.toString(), error: e.toString(),
attempts: 1, attempts: 1,
); );
_store.updateEvent(failed); _log(event);
return failed; return event;
} }
} }
// --------------------------------------------------- Business hours: read
@override @override
ShiftReport buildShiftReport({ Future<int> unsyncedCount() => _store.orders.unsyncedCount();
required DateTime businessDate,
@override
Future<List<SaleTransaction>> unsyncedOrders() => _store.orders.unsynced();
@override
Future<ShiftReport> todayReport({
required String terminalId, required String terminalId,
required String cashierName, required String cashierName,
}) { }) async {
final today = DateTime.now();
final orders = await _store.orders.forBusinessDate(today);
return ShiftReport.fromTransactions( return ShiftReport.fromTransactions(
transactions: _store.transactions, transactions: orders,
businessDate: businessDate, businessDate: today,
terminalId: terminalId, terminalId: terminalId,
cashierName: cashierName, cashierName: cashierName,
); );
} }
@override @override
Future<SyncEvent> pushShiftReport(ShiftReport report) async { Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async {
// Queued first, so the data is durable before the network is touched. final rows = await _store.orders.syncRows(limit: limit);
final queued = SyncEvent( return rows
id: _uuid.v4(), .map((r) => OrderSyncRow(
type: SyncEventType.shiftReport, orderId: r['id']! as String,
status: SyncStatus.pending, invoiceNumber: r['invoice_number']! as String,
createdAt: DateTime.now(), total: (r['total']! as num).toDouble(),
summary: '${report.billCount} bills · ' createdAt:
'${Formatters.money(report.grossSales)} · ' DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
'${Formatters.date(report.businessDate)}', isSynced: (r['sync_status']! as int) == OrderDao.synced,
payload: report.toPayload(), syncedAt: r['synced_at'] == null
); ? null
_store.addEvent(queued); : DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
attempts: (r['sync_attempts'] as int?) ?? 0,
return _attempt(queued); error: r['sync_error'] as String?,
))
.toList();
} }
// ------------------------------------------------------ End of day: sync
@override @override
Future<SyncEvent> retry(String eventId) async { Future<SyncOutcome> syncOrders({
final matches = _store.events.where((e) => e.id == eventId).toList(); void Function(double progress, String stage)? onProgress,
if (matches.isEmpty) { }) async {
throw StateError('No queued event with id $eventId'); onProgress?.call(0.05, 'Collecting unsynced bills…');
}
final event = matches.first; final pending = await _store.orders.unsynced();
if (event.type == SyncEventType.catalogueImport) { if (pending.isEmpty) {
return importCatalogue(); onProgress?.call(1, 'Nothing to upload');
} return const SyncOutcome(attempted: 0, uploaded: 0);
return _attempt(event);
} }
/// Sends one event, leaving it queued if the push fails. final started = DateTime.now();
Future<SyncEvent> _attempt(SyncEvent event) async { final ids = pending.map((o) => o.id).toList();
_store.updateEvent(event.copyWith(status: SyncStatus.syncing));
onProgress?.call(0.35, 'Uploading ${pending.length} bills…');
try { try {
await _reports.push(event.payload); final accepted = await _orderSink.pushOrders(
final done = event.copyWith( pending.map(_orderToPayload).toList(),
);
onProgress?.call(0.85, 'Marking bills as synced…');
// Only what the server confirmed is flipped to 1.
await _store.orders.markSynced(accepted);
await _store.refreshUnsyncedCount();
final rejected = ids.where((id) => !accepted.contains(id)).toList();
if (rejected.isNotEmpty) {
await _store.orders.markFailed(rejected, 'Rejected by server');
}
onProgress?.call(1, 'Done');
_log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.synced, status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(), syncedAt: DateTime.now(),
attempts: event.attempts + 1, summary: '${accepted.length} of ${pending.length} bills uploaded',
clearError: true, attempts: 1,
); ));
_store.updateEvent(done);
return done.copyWith(); return SyncOutcome(attempted: pending.length, uploaded: accepted.length);
} catch (e) { } catch (e) {
final failed = event.copyWith( // Transport failed: record the attempt but leave every row at 0.
await _store.orders.markFailed(ids, e.toString());
await _store.refreshUnsyncedCount();
_log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.failed, status: SyncStatus.failed,
createdAt: started,
summary: '${pending.length} bills still pending '
'(${Formatters.money(pending.fold<double>(0, (s, o) => s + o.total))})',
error: e.toString(),
attempts: 1,
));
return SyncOutcome(
attempted: pending.length,
uploaded: 0,
error: e.toString(), error: e.toString(),
attempts: event.attempts + 1,
); );
_store.updateEvent(failed);
return failed;
} }
} }
/// The JSON body sent per order.
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
'id': t.id,
'invoice_number': t.invoiceNumber,
'created_at': t.createdAt.toIso8601String(),
'terminal_id': t.terminalId,
'cashier': t.cashierName,
'customer': t.customer == null
? null
: {
'id': t.customer!.id,
'mobile': t.customer!.mobile,
'name': t.customer!.name,
},
'subtotal': t.cart.subtotal,
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
'tax': t.cart.taxAmount,
'round_off': t.cart.roundOff,
'total': t.total,
'points_earned': t.pointsEarned,
'points_redeemed': t.pointsRedeemed,
'payments': [
for (final p in t.payments)
{
'method': p.method.name,
'amount': p.amount,
'reference': p.reference,
},
],
'items': [
for (final l in t.cart.lines)
{
'product_id': l.product.id,
'barcode': l.product.barcode,
'name': l.product.name,
'quantity': l.quantity,
'unit_price': l.product.price,
'discount': l.discountAmount,
'gst_rate': l.product.gstRate,
'tax': l.taxAmount,
'line_total': l.payable,
},
],
};
} }

View File

@@ -1,8 +1,8 @@
import '../../core/utils/extensions.dart';
import '../../domain/entities/transaction.dart'; import '../../domain/entities/transaction.dart';
import '../../domain/repositories/transaction_repository.dart'; import '../../domain/repositories/transaction_repository.dart';
import '../datasources/local_store.dart'; import '../datasources/local_store.dart';
/// All bill persistence goes straight to SQLite.
class TransactionRepositoryImpl implements TransactionRepository { class TransactionRepositoryImpl implements TransactionRepository {
TransactionRepositoryImpl(this._store); TransactionRepositoryImpl(this._store);
@@ -10,40 +10,33 @@ class TransactionRepositoryImpl implements TransactionRepository {
@override @override
Future<SaleTransaction> save(SaleTransaction transaction) async { Future<SaleTransaction> save(SaleTransaction transaction) async {
_store.addTransaction(transaction); // Written with sync_status = 0; the end-of-day upload picks it up.
await _store.orders.insertOrder(transaction);
await _store.refreshUnsyncedCount();
return transaction; return transaction;
} }
@override @override
Future<List<SaleTransaction>> history({int limit = 50}) async => Future<List<SaleTransaction>> history({int limit = 50}) =>
_store.transactions.take(limit).toList(); _store.orders.recent(limit: limit);
@override @override
Future<SaleTransaction?> findByInvoice(String invoiceNumber) async => Future<SaleTransaction?> findByInvoice(String invoiceNumber) =>
_store.transactions _store.orders.byInvoice(invoiceNumber);
.firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
@override @override
Future<int> nextInvoiceSequence() async => _store.nextInvoiceSequence(); Future<int> nextInvoiceSequence() => _store.catalogue.nextInvoiceSequence();
@override @override
Future<void> park(ParkedBill bill) async => _store.addParked(bill); Future<void> park(ParkedBill bill) => _store.orders.park(bill);
@override @override
Future<List<ParkedBill>> parkedBills() async => _store.parked; Future<List<ParkedBill>> parkedBills() => _store.orders.parkedBills();
@override @override
Future<void> removeParked(String id) async => _store.removeParked(id); Future<void> removeParked(String id) => _store.orders.removeParked(id);
@override @override
Future<double> salesTotalForDay(DateTime day) async { Future<double> salesTotalForDay(DateTime day) =>
return _store.transactions _store.orders.salesTotalForDay(day);
.where((t) =>
t.status == TransactionStatus.completed &&
t.createdAt.year == day.year &&
t.createdAt.month == day.month &&
t.createdAt.day == day.day)
.fold(0.0, (sum, t) => sum + t.total)
.asMoney;
}
} }

View File

@@ -1,38 +1,79 @@
import '../entities/shift_report.dart'; import '../entities/shift_report.dart';
import '../entities/sync_event.dart'; import '../entities/sync_event.dart';
import '../entities/transaction.dart';
/// The terminal's two network touchpoints, plus the durable event log. /// Result of one end-of-day upload.
class SyncOutcome {
const SyncOutcome({
required this.attempted,
required this.uploaded,
this.error,
});
final int attempted;
final int uploaded;
final String? error;
bool get isSuccess => error == null;
bool get hadNothingToDo => attempted == 0;
int get remaining => attempted - uploaded;
}
/// One row of the order sync log.
class OrderSyncRow {
const OrderSyncRow({
required this.orderId,
required this.invoiceNumber,
required this.total,
required this.createdAt,
required this.isSynced,
this.syncedAt,
this.attempts = 0,
this.error,
});
final String orderId;
final String invoiceNumber;
final double total;
final DateTime createdAt;
final bool isSynced;
final DateTime? syncedAt;
final int attempts;
final String? error;
}
/// The terminal's two network touchpoints.
///
/// Morning: pull the catalogue. End of day: upload every order still at
/// `sync_status = 0`. Nothing else leaves the device.
abstract class SyncRepository { abstract class SyncRepository {
/// True once products have been pulled onto this terminal.
bool get hasCatalogue; bool get hasCatalogue;
DateTime? get lastImportAt; DateTime? get lastImportAt;
String? get catalogueRevision; String? get catalogueRevision;
/// Pulls the catalogue and writes it locally. /// Morning step — downloads products and writes them to SQLite.
///
/// Records a [SyncEventType.catalogueImport] event whether or not it
/// succeeds, so the log reflects every attempt.
Future<SyncEvent> importCatalogue({ Future<SyncEvent> importCatalogue({
void Function(double progress, String stage)? onProgress, void Function(double progress, String stage)? onProgress,
}); });
/// Builds the day's report from locally stored sales. /// How many bills are still held locally.
ShiftReport buildShiftReport({ Future<int> unsyncedCount();
required DateTime businessDate,
Future<List<SaleTransaction>> unsyncedOrders();
/// Today's trading totals, read back from SQLite.
Future<ShiftReport> todayReport({
required String terminalId, required String terminalId,
required String cashierName, required String cashierName,
}); });
/// Queues the report and attempts to push it. /// End-of-day step — uploads pending orders and flips the accepted ones to
/// /// `sync_status = 1`. Failures leave every row untouched at 0.
/// On failure the event is kept in [SyncStatus.failed] so nothing is lost. Future<SyncOutcome> syncOrders({
Future<SyncEvent> pushShiftReport(ShiftReport report); void Function(double progress, String stage)? onProgress,
});
/// Retries a previously failed or pending push. Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
Future<SyncEvent> retry(String eventId);
List<SyncEvent> get events; List<SyncEvent> get events;
bool get hasUnsyncedEvents;
} }

View File

@@ -12,6 +12,8 @@ Future<void> main() async {
await _configureChrome(); await _configureChrome();
// Opens SQLite and loads the catalogue into memory. Any bill written on a
// previous run is still on disk and still counted as unsynced.
await LocalStore.instance.init(); await LocalStore.instance.init();
await SoundService.instance.preload(); await SoundService.instance.preload();

View File

@@ -44,7 +44,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
password: _password.text, password: _password.text,
); );
if (ok && mounted) context.go(AppRoutes.welcome); if (ok && mounted) context.go(AppRoutes.pos);
} }
@override @override

View File

@@ -1,321 +0,0 @@
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 '../../../app/providers.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/utils/validators.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Screen 2 — registers a shopper and drops straight into billing.
class CustomerRegistrationScreen extends ConsumerStatefulWidget {
const CustomerRegistrationScreen({super.key, this.prefillMobile});
final String? prefillMobile;
@override
ConsumerState<CustomerRegistrationScreen> createState() =>
_CustomerRegistrationScreenState();
}
class _CustomerRegistrationScreenState
extends ConsumerState<CustomerRegistrationScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _mobile;
final _name = TextEditingController();
final _email = TextEditingController();
Gender _gender = Gender.unspecified;
DateTime? _dob;
bool _saving = false;
String? _serverError;
@override
void initState() {
super.initState();
_mobile = TextEditingController(text: widget.prefillMobile ?? '');
}
@override
void dispose() {
_mobile.dispose();
_name.dispose();
_email.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _serverError = null);
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
final customer = await ref.read(customerRepositoryProvider).create(
Customer(
id: '',
name: _name.text,
mobile: _mobile.text,
email: _email.text,
gender: _gender,
dateOfBirth: _dob,
),
);
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
ref.invalidate(recentCustomersProvider);
if (!mounted) return;
context.go(AppRoutes.pos);
} catch (e) {
if (!mounted) return;
setState(() {
_saving = false;
_serverError = e is StateError ? e.message : 'Could not save customer.';
});
}
}
Future<void> _pickDob() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _dob ?? DateTime(now.year - 25),
firstDate: DateTime(now.year - 100),
lastDate: now,
helpText: 'Date of birth',
);
if (picked != null) setState(() => _dob = picked);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('New Customer'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(),
),
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxxl),
radius: AppRadius.xl,
shadows: AppColors.shadowMd,
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Register a shopper',
style: context.text.headlineSmall),
const SizedBox(height: AppSpacing.xs),
Text(
'Only the mobile number and name are required.',
style: context.text.bodySmall,
),
const SizedBox(height: AppSpacing.xxl),
_Field(
label: 'Mobile Number',
required: true,
child: TextFormField(
controller: _mobile,
autofocus: true,
keyboardType: TextInputType.phone,
maxLength: 10,
validator: Validators.mobile,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
hintText: '10-digit mobile number',
prefixText: '+91 ',
counterText: '',
prefixIcon: Icon(Icons.phone_outlined),
),
),
),
_Field(
label: 'Customer Name',
required: true,
child: TextFormField(
controller: _name,
textCapitalization: TextCapitalization.words,
validator: Validators.name,
decoration: const InputDecoration(
hintText: 'Full name',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
),
_Field(
label: 'Email',
child: TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
validator: Validators.emailOptional,
decoration: const InputDecoration(
hintText: 'name@example.com',
prefixIcon: Icon(Icons.mail_outline_rounded),
),
),
),
_Field(
label: 'Gender',
child: Wrap(
spacing: AppSpacing.sm,
children: Gender.values
.map((g) => ChoiceChip(
label: Text(g.label),
selected: _gender == g,
onSelected: (_) =>
setState(() => _gender = g),
labelStyle: TextStyle(
color: _gender == g
? Colors.white
: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
))
.toList(),
),
),
_Field(
label: 'Date of Birth',
child: InkWell(
onTap: _pickDob,
borderRadius: AppRadius.brMd,
child: InputDecorator(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.cake_outlined),
),
child: Text(
_dob == null
? 'Select a date (optional)'
: Formatters.date(_dob!),
style: TextStyle(
color: _dob == null
? AppColors.textTertiary
: AppColors.textPrimary,
fontSize: 15,
),
),
),
),
),
if (_serverError != null) ...[
const SizedBox(height: AppSpacing.sm),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
_serverError!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13.5,
),
),
),
]),
),
],
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: PrimaryButton(
label: 'Cancel',
tone: ButtonTone.neutral,
onPressed:
_saving ? null : () => context.pop(),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
flex: 2,
child: PrimaryButton(
label: 'Save & Continue',
icon: Icons.check_rounded,
busy: _saving,
onPressed: _save,
),
),
]),
],
),
),
),
),
),
),
);
}
}
class _Field extends StatelessWidget {
const _Field({
required this.label,
required this.child,
this.required = false,
});
final String label;
final Widget child;
final bool required;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(label,
style: context.text.labelMedium
?.copyWith(color: AppColors.textSecondary)),
if (required)
const Text(' *',
style: TextStyle(color: AppColors.danger, fontSize: 13)),
if (!required)
Text(' optional',
style: context.text.labelSmall
?.copyWith(color: AppColors.textTertiary)),
]),
const SizedBox(height: AppSpacing.sm),
child,
],
),
);
}
}

View File

@@ -1,504 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Screen 3 — mobile lookup that auto-searches on the tenth digit.
class ExistingCustomerScreen extends ConsumerStatefulWidget {
const ExistingCustomerScreen({super.key});
@override
ConsumerState<ExistingCustomerScreen> createState() =>
_ExistingCustomerScreenState();
}
class _ExistingCustomerScreenState
extends ConsumerState<ExistingCustomerScreen> {
String _digits = '';
void _append(String d) {
if (_digits.length >= AppConstants.mobileNumberLength) return;
setState(() => _digits += d);
if (_digits.length == AppConstants.mobileNumberLength) _search();
}
void _backspace() {
if (_digits.isEmpty) return;
setState(() => _digits = _digits.substring(0, _digits.length - 1));
ref.read(customerLookupProvider.notifier).reset();
}
void _clear() {
setState(() => _digits = '');
ref.read(customerLookupProvider.notifier).reset();
}
void _search() => ref.read(customerLookupProvider.notifier).search(_digits);
void _continueWith(Customer customer) {
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
context.go(AppRoutes.pos);
}
@override
Widget build(BuildContext context) {
final lookup = ref.watch(customerLookupProvider);
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('Find Customer'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(),
),
actions: [
TextButton.icon(
onPressed: () {
ref.read(cartControllerProvider.notifier).attachCustomer(null);
context.go(AppRoutes.pos);
},
icon: const Icon(Icons.directions_walk_rounded,
color: Colors.white, size: 18),
label: const Text('Continue as Walk-in',
style: TextStyle(color: Colors.white)),
),
const SizedBox(width: AppSpacing.lg),
],
),
body: Padding(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: context.isCompact
? SingleChildScrollView(
child: Column(children: [
_entryPanel(),
const SizedBox(height: AppSpacing.xxl),
// Bound the height: the panel uses Expanded/Spacer
// internally, which a scroll view cannot supply.
SizedBox(height: 480, child: _resultPanel(lookup)),
]),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 4, child: _entryPanel()),
const SizedBox(width: AppSpacing.xxl),
Expanded(flex: 5, child: _resultPanel(lookup)),
],
),
),
);
}
Widget _entryPanel() {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxl),
radius: AppRadius.xl,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Mobile number', style: context.text.labelMedium),
const SizedBox(height: AppSpacing.md),
_display(),
const SizedBox(height: AppSpacing.xxl),
Center(
child: NumericKeypad(
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
onSubmit:
_digits.length == AppConstants.mobileNumberLength
? _search
: null,
submitLabel: 'Search',
),
),
],
),
);
}
/// Ten slots so the cashier can see progress at a glance.
Widget _display() {
return Container(
height: 72,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(children: [
const Text('+91',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
)),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
AppConstants.mobileNumberLength,
(i) {
final filled = i < _digits.length;
return AnimatedContainer(
duration: AppMotion.fast,
width: 22,
alignment: Alignment.center,
child: Text(
filled ? _digits[i] : '',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: filled
? AppColors.textPrimary
: AppColors.textTertiary.withValues(alpha: 0.5),
),
),
);
},
),
),
),
if (_digits.isNotEmpty)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.close_rounded, size: 20),
color: AppColors.textTertiary,
tooltip: 'Clear',
),
]),
);
}
Widget _resultPanel(CustomerLookupState state) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxl),
radius: AppRadius.xl,
child: switch (state) {
LookupIdle() => _idle(),
LookupSearching() => const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
LookupFound(:final customer) => _found(customer),
LookupNotFound(:final mobile) => _notFound(mobile),
LookupError(:final message) => EmptyState(
title: 'Something went wrong',
message: message,
emoji: '⚠️',
),
},
);
}
Widget _idle() {
final recent = ref.watch(recentCustomersProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Recent customers', style: context.text.titleMedium),
const SizedBox(height: AppSpacing.xs),
Text('Tap to select, or key in a mobile number.',
style: context.text.bodySmall),
const SizedBox(height: AppSpacing.lg),
Expanded(
child: recent.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => EmptyState(
title: 'Could not load customers',
message: '$e',
emoji: '⚠️',
compact: true,
),
data: (customers) => customers.isEmpty
? const EmptyState(
title: 'No customers yet',
message: 'Register the first one from the welcome screen.',
emoji: '👤',
compact: true,
)
: ListView.separated(
itemCount: customers.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (_, i) => _RecentTile(
customer: customers[i],
onTap: () => _continueWith(customers[i]),
),
),
),
),
],
);
}
Widget _found(Customer c) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
CircleAvatar(
radius: 30,
backgroundColor: AppColors.primarySurface,
child: Text(
Formatters.initials(c.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Flexible(
child: Text(c.name,
style: context.text.headlineSmall,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(c.tier),
]),
const SizedBox(height: 2),
Text('+91 ${Formatters.mobile(c.mobile)}',
style: context.text.bodyMedium
?.copyWith(color: AppColors.textSecondary)),
],
),
),
]),
if (c.isBirthdayToday) ...[
const SizedBox(height: AppSpacing.lg),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.warningSurface,
borderRadius: AppRadius.brSm,
),
child: const Row(children: [
Text('🎂', style: TextStyle(fontSize: 18)),
SizedBox(width: AppSpacing.sm),
Text("It's their birthday today — wish them!",
style: TextStyle(
color: AppColors.warning,
fontWeight: FontWeight.w600,
)),
]),
),
],
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: _Stat(
label: 'Loyalty Points',
value: '${c.loyaltyPoints}',
caption: 'Worth ${Formatters.money(c.redeemableValue)}',
icon: Icons.stars_rounded,
color: AppColors.tierGold,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: _Stat(
label: 'Lifetime Spend',
value: Formatters.moneyCompact(c.lifetimeSpend),
caption: '${c.visitCount} visits',
icon: Icons.receipt_long_rounded,
color: AppColors.primary,
),
),
]),
if (c.tier.discountRate > 0) ...[
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brSm,
),
child: Row(children: [
const Icon(Icons.local_offer_rounded,
color: AppColors.success, size: 18),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'${c.tier.label} members get '
'${Formatters.percent(c.tier.discountRate)} off '
'automatically on every bill.',
style: const TextStyle(
color: AppColors.success,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
]),
),
],
const Spacer(),
PrimaryButton(
label: 'Continue to Billing',
icon: Icons.point_of_sale_rounded,
large: true,
onPressed: () => _continueWith(c),
),
],
).animate().fadeIn(duration: 200.ms);
}
Widget _notFound(String mobile) {
return Column(
children: [
Expanded(
child: EmptyState(
title: 'No customer found',
message: 'Nobody is registered against '
'+91 ${Formatters.mobile(mobile)}.',
emoji: '🔍',
),
),
PrimaryButton(
label: 'Register Customer',
icon: Icons.person_add_alt_1_rounded,
onPressed: () => context.push(
'${AppRoutes.registerCustomer}?mobile=$mobile',
),
),
const SizedBox(height: AppSpacing.md),
PrimaryButton(
label: 'Continue as Walk-in',
icon: Icons.directions_walk_rounded,
tone: ButtonTone.neutral,
onPressed: () {
ref.read(cartControllerProvider.notifier).attachCustomer(null);
context.go(AppRoutes.pos);
},
),
],
).animate().fadeIn(duration: 200.ms);
}
}
class _RecentTile extends StatelessWidget {
const _RecentTile({required this.customer, required this.onTap});
final Customer customer;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.brMd,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(children: [
CircleAvatar(
radius: 18,
backgroundColor: AppColors.primarySurface,
child: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(customer.name,
style: context.text.titleSmall,
overflow: TextOverflow.ellipsis),
Text(Formatters.maskedMobile(customer.mobile),
style: context.text.bodySmall),
],
),
),
StatusPill.tier(customer.tier, dense: true),
const SizedBox(width: AppSpacing.sm),
const Icon(Icons.chevron_right_rounded,
color: AppColors.textTertiary),
]),
),
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({
required this.label,
required this.value,
required this.caption,
required this.icon,
required this.color,
});
final String label;
final String value;
final String caption;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: AppSpacing.xs),
Text(label,
style: context.text.labelSmall
?.copyWith(color: AppColors.textSecondary)),
]),
const SizedBox(height: AppSpacing.sm),
Text(value,
style: context.text.headlineSmall?.copyWith(color: color)),
Text(caption, style: context.text.bodySmall),
],
),
);
}
}

View File

@@ -0,0 +1,561 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Attaches a customer to the current bill using nothing but a mobile number.
///
/// Registration is deliberately minimal: an unknown number can be saved with
/// just a name, or the whole step skipped. Nothing here blocks the sale.
Future<void> showCustomerCaptureSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const _CustomerCaptureSheet(),
);
}
class _CustomerCaptureSheet extends ConsumerStatefulWidget {
const _CustomerCaptureSheet();
@override
ConsumerState<_CustomerCaptureSheet> createState() =>
_CustomerCaptureSheetState();
}
class _CustomerCaptureSheetState
extends ConsumerState<_CustomerCaptureSheet> {
String _digits = '';
final _name = TextEditingController();
bool _saving = false;
String? _error;
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _append(String d) {
if (_digits.length >= AppConstants.mobileNumberLength) return;
setState(() {
_digits += d;
_error = null;
});
if (_digits.length == AppConstants.mobileNumberLength) {
ref.read(customerLookupProvider.notifier).search(_digits);
}
}
void _backspace() {
if (_digits.isEmpty) return;
setState(() => _digits = _digits.substring(0, _digits.length - 1));
ref.read(customerLookupProvider.notifier).reset();
}
void _clear() {
setState(() {
_digits = '';
_error = null;
});
ref.read(customerLookupProvider.notifier).reset();
}
void _attachAndClose(Customer? customer) {
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
Navigator.of(context).pop();
}
Future<void> _quickRegister() async {
final name = _name.text.trim();
if (name.length < 2) {
setState(() => _error = 'Enter a name to save this customer');
return;
}
setState(() {
_saving = true;
_error = null;
});
try {
final created = await ref.read(customerRepositoryProvider).create(
Customer(id: '', name: name, mobile: _digits),
);
ref.invalidate(recentCustomersProvider);
if (mounted) _attachAndClose(created);
} catch (e) {
if (!mounted) return;
setState(() {
_saving = false;
_error = e is StateError ? e.message : 'Could not save customer.';
});
}
}
@override
Widget build(BuildContext context) {
final lookup = ref.watch(customerLookupProvider);
final attached = ref.watch(
cartControllerProvider.select((c) => c.customer),
);
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_grabber(),
_header(attached),
const Divider(height: 1),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: LayoutBuilder(
builder: (context, constraints) {
// Side by side once there is room for both columns.
final wide = constraints.maxWidth >= 720;
final entry = _entryColumn();
final result = _resultColumn(lookup);
if (!wide) {
return Column(
children: [
entry,
const SizedBox(height: AppSpacing.xl),
result,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: entry),
const SizedBox(width: AppSpacing.xxl),
Expanded(child: result),
],
);
},
),
),
),
],
),
),
),
);
}
Widget _grabber() => Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: AppRadius.brPill,
),
);
Widget _header(Customer? attached) => Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
0,
AppSpacing.md,
AppSpacing.lg,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
attached == null ? 'Add customer' : 'Change customer',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
const Text(
'Optional — for loyalty points and tier discounts',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
onPressed: () => _attachAndClose(null),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('Skip'),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
tooltip: 'Close',
),
],
),
);
Widget _entryColumn() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_display(),
const SizedBox(height: AppSpacing.xl),
Center(
child: NumericKeypad(
maxWidth: 340,
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
),
),
],
);
Widget _display() => Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
children: [
const Text(
'+91',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(width: AppSpacing.md),
// FittedBox guarantees ten digits fit at any sheet width.
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_digits.isEmpty
? ' '
: _digits.split('').join(' '),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: _digits.isEmpty
? AppColors.textTertiary
: AppColors.textPrimary,
),
),
),
),
if (_digits.isNotEmpty)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.close_rounded, size: 20),
color: AppColors.textTertiary,
tooltip: 'Clear',
),
],
),
);
Widget _resultColumn(CustomerLookupState state) => switch (state) {
LookupIdle() => _idle(),
LookupSearching() => const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
child: Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
),
LookupFound(:final customer) => _found(customer),
LookupNotFound() => _notFound(),
LookupError(:final message) => _message(
Icons.error_outline_rounded,
AppColors.danger,
message,
),
};
Widget _idle() {
final recent = ref.watch(recentCustomersProvider).value ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_message(
Icons.dialpad_rounded,
AppColors.textTertiary,
'Key in a 10-digit mobile number. The lookup runs automatically.',
),
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: 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),
PrimaryButton(
label: 'Use this customer',
icon: Icons.check_rounded,
large: true,
onPressed: () => _attachAndClose(c),
),
],
);
Widget _notFound() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_message(
Icons.person_search_rounded,
AppColors.warning,
'Not registered yet. Add a name to save them, or carry on '
'without.',
),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
enabled: !_saving,
onSubmitted: (_) => _quickRegister(),
inputFormatters: [LengthLimitingTextInputFormatter(60)],
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Full name',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
),
],
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Save & use',
icon: Icons.person_add_alt_1_rounded,
large: true,
busy: _saving,
onPressed: _quickRegister,
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: 'Continue without customer',
tone: ButtonTone.neutral,
onPressed: _saving ? null : () => _attachAndClose(null),
),
],
);
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),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
border: Border.all(color: AppColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 19, color: color),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
),
);
}

View File

@@ -5,27 +5,28 @@ 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 '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/sync_event.dart';
import '../../../domain/entities/transaction.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';
/// The terminal's outbound half: what today produced, and what has been sent. /// End-of-day sync.
///
/// Shows what the terminal produced today and uploads every bill still at
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
/// and is retried on the next tap.
class EventsView extends ConsumerWidget { class EventsView extends ConsumerWidget {
const EventsView({super.key}); const EventsView({super.key});
static Color statusColor(SyncStatus s) => switch (s) {
SyncStatus.synced => AppColors.success,
SyncStatus.failed => AppColors.danger,
SyncStatus.syncing => AppColors.info,
SyncStatus.pending => AppColors.warning,
};
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final report = ref.watch(shiftReportProvider); final report = ref.watch(todayReportProvider);
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
final syncState = ref.watch(orderSyncProvider);
final events = ref.watch(syncEventsProvider); final events = ref.watch(syncEventsProvider);
final pushing = ref.watch(reportPushProvider);
final r = report.value;
return ModulePage( return ModulePage(
children: [ children: [
@@ -35,112 +36,110 @@ class EventsView extends ConsumerWidget {
children: [ children: [
StatTile( StatTile(
label: 'Bills Today', label: 'Bills Today',
value: '${report.billCount}', value: '${r?.billCount ?? 0}',
icon: Icons.receipt_long_rounded, icon: Icons.receipt_long_rounded,
caption: report.firstBillAt == null caption: r?.firstBillAt == null
? 'no sales yet' ? 'no sales yet'
: '${Formatters.time(report.firstBillAt!)} ' : '${Formatters.time(r!.firstBillAt!)} '
'${Formatters.time(report.lastBillAt!)}', '${Formatters.time(r.lastBillAt!)}',
), ),
StatTile( StatTile(
label: 'Items Sold', label: 'Items Sold',
value: report.itemCount.toStringAsFixed(0), value: (r?.itemCount ?? 0).toStringAsFixed(0),
icon: Icons.shopping_basket_rounded, icon: Icons.shopping_basket_rounded,
color: AppColors.info, color: AppColors.info,
caption: 'units across all bills', caption: 'units across all bills',
), ),
StatTile( StatTile(
label: "Today's Sales", label: "Today's Sales",
value: Formatters.money(report.grossSales), value: Formatters.money(r?.grossSales ?? 0),
icon: Icons.payments_rounded, icon: Icons.payments_rounded,
color: AppColors.success, color: AppColors.success,
caption: 'gross takings', caption: 'gross takings',
), ),
StatTile( StatTile(
label: 'Average Basket', label: 'Awaiting Sync',
value: Formatters.money(report.averageBasket), value: '$pending',
icon: Icons.trending_up_rounded, icon: Icons.cloud_off_rounded,
color: AppColors.tierGold, color: pending > 0 ? AppColors.warning : AppColors.success,
caption: 'per bill', caption: pending > 0
? 'held on this terminal'
: 'everything uploaded',
), ),
], ],
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
PanelCard( PanelCard(
title: 'Shift report', title: 'Upload bills to server',
subtitle: '${Formatters.date(report.businessDate)} · ' subtitle: r == null
'${report.cashierName} · ${report.terminalId}', ? 'Reading today\u2019s trading from SQLite\u2026'
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
'\u00b7 ${r.terminalId}',
action: TagChip( action: TagChip(
report.isEmpty ? 'Nothing to send' : 'Ready to push', pending > 0 ? '$pending pending' : 'All synced',
color: report.isEmpty ? AppColors.textSecondary : AppColors.warning, color: pending > 0 ? AppColors.warning : AppColors.success,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_row('Bills', '${report.billCount}'), if (r != null && !r.isEmpty) ...[
_row('Items sold', report.itemCount.toStringAsFixed(0)), _row('Bills', '${r.billCount}'),
_row('Gross sales', Formatters.money(report.grossSales)), _row('Items sold', r.itemCount.toStringAsFixed(0)),
_row('Net of tax', Formatters.money(report.netOfTax)), _row('Gross sales', Formatters.money(r.grossSales)),
_row('GST collected', Formatters.money(report.taxCollected)), _row('GST collected', Formatters.money(r.taxCollected)),
_row('Discount given', Formatters.money(report.discountGiven)), _row('Discount given', Formatters.money(r.discountGiven)),
_row('Round off', Formatters.money(report.roundOff)), _row('Average basket', Formatters.money(r.averageBasket)),
_row('Points issued', '${report.loyaltyPointsIssued}'), if (r.paymentBreakdown.isNotEmpty) ...[
_row('Points redeemed', '${report.loyaltyPointsRedeemed}'),
if (report.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl), const Divider(height: AppSpacing.xxl),
const Align( for (final e in r.paymentBreakdown.entries)
alignment: Alignment.centerLeft,
child: Text(
'By payment method',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
const SizedBox(height: AppSpacing.sm),
for (final e in report.paymentBreakdown.entries)
ProgressRow( ProgressRow(
label: '${e.key.emoji} ${e.key.label}', label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value), value: Formatters.money(e.value),
fraction: report.grossSales <= 0 fraction:
? 0 r.grossSales <= 0 ? 0 : e.value / r.grossSales,
: e.value / report.grossSales,
color: _methodColor(e.key), 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),
const SizedBox(height: AppSpacing.xl),
PrimaryButton( PrimaryButton(
label: 'Push report to server', label: pending > 0
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
: 'Nothing to sync',
icon: Icons.cloud_upload_rounded, icon: Icons.cloud_upload_rounded,
large: true, large: true,
busy: pushing, busy: syncState is SyncRunning,
onPressed: report.isEmpty onPressed: pending == 0
? null ? null
: () async { : () => ref.read(orderSyncProvider.notifier).run(),
final event = await ref
.read(reportPushProvider.notifier)
.pushToday();
if (!context.mounted) return;
final ok = event.status == SyncStatus.synced;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
backgroundColor:
ok ? AppColors.success : AppColors.danger,
content: Text(
ok
? 'Shift report sent.'
: 'Push failed — the report is still saved '
'on this terminal.',
),
));
},
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
const Row( const Row(
@@ -151,8 +150,9 @@ class EventsView extends ConsumerWidget {
SizedBox(width: AppSpacing.sm), SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
'A failed push never discards data. The report stays ' 'Bills are written to SQLite the moment a sale '
'queued below and can be retried at any time.', 'completes. A failed upload changes nothing on disk — '
'every bill stays until the server confirms it.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textTertiary, color: AppColors.textTertiary,
@@ -168,25 +168,41 @@ class EventsView extends ConsumerWidget {
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
PanelCard( PanelCard(
title: 'Event log', title: 'Orders',
subtitle: '${events.length} recorded · ' subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
'${events.where((e) => e.status != SyncStatus.synced).length} ' child: ResponsiveTable(
'outstanding', columns: const [
child: events.isEmpty TableCol('Invoice', flex: 3),
? const Padding( TableCol('Time', flex: 2, priority: 1),
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), TableCol('Total', flex: 2, numeric: true),
child: Text( TableCol('Sync', flex: 2, numeric: true),
'No sync activity yet. Importing the catalogue or pushing ' ],
'a report will appear here.', rows: rows
style: TextStyle(color: AppColors.textTertiary), .map((o) => [
Cell(o.invoiceNumber, bold: true, mono: true),
Cell(Formatters.time(o.createdAt),
color: AppColors.textTertiary),
Cell(Formatters.money(o.total), mono: true, bold: true),
TagChip(
o.isSynced ? 'Synced' : 'Pending',
color:
o.isSynced ? AppColors.success : AppColors.warning,
), ),
) ])
: ResponsiveTable( .toList(),
),
),
if (events.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Sync history',
subtitle: 'This session',
child: ResponsiveTable(
columns: const [ columns: const [
TableCol('Event', flex: 3), TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1), TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true, priority: 1), TableCol('Time', flex: 2, numeric: true),
TableCol('Status', flex: 2, numeric: true),
], ],
rows: events rows: events
.map((e) => [ .map((e) => [
@@ -212,17 +228,50 @@ class EventsView extends ConsumerWidget {
), ),
Cell(Formatters.time(e.createdAt), Cell(Formatters.time(e.createdAt),
color: AppColors.textTertiary), color: AppColors.textTertiary),
e.status == SyncStatus.failed
? _RetryButton(eventId: e.id)
: TagChip(
e.status.label,
color: statusColor(e.status),
),
]) ])
.toList(), .toList(),
), ),
), ),
], ],
],
);
}
Widget _outcomeBanner(SyncOutcome outcome) {
final ok = outcome.isSuccess;
final uploaded = outcome.uploaded;
final attempted = outcome.attempted;
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
size: 18,
color: ok ? AppColors.success : AppColors.danger,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
ok
? '$uploaded of $attempted bills uploaded and marked synced.'
: '${outcome.error}',
style: TextStyle(
fontSize: 13,
height: 1.45,
color: ok ? AppColors.success : AppColors.danger,
),
),
),
],
),
); );
} }
@@ -242,6 +291,7 @@ class EventsView extends ConsumerWidget {
Expanded( Expanded(
child: Text( child: Text(
label, label,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 13.5, fontSize: 13.5,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -254,34 +304,9 @@ class EventsView extends ConsumerWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 13.5, fontSize: 13.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
), ),
), ),
], ],
), ),
); );
} }
class _RetryButton extends ConsumerWidget {
const _RetryButton({required this.eventId});
final String eventId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(reportPushProvider);
return TextButton.icon(
onPressed: busy
? null
: () => ref.read(reportPushProvider.notifier).retry(eventId),
icon: const Icon(Icons.refresh_rounded, size: 15),
label: const Text('Retry', style: TextStyle(fontSize: 12.5)),
style: TextButton.styleFrom(
foregroundColor: AppColors.danger,
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
);
}
}

View File

@@ -7,7 +7,6 @@ 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 '../../../domain/entities/store_account.dart'; import '../../../domain/entities/store_account.dart';
import '../../../domain/entities/sync_event.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart'; import '../widgets/module_widgets.dart';
@@ -196,10 +195,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
Widget _connectivityCard() { Widget _connectivityCard() {
final ready = ref.watch(catalogueReadyProvider); final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider); final lastImport = ref.watch(lastImportAtProvider);
final outstanding = ref final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
.watch(syncEventsProvider)
.where((e) => e.status != SyncStatus.synced)
.length;
return PanelCard( return PanelCard(
title: 'Connectivity & sync', title: 'Connectivity & sync',
@@ -213,7 +209,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'Last import', 'Last import',
lastImport == null ? 'Never' : Formatters.dateTime(lastImport), lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
), ),
_row('Outstanding pushes', '$outstanding'), _row('Unsynced bills', '$outstanding'),
_toggle( _toggle(
'Simulate offline', 'Simulate offline',
'Forces import and push to fail, so you can confirm nothing is ' 'Forces import and push to fail, so you can confirm nothing is '
@@ -222,7 +218,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
(v) { (v) {
setState(() => _offline = v); setState(() => _offline = v);
ref.read(remoteCatalogueProvider).simulateOffline = v; ref.read(remoteCatalogueProvider).simulateOffline = v;
ref.read(remoteReportSinkProvider).simulateOffline = v; ref.read(remoteOrderSinkProvider).simulateOffline = v;
}, },
), ),
], ],
@@ -237,7 +233,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
children: [ children: [
_row('Application', '${AppConstants.appName} 1.0.0'), _row('Application', '${AppConstants.appName} 1.0.0'),
_row('Terminal', 'TERM-01'), _row('Terminal', 'TERM-01'),
_row('Data store', 'Local — offline first'), _row('Data store', 'SQLite (on device)'),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -256,20 +252,24 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( Flexible(
width: 148, flex: 3,
child: Text( child: Text(
label, label,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
), ),
Expanded( const SizedBox(width: AppSpacing.md),
Flexible(
flex: 4,
child: Text( child: Text(
value, value,
textAlign: TextAlign.right, textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -8,6 +8,7 @@ import '../../../domain/entities/transaction.dart';
import '../../../domain/usecases/checkout_sale.dart'; import '../../../domain/usecases/checkout_sale.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 '../../sync/providers/sync_controller.dart';
/// UI state for the payment screen. /// UI state for the payment screen.
class PaymentState { class PaymentState {
@@ -161,9 +162,11 @@ class PaymentController extends StateNotifier<PaymentState> {
unawaited(receipts.openCashDrawer()); unawaited(receipts.openCashDrawer());
unawaited(_ref.read(soundServiceProvider).saleComplete()); unawaited(_ref.read(soundServiceProvider).saleComplete());
// Stock changed, so the grid must refresh. // Stock changed, so the grid must refresh; the new order changes the
// unsynced tally and today's totals.
_ref.invalidate(allProductsProvider); _ref.invalidate(allProductsProvider);
_ref.invalidate(visibleProductsProvider); _ref.invalidate(visibleProductsProvider);
_ref.read(orderVersionProvider.notifier).state++;
return result; return result;
} on CheckoutFailure catch (e) { } on CheckoutFailure catch (e) {

View File

@@ -7,18 +7,26 @@ 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/theme/app_typography.dart'; import '../../../core/theme/app_typography.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart'; 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 '../../../domain/entities/transaction.dart'; import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/cart_controller.dart';
import '../providers/payment_controller.dart'; import '../providers/payment_controller.dart';
/// Tender capture and sale completion.
///
/// Customer identification happens here rather than at the start of the sale:
/// a mobile number is all that is asked for, and the step can be skipped.
class PaymentScreen extends ConsumerStatefulWidget { class PaymentScreen extends ConsumerStatefulWidget {
const PaymentScreen({super.key}); const PaymentScreen({super.key});
/// Below this the two columns stack into one scrolling page.
static const double twoColumnAbove = 1080;
@override @override
ConsumerState<PaymentScreen> createState() => _PaymentScreenState(); ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
} }
@@ -27,8 +35,9 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
String _cashBuffer = ''; String _cashBuffer = '';
void _syncCash() { void _syncCash() {
final value = double.tryParse(_cashBuffer) ?? 0; ref
ref.read(paymentControllerProvider.notifier).setCashTendered(value); .read(paymentControllerProvider.notifier)
.setCashTendered(double.tryParse(_cashBuffer) ?? 0);
} }
void _appendCash(String d) { void _appendCash(String d) {
@@ -40,7 +49,8 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
void _backspaceCash() { void _backspaceCash() {
if (_cashBuffer.isEmpty) return; if (_cashBuffer.isEmpty) return;
setState(() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1)); setState(
() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1));
_syncCash(); _syncCash();
} }
@@ -53,7 +63,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
final result = await ref.read(paymentControllerProvider.notifier).confirm(); final result = await ref.read(paymentControllerProvider.notifier).confirm();
if (result == null || !mounted) return; if (result == null || !mounted) return;
// Sale is banked — clear the terminal and show the receipt.
ref.read(cartControllerProvider.notifier).reset(); ref.read(cartControllerProvider.notifier).reset();
context.go(AppRoutes.receipt, extra: result.transaction); context.go(AppRoutes.receipt, extra: result.transaction);
} }
@@ -71,221 +80,321 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded), icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(), onPressed: () => context.pop(),
tooltip: 'Back to bill',
), ),
), ),
body: Padding( body: LayoutBuilder(
padding: const EdgeInsets.all(AppSpacing.xxl), builder: (context, constraints) {
child: context.isCompact final twoColumn = constraints.maxWidth >= PaymentScreen.twoColumnAbove;
? SingleChildScrollView( final pad =
child: Column(children: [ constraints.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
_amountCard(controller, state),
const SizedBox(height: AppSpacing.lg), final left = Column(
_methodsCard(controller, state),
const SizedBox(height: AppSpacing.lg),
_tenderCard(controller, state),
]),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Expanded( _amountCard(controller, state, cart.grandTotal, cart.lineCount),
flex: 4,
child: Column(children: [
_amountCard(controller, state),
const SizedBox(height: AppSpacing.lg),
Expanded(child: _methodsCard(controller, state)),
]),
),
const SizedBox(width: AppSpacing.lg),
Expanded(flex: 5, child: _tenderCard(controller, state)),
],
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
0,
AppSpacing.xxl,
AppSpacing.xxl,
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (state.error != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
margin: const EdgeInsets.only(bottom: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(state.error!,
style: const TextStyle(color: AppColors.danger)),
),
]),
).animate().shake(duration: 300.ms, hz: 3),
],
PrimaryButton(
label: 'Complete Sale',
icon: Icons.check_circle_outline_rounded,
large: true,
tone: ButtonTone.success,
busy: state.isProcessing,
onPressed: cart.isEmpty ? null : _confirm,
trailing: Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(21, color: Colors.white),
),
),
]),
),
),
);
}
// ------------------------------------------------------------- Sections
Widget _amountCard(PaymentController controller, PaymentState state) {
final cart = ref.watch(cartControllerProvider);
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xxl),
radius: AppRadius.xl,
tinted: true,
child: Column(children: [
Text('Amount due', style: context.text.labelMedium),
const SizedBox(height: AppSpacing.xs),
Text(
Formatters.money(controller.balanceDue),
style: AppTypography.money(40, color: AppColors.primary),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [ _customerCard(),
_mini('Items', '${cart.lineCount}'), const SizedBox(height: AppSpacing.md),
_dot(), _methodsCard(controller, state),
_mini('Bill total', Formatters.money(cart.grandTotal)),
if (state.settled > 0) ...[
_dot(),
_mini('Settled', Formatters.money(state.settled)),
], ],
]), );
]),
final right = _tenderCard(controller, state);
if (!twoColumn) {
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: Column(
children: [left, const SizedBox(height: AppSpacing.md), right],
),
); );
} }
Widget _methodsCard(PaymentController controller, PaymentState state) { return Padding(
padding: EdgeInsets.all(pad),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
const SizedBox(width: AppSpacing.lg),
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
],
),
);
},
),
bottomNavigationBar: _bottomBar(cart.grandTotal, state, cart.isEmpty),
);
}
// ------------------------------------------------------------ Amount due
Widget _amountCard(
PaymentController controller,
PaymentState state,
double total,
int lineCount,
) {
return GlassCard( return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xl), padding: const EdgeInsets.all(AppSpacing.xl),
radius: AppRadius.xl, radius: AppRadius.xl,
tinted: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Amount due',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
letterSpacing: 0.4,
),
),
const SizedBox(height: AppSpacing.xs),
// Scales instead of clipping when the amount runs long.
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
Formatters.money(controller.balanceDue),
style: AppTypography.money(38, color: AppColors.primary),
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
alignment: WrapAlignment.center,
spacing: AppSpacing.lg,
runSpacing: AppSpacing.xs,
children: [
_mini('Items', '$lineCount'),
_mini('Bill total', Formatters.money(total)),
if (state.settled > 0)
_mini('Settled', Formatters.money(state.settled)),
],
),
],
),
);
}
// -------------------------------------------------------------- Customer
Widget _customerCard() {
final cart = ref.watch(cartControllerProvider);
final customer = cart.customer;
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
onTap: () => showCustomerCaptureSheet(context),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: customer == null
? AppColors.surfaceAlt
: AppColors.primarySurface,
child: customer == null
? const Icon(Icons.person_add_alt_1_outlined,
size: 19, color: AppColors.textSecondary)
: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(
customer?.name ?? 'Walk-in customer',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
if (customer != null) ...[
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(customer.tier, dense: true),
],
],
),
const SizedBox(height: 1),
Text(
customer == null
? 'Add a mobile number for loyalty — or skip'
: '${Formatters.mobile(customer.mobile)} · earns '
'+${cart.pointsEarned} pts',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
onPressed: () => showCustomerCaptureSheet(context),
style: TextButton.styleFrom(
minimumSize: const Size(0, 38),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
),
child: Text(customer == null ? 'Add' : 'Change'),
),
],
),
);
}
// --------------------------------------------------------------- Methods
Widget _methodsCard(PaymentController controller, PaymentState state) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Payment method', style: context.text.titleMedium), const Text(
const SizedBox(height: AppSpacing.lg), 'Payment method',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
Wrap( Wrap(
spacing: AppSpacing.md, spacing: AppSpacing.sm,
runSpacing: AppSpacing.md, runSpacing: AppSpacing.sm,
children: PaymentMethod.values children: [
.where((m) => m != PaymentMethod.loyalty) for (final m in PaymentMethod.values)
.map((m) => _MethodTile( if (m != PaymentMethod.loyalty)
_MethodTile(
method: m, method: m,
selected: state.activeMethod == m, selected: state.activeMethod == m,
onTap: () { onTap: () {
setState(() => _cashBuffer = ''); setState(() => _cashBuffer = '');
controller.selectMethod(m); controller.selectMethod(m);
}, },
))
.toList(),
), ),
],
),
if (state.splits.isNotEmpty) ...[ if (state.splits.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xl), const Divider(height: AppSpacing.xxl),
const Divider(), Row(
const SizedBox(height: AppSpacing.md), children: [
Row(children: [ const Expanded(
Text('Split tenders', style: context.text.titleSmall), child: Text(
const Spacer(), 'Split tenders',
style:
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
TextButton( TextButton(
onPressed: controller.clearSplits, onPressed: controller.clearSplits,
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.danger), foregroundColor: AppColors.danger,
child: const Text('Clear all'), minimumSize: const Size(0, 32),
), ),
]), child: const Text('Clear'),
const SizedBox(height: AppSpacing.sm), ),
...state.splits.asMap().entries.map((e) => Padding( ],
padding: const EdgeInsets.only(bottom: AppSpacing.sm), ),
child: Row(children: [ for (final e in state.splits.asMap().entries)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
children: [
Text(e.value.method.emoji, Text(e.value.method.emoji,
style: const TextStyle(fontSize: 16)), style: const TextStyle(fontSize: 15)),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded(child: Text(e.value.method.label)), Expanded(
child: Text(
e.value.method.label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13.5),
),
),
Text(Formatters.money(e.value.amount), Text(Formatters.money(e.value.amount),
style: AppTypography.money(14.5)), style: AppTypography.money(13.5)),
IconButton( IconButton(
onPressed: () => controller.removeSplit(e.key), onPressed: () => controller.removeSplit(e.key),
icon: const Icon(Icons.close_rounded, size: 17), icon: const Icon(Icons.close_rounded, size: 16),
color: AppColors.textTertiary, color: AppColors.textTertiary,
constraints: constraints:
const BoxConstraints(minWidth: 30, minHeight: 30), const BoxConstraints(minWidth: 30, minHeight: 30),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
tooltip: 'Remove tender',
),
],
),
), ),
]),
)),
], ],
], ],
), ),
); );
} }
// ---------------------------------------------------------------- Tender
Widget _tenderCard(PaymentController controller, PaymentState state) { Widget _tenderCard(PaymentController controller, PaymentState state) {
final isCash = state.activeMethod.needsChange;
return GlassCard( return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xl), padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl, radius: AppRadius.xl,
child: isCash child: state.activeMethod.needsChange
? _cashTender(controller, state) ? _cashTender(controller)
: _referenceTender(controller, state), : _referenceTender(controller, state),
); );
} }
Widget _cashTender(PaymentController controller, PaymentState state) { Widget _cashTender(PaymentController controller) {
final change = controller.changeDue;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Cash received', style: context.text.titleMedium), const Text(
'Cash received',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl, horizontal: AppSpacing.lg,
vertical: AppSpacing.lg, vertical: AppSpacing.md,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceAlt, color: AppColors.surfaceAlt,
borderRadius: AppRadius.brLg, borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
), ),
child: Row(children: [ child: Row(
children: [
const Text('', const Text('',
style: TextStyle(fontSize: 24, color: AppColors.textTertiary)), style:
TextStyle(fontSize: 22, color: AppColors.textTertiary)),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text( child: Text(
_cashBuffer.isEmpty ? '0' : _cashBuffer, _cashBuffer.isEmpty ? '0' : _cashBuffer,
style: AppTypography.money(30), style: AppTypography.money(28),
), ),
), ),
]),
), ),
],
),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Wrap( Wrap(
spacing: AppSpacing.sm, spacing: AppSpacing.sm,
@@ -296,58 +405,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
label: const Text('Exact'), label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue), onPressed: () => _setCash(controller.balanceDue),
), ),
...[50, 100, 200, 500, 2000].map( for (final note in const [50, 100, 200, 500, 2000])
(note) => ActionChip( ActionChip(
label: Text('$note'), label: Text('$note'),
onPressed: () => _setCash( onPressed: () =>
(double.tryParse(_cashBuffer) ?? 0) + note, _setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
),
), ),
], ],
), ),
const SizedBox(height: AppSpacing.md),
const SizedBox(height: AppSpacing.lg), _changeRow(controller.changeDue),
const SizedBox(height: AppSpacing.md),
AnimatedContainer(
duration: AppMotion.normal,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: change > 0
? AppColors.successSurface
: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
),
child: Row(children: [
Icon(
change > 0
? Icons.currency_exchange_rounded
: Icons.info_outline_rounded,
size: 19,
color: change > 0 ? AppColors.success : AppColors.textTertiary,
),
const SizedBox(width: AppSpacing.md),
Text(
'Change to return',
style: TextStyle(
fontWeight: FontWeight.w600,
color: change > 0
? AppColors.success
: AppColors.textSecondary,
),
),
const Spacer(),
Text(
Formatters.money(change),
style: AppTypography.money(
22,
color: change > 0 ? AppColors.success : AppColors.textTertiary,
),
),
]),
),
const SizedBox(height: AppSpacing.lg),
Center( Center(
child: NumericKeypad( child: NumericKeypad(
allowDecimal: true, allowDecimal: true,
@@ -356,61 +424,100 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
onBackspace: _backspaceCash, onBackspace: _backspaceCash,
), ),
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
OutlinedButton.icon( _splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
onPressed: controller.balanceDue > 0 ],
? () {
controller.addSplit(
amount: (double.tryParse(_cashBuffer) ?? 0)
.clamp(0, controller.balanceDue)
.toDouble(),
); );
setState(() => _cashBuffer = '');
} }
: null,
icon: const Icon(Icons.call_split_rounded, size: 17), Widget _changeRow(double change) {
label: const Text('Add as split payment'), final active = change > 0;
return AnimatedContainer(
duration: AppMotion.normal,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: active ? AppColors.successSurface : AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
),
child: Row(
children: [
Icon(
active
? Icons.currency_exchange_rounded
: Icons.info_outline_rounded,
size: 18,
color: active ? AppColors.success : AppColors.textTertiary,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Change to return',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: active ? AppColors.success : AppColors.textSecondary,
),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
Formatters.money(change),
style: AppTypography.money(
20,
color: active ? AppColors.success : AppColors.textTertiary,
),
), ),
], ],
),
); );
} }
Widget _referenceTender(PaymentController controller, PaymentState state) { Widget _referenceTender(PaymentController controller, PaymentState state) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row(children: [ Row(
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 22)), children: [
Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Text('${state.activeMethod.label} payment', Expanded(
style: context.text.titleMedium), child: Text(
]), '${state.activeMethod.label} payment',
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
Center( Center(
child: Column(children: [ child: Container(
Container( width: 104,
width: 120, height: 104,
height: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primarySurface, color: AppColors.primarySurface,
borderRadius: AppRadius.brXl, borderRadius: AppRadius.brXl,
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text(state.activeMethod.emoji, child: Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 52)), style: const TextStyle(fontSize: 46)),
),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Text( Text(
'Charge ${Formatters.money(controller.balanceDue)} ' 'Charge ${Formatters.money(controller.balanceDue)} on the '
'on the ${state.activeMethod.label.toLowerCase()} terminal', '${state.activeMethod.label.toLowerCase()} terminal',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: context.text.bodyMedium, style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
height: 1.5,
), ),
]),
), ),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
if (state.activeMethod.needsReference) if (state.activeMethod.needsReference)
TextField( TextField(
@@ -425,37 +532,103 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
prefixIcon: const Icon(Icons.tag_rounded), prefixIcon: const Icon(Icons.tag_rounded),
), ),
), ),
const SizedBox(height: AppSpacing.lg),
const SizedBox(height: AppSpacing.xxxl), _splitButton(controller),
OutlinedButton.icon(
onPressed: controller.balanceDue > 0
? () => controller.addSplit()
: null,
icon: const Icon(Icons.call_split_rounded, size: 17),
label: const Text('Add as split payment'),
),
], ],
); );
} }
Widget _mini(String label, String value) => Column(children: [ Widget _splitButton(PaymentController controller, {double? amount}) {
Text(label, return OutlinedButton.icon(
onPressed: controller.balanceDue > 0
? () {
controller.addSplit(
amount: amount == null
? null
: amount.clamp(0, controller.balanceDue).toDouble(),
);
setState(() => _cashBuffer = '');
}
: null,
icon: const Icon(Icons.call_split_rounded, size: 17),
label: const Text('Add as split payment'),
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
);
}
// ------------------------------------------------------------ Bottom bar
Widget _bottomBar(double total, PaymentState state, bool cartEmpty) {
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: [
if (state.error != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
margin: const EdgeInsets.only(bottom: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
state.error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13,
),
),
),
],
),
).animate().shake(duration: 300.ms, hz: 3),
PrimaryButton(
label: 'Complete Sale',
icon: Icons.check_circle_outline_rounded,
large: true,
tone: ButtonTone.success,
busy: state.isProcessing,
onPressed: cartEmpty ? null : _confirm,
trailing: Text(
Formatters.money(total),
style: AppTypography.money(19, color: Colors.white),
),
),
],
),
),
);
}
Widget _mini(String label, String value) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: AppColors.textSecondary, color: AppColors.textSecondary,
)),
Text(value,
style: AppTypography.money(14, weight: FontWeight.w600)),
]);
Widget _dot() => Container(
width: 3,
height: 3,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
decoration: const BoxDecoration(
color: AppColors.textTertiary,
shape: BoxShape.circle,
), ),
),
Text(value, style: AppTypography.money(13.5)),
],
); );
} }
@@ -480,10 +653,10 @@ class _MethodTile extends StatelessWidget {
borderRadius: AppRadius.brMd, borderRadius: AppRadius.brMd,
child: AnimatedContainer( child: AnimatedContainer(
duration: AppMotion.fast, duration: AppMotion.fast,
width: 118, width: 104,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md, horizontal: AppSpacing.sm,
vertical: AppSpacing.lg, vertical: AppSpacing.md,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: AppRadius.brMd, borderRadius: AppRadius.brMd,
@@ -491,19 +664,24 @@ class _MethodTile extends StatelessWidget {
color: selected ? AppColors.primary : AppColors.border, color: selected ? AppColors.primary : AppColors.border,
), ),
), ),
child: Column(children: [ child: Column(
Text(method.emoji, style: const TextStyle(fontSize: 24)), mainAxisSize: MainAxisSize.min,
const SizedBox(height: AppSpacing.sm), children: [
Text(method.emoji, style: const TextStyle(fontSize: 22)),
const SizedBox(height: AppSpacing.xs),
Text( Text(
method.label, method.label,
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 12.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColors.textPrimary, color: selected ? Colors.white : AppColors.textPrimary,
), ),
), ),
]), ],
),
), ),
), ),
); );

View File

@@ -7,7 +7,6 @@ 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/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
import '../../../domain/entities/sync_event.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../../sync/widgets/sign_out_dialog.dart'; import '../../sync/widgets/sign_out_dialog.dart';
import '../providers/cart_controller.dart'; import '../providers/cart_controller.dart';
@@ -232,10 +231,7 @@ class _Section extends ConsumerWidget {
final active = ref.watch(activeModuleProvider); final active = ref.watch(activeModuleProvider);
final cartCount = ref.watch(cartItemCountProvider); final cartCount = ref.watch(cartItemCountProvider);
final ready = ref.watch(catalogueReadyProvider); final ready = ref.watch(catalogueReadyProvider);
final outstanding = ref final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
.watch(syncEventsProvider)
.where((e) => e.status != SyncStatus.synced)
.length;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -82,17 +82,24 @@ class _Header extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg, AppSpacing.lg,
AppSpacing.md, AppSpacing.md,
AppSpacing.lg, AppSpacing.sm,
AppSpacing.md,
), ),
child: Row(children: [ child: Row(
Text('Cart', style: context.text.headlineSmall), children: [
Flexible(
child: Text(
'Cart',
overflow: TextOverflow.ellipsis,
style: context.text.titleLarge,
),
),
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
if (cart.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primarySurface, color: AppColors.primarySurface,
borderRadius: AppRadius.brPill, borderRadius: AppRadius.brPill,
@@ -101,42 +108,78 @@ class _Header extends ConsumerWidget {
'${cart.lineCount}', '${cart.lineCount}',
style: const TextStyle( style: const TextStyle(
color: AppColors.primary, color: AppColors.primary,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
fontSize: 13, fontSize: 12.5,
), ),
), ),
), ),
],
const Spacer(), const Spacer(),
// Icon-only actions: labelled buttons overflowed the 380px panel.
if (controller.canUndo) if (controller.canUndo)
IconButton( _IconAction(
icon: Icons.undo_rounded,
tooltip: 'Undo (F8)', tooltip: 'Undo (F8)',
onPressed: controller.undo,
icon: const Icon(Icons.undo_rounded, size: 19),
color: AppColors.textSecondary, color: AppColors.textSecondary,
onTap: controller.undo,
), ),
if (cart.isNotEmpty) ...[ if (cart.isNotEmpty) ...[
TextButton.icon( _IconAction(
onPressed: () async { icon: Icons.pause_circle_outline_rounded,
tooltip: 'Park bill',
color: AppColors.warning,
onTap: () async {
await controller.park(); await controller.park();
ref.invalidate(parkedBillsProvider); ref.invalidate(parkedBillsProvider);
if (context.mounted) context.showSnack('Bill parked'); if (context.mounted) context.showSnack('Bill parked');
}, },
icon: const Icon(Icons.pause_circle_outline_rounded, size: 17),
label: const Text('Park'),
style: TextButton.styleFrom(foregroundColor: AppColors.warning),
), ),
TextButton( _IconAction(
onPressed: controller.clear, icon: Icons.delete_outline_rounded,
style: TextButton.styleFrom(foregroundColor: AppColors.danger), tooltip: 'Clear bill',
child: const Text('Clear'), color: AppColors.danger,
onTap: controller.clear,
), ),
], ],
if (inSheet) if (inSheet)
IconButton( _IconAction(
onPressed: () => Navigator.of(context).pop(), icon: Icons.close_rounded,
icon: const Icon(Icons.close_rounded), tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
}
}
/// Compact square action used in the bill header.
class _IconAction extends StatelessWidget {
const _IconAction({
required this.icon,
required this.tooltip,
required this.color,
required this.onTap,
});
final IconData icon;
final String tooltip;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: IconButton(
onPressed: onTap,
icon: Icon(icon, size: 19),
color: color,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
), ),
]),
); );
} }
} }
@@ -305,11 +348,16 @@ class _Row extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [ child: Row(children: [
Text(label, Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.textSecondary, color: AppColors.textSecondary,
)), ),
),
),
if (hint != null) ...[ if (hint != null) ...[
const SizedBox(width: AppSpacing.xs), const SizedBox(width: AppSpacing.xs),
Text('($hint)', Text('($hint)',
@@ -318,6 +366,7 @@ class _Row extends StatelessWidget {
color: AppColors.textTertiary, color: AppColors.textTertiary,
)), )),
], ],
const SizedBox(width: AppSpacing.sm),
const Spacer(), const Spacer(),
Text( Text(
value, value,

View File

@@ -22,6 +22,10 @@ class CartFab extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.all(AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.lg),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width - AppSpacing.xxxl,
),
child: Material( child: Material(
color: AppColors.primary, color: AppColors.primary,
borderRadius: AppRadius.brLg, borderRadius: AppRadius.brLg,
@@ -57,15 +61,18 @@ class CartFab extends ConsumerWidget {
), ),
), ),
const SizedBox(width: AppSpacing.md), const SizedBox(width: AppSpacing.md),
const Text( const Flexible(
child: Text(
'View bill', 'View bill',
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: AppSpacing.xl), ),
const SizedBox(width: AppSpacing.lg),
Text( Text(
Formatters.money(cart.grandTotal), Formatters.money(cart.grandTotal),
style: AppTypography.money(19, color: Colors.white), style: AppTypography.money(19, color: Colors.white),
@@ -78,6 +85,7 @@ class CartFab extends ConsumerWidget {
), ),
), ),
), ),
),
).animate().fadeIn(duration: 180.ms).slideY(begin: 0.3, end: 0); ).animate().fadeIn(duration: 180.ms).slideY(begin: 0.3, end: 0);
} }
} }

View File

@@ -1,13 +1,11 @@
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 'package:go_router/go_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';
import '../../../core/utils/extensions.dart'; import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart'; import '../../../core/utils/formatters.dart';
import '../../../core/widgets/status_pill.dart'; import '../../../core/widgets/status_pill.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart'; import '../providers/cart_controller.dart';
/// Strip above the product grid showing who the sale belongs to. /// Strip above the product grid showing who the sale belongs to.
@@ -85,7 +83,7 @@ class CustomerBar extends ConsumerWidget {
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => context.push(AppRoutes.existingCustomer), onPressed: () => showCustomerCaptureSheet(context),
icon: const Icon(Icons.sync_alt_rounded, size: 17), icon: const Icon(Icons.sync_alt_rounded, size: 17),
label: Text(customer == null ? 'Add Customer' : 'Change'), label: Text(customer == null ? 'Add Customer' : 'Change'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(

View File

@@ -137,12 +137,12 @@ class _DiscountSheetState extends State<_DiscountSheet> {
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: DiscountType.percentage, value: DiscountType.percentage,
label: Text('Percentage'), label: Text('Percent'),
icon: Icon(Icons.percent_rounded, size: 17), icon: Icon(Icons.percent_rounded, size: 17),
), ),
ButtonSegment( ButtonSegment(
value: DiscountType.flat, value: DiscountType.flat,
label: Text('Flat amount'), label: Text('Flat'),
icon: Icon(Icons.currency_rupee_rounded, size: 17), icon: Icon(Icons.currency_rupee_rounded, size: 17),
), ),
], ],

View File

@@ -1,9 +1,7 @@
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 'package:go_router/go_router.dart';
import '../../../app/providers.dart'; import '../../../app/providers.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';
import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_layout.dart';
@@ -29,6 +27,8 @@ class PageHeader extends ConsumerWidget {
final module = ref.watch(activeModuleProvider); final module = ref.watch(activeModuleProvider);
final now = ref.watch(clockProvider).value ?? DateTime.now(); final now = ref.watch(clockProvider).value ?? DateTime.now();
final compact = layout.sidebarIsDrawer; final compact = layout.sidebarIsDrawer;
// Status chrome is the first thing dropped when width gets tight.
final showStatus = MediaQuery.sizeOf(context).width >= 1180;
return Container( return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
@@ -75,7 +75,7 @@ class PageHeader extends ConsumerWidget {
const Spacer(), const Spacer(),
if (!compact) ...[ if (showStatus) ...[
const _LivePill(), const _LivePill(),
const SizedBox(width: AppSpacing.lg), const SizedBox(width: AppSpacing.lg),
Text( Text(
@@ -250,7 +250,7 @@ class _ParkedBillsButton extends ConsumerWidget {
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
title: const Text('Parked bills'), title: const Text('Parked bills'),
content: SizedBox( content: SizedBox(
width: 380, width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 380.0),
child: ListView.separated( child: ListView.separated(
shrinkWrap: true, shrinkWrap: true,
itemCount: parked.length, itemCount: parked.length,
@@ -297,7 +297,10 @@ class _NewSaleButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
void start() { void start() {
ref.read(cartControllerProvider.notifier).reset(); ref.read(cartControllerProvider.notifier).reset();
context.go(AppRoutes.welcome); ref.read(activeModuleProvider.notifier).state = PosModule.pos;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('Started a new sale.')));
} }
if (compact) { if (compact) {

View File

@@ -88,8 +88,11 @@ class _ProductCardState extends State<ProductCard> {
), ),
), ),
const SizedBox(height: AppSpacing.xs + 2), const SizedBox(height: AppSpacing.xs + 2),
Row( // Scales down rather than overflowing on small tiles.
mainAxisAlignment: MainAxisAlignment.center, FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
@@ -115,6 +118,7 @@ class _ProductCardState extends State<ProductCard> {
], ],
], ],
), ),
),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
Text( Text(
disabled disabled

View File

@@ -58,7 +58,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
void _newSale() { void _newSale() {
_timer?.cancel(); _timer?.cancel();
ref.read(cartControllerProvider.notifier).reset(); ref.read(cartControllerProvider.notifier).reset();
if (mounted) context.go(AppRoutes.welcome); if (mounted) context.go(AppRoutes.pos);
} }
void _continueBilling() { void _continueBilling() {

View File

@@ -3,10 +3,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart'; import '../../../app/providers.dart';
import '../../../domain/entities/shift_report.dart'; import '../../../domain/entities/shift_report.dart';
import '../../../domain/entities/sync_event.dart'; import '../../../domain/entities/sync_event.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../../pos/providers/catalog_providers.dart'; import '../../pos/providers/catalog_providers.dart';
/// Progress of a catalogue pull. /// Bumped after every import so catalogue-backed providers refetch.
final catalogueVersionProvider = StateProvider<int>((ref) => 0);
/// Bumped after every sale or sync so order-backed providers refetch.
final orderVersionProvider = StateProvider<int>((ref) => 0);
/// Whether products exist on this terminal. Billing is gated on it.
final catalogueReadyProvider = Provider<bool>((ref) {
ref.watch(catalogueVersionProvider);
return ref.watch(syncRepositoryProvider).hasCatalogue;
});
final lastImportAtProvider = Provider<DateTime?>((ref) {
ref.watch(catalogueVersionProvider);
return ref.watch(syncRepositoryProvider).lastImportAt;
});
// ------------------------------------------------------- Morning: import
sealed class ImportState { sealed class ImportState {
const ImportState(); const ImportState();
} }
@@ -34,20 +52,6 @@ class ImportFailed extends ImportState {
final String message; final String message;
} }
/// Bumped after every successful import so catalogue providers refetch.
final catalogueVersionProvider = StateProvider<int>((ref) => 0);
/// Whether the terminal has products to sell. The POS is gated on this.
final catalogueReadyProvider = Provider<bool>((ref) {
ref.watch(catalogueVersionProvider);
return ref.watch(syncRepositoryProvider).hasCatalogue;
});
final lastImportAtProvider = Provider<DateTime?>((ref) {
ref.watch(catalogueVersionProvider);
return ref.watch(syncRepositoryProvider).lastImportAt;
});
class CatalogueImportController extends StateNotifier<ImportState> { class CatalogueImportController extends StateNotifier<ImportState> {
CatalogueImportController(this._ref) : super(const ImportIdle()); CatalogueImportController(this._ref) : super(const ImportIdle());
@@ -55,7 +59,6 @@ class CatalogueImportController extends StateNotifier<ImportState> {
Future<bool> run() async { Future<bool> run() async {
if (state is ImportRunning) return false; if (state is ImportRunning) return false;
state = const ImportRunning(0, 'Starting…'); state = const ImportRunning(0, 'Starting…');
final event = await _ref.read(syncRepositoryProvider).importCatalogue( final event = await _ref.read(syncRepositoryProvider).importCatalogue(
@@ -65,13 +68,11 @@ class CatalogueImportController extends StateNotifier<ImportState> {
); );
if (event.status == SyncStatus.synced) { if (event.status == SyncStatus.synced) {
// Force every catalogue-backed provider to refetch.
_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);
state = ImportDone(event); state = ImportDone(event);
return true; return true;
} }
@@ -79,8 +80,6 @@ class CatalogueImportController extends StateNotifier<ImportState> {
state = ImportFailed(event.error ?? 'Import failed.'); state = ImportFailed(event.error ?? 'Import failed.');
return false; return false;
} }
void reset() => state = const ImportIdle();
} }
final catalogueImportProvider = final catalogueImportProvider =
@@ -88,69 +87,89 @@ final catalogueImportProvider =
(ref) => CatalogueImportController(ref), (ref) => CatalogueImportController(ref),
); );
// ------------------------------------------------------------------ Events // ---------------------------------------------------- Business hours: read
/// Bumped whenever the event log changes. /// Bills still held on this terminal at sync_status = 0.
final syncVersionProvider = StateProvider<int>((ref) => 0); final unsyncedCountProvider = FutureProvider<int>((ref) {
ref.watch(orderVersionProvider);
final syncEventsProvider = Provider<List<SyncEvent>>((ref) { return ref.watch(syncRepositoryProvider).unsyncedCount();
ref.watch(syncVersionProvider);
ref.watch(catalogueVersionProvider);
return ref.watch(syncRepositoryProvider).events;
}); });
final hasUnsyncedProvider = Provider<bool>((ref) { /// Today's totals, read back from SQLite.
ref.watch(syncVersionProvider); final todayReportProvider = FutureProvider<ShiftReport>((ref) {
ref.watch(catalogueVersionProvider); ref.watch(orderVersionProvider);
return ref.watch(syncRepositoryProvider).hasUnsyncedEvents;
});
/// Today's takings, recomputed from local sales on every change.
final shiftReportProvider = Provider<ShiftReport>((ref) {
ref.watch(syncVersionProvider);
final session = ref.watch(cashierSessionProvider); final session = ref.watch(cashierSessionProvider);
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
return ref.watch(syncRepositoryProvider).buildShiftReport( return ref.watch(syncRepositoryProvider).todayReport(
businessDate: DateTime.now(),
terminalId: session.terminalId, terminalId: session.terminalId,
cashierName: user?.name ?? session.name, cashierName: user?.name ?? session.name,
); );
}); });
/// Drives the push button and the sign-out dialog. /// Per-order sync state for the events log.
class ReportPushController extends StateNotifier<bool> { final orderSyncRowsProvider = FutureProvider<List<OrderSyncRow>>((ref) {
ReportPushController(this._ref) : super(false); ref.watch(orderVersionProvider);
return ref.watch(syncRepositoryProvider).orderSyncRows();
});
final syncEventsProvider = Provider<List<SyncEvent>>((ref) {
ref.watch(orderVersionProvider);
ref.watch(catalogueVersionProvider);
return ref.watch(syncRepositoryProvider).events;
});
// ------------------------------------------------------ End of day: upload
sealed class OrderSyncState {
const OrderSyncState();
}
class SyncIdle extends OrderSyncState {
const SyncIdle();
}
class SyncRunning extends OrderSyncState {
const SyncRunning(this.progress, this.stage);
final double progress;
final String stage;
}
class SyncFinished extends OrderSyncState {
const SyncFinished(this.outcome);
final SyncOutcome outcome;
}
class OrderSyncController extends StateNotifier<OrderSyncState> {
OrderSyncController(this._ref) : super(const SyncIdle());
final Ref _ref; final Ref _ref;
/// Pushes today's report. Returns the resulting event so the caller can bool get isRunning => state is SyncRunning;
/// tell the cashier whether it landed.
Future<SyncEvent> pushToday() async { /// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
state = true; Future<SyncOutcome> run() async {
try { if (isRunning) {
final report = _ref.read(shiftReportProvider); return const SyncOutcome(attempted: 0, uploaded: 0);
final event =
await _ref.read(syncRepositoryProvider).pushShiftReport(report);
_ref.read(syncVersionProvider.notifier).state++;
return event;
} finally {
if (mounted) state = false;
}
} }
Future<SyncEvent> retry(String eventId) async { state = const SyncRunning(0, 'Starting…');
state = true;
try { final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
final event = await _ref.read(syncRepositoryProvider).retry(eventId); onProgress: (progress, stage) {
_ref.read(syncVersionProvider.notifier).state++; if (mounted) state = SyncRunning(progress, stage);
return event; },
} finally { );
if (mounted) state = false;
} _ref.read(orderVersionProvider.notifier).state++;
if (mounted) state = SyncFinished(outcome);
return outcome;
} }
void reset() => state = const SyncIdle();
} }
final reportPushProvider = final orderSyncProvider =
StateNotifierProvider<ReportPushController, bool>( StateNotifierProvider<OrderSyncController, OrderSyncState>(
(ref) => ReportPushController(ref), (ref) => OrderSyncController(ref),
); );

View File

@@ -7,9 +7,9 @@ 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 '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/sync_event.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 '../../../domain/repositories/sync_repository.dart';
import '../providers/sync_controller.dart'; import '../providers/sync_controller.dart';
/// End-of-shift flow. /// End-of-shift flow.
@@ -33,7 +33,7 @@ class _SignOutDialog extends ConsumerStatefulWidget {
} }
class _SignOutDialogState extends ConsumerState<_SignOutDialog> { class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
SyncEvent? _result; SyncOutcome? _result;
void _finish() { void _finish() {
ref.read(cartControllerProvider.notifier).reset(); ref.read(cartControllerProvider.notifier).reset();
@@ -43,12 +43,12 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
} }
Future<void> _pushThenFinish() async { Future<void> _pushThenFinish() async {
final event = await ref.read(reportPushProvider.notifier).pushToday(); final outcome = await ref.read(orderSyncProvider.notifier).run();
if (!mounted) return; if (!mounted) return;
setState(() => _result = event); setState(() => _result = outcome);
if (event.status == SyncStatus.synced) { if (outcome.isSuccess) {
await Future<void>.delayed(const Duration(milliseconds: 700)); await Future<void>.delayed(const Duration(milliseconds: 700));
if (mounted) _finish(); if (mounted) _finish();
} }
@@ -56,10 +56,11 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final report = ref.watch(shiftReportProvider); final report = ref.watch(todayReportProvider).value;
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final cart = ref.watch(cartControllerProvider); final cart = ref.watch(cartControllerProvider);
final pushing = ref.watch(reportPushProvider); final pushing = ref.watch(orderSyncProvider) is SyncRunning;
final failed = _result?.status == SyncStatus.failed; final failed = _result != null && !_result!.isSuccess;
return AlertDialog( return AlertDialog(
title: const Text('End shift'), title: const Text('End shift'),
@@ -70,7 +71,8 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
AppSpacing.sm, AppSpacing.sm,
), ),
content: SizedBox( content: SizedBox(
width: 420, // Never wider than the viewport allows.
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 420.0),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -85,17 +87,17 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
'and will be cleared. Park it first if you need it.', 'and will be cleared. Park it first if you need it.',
), ),
if (report.isEmpty) if (pending == 0)
const _Banner( const _Banner(
icon: Icons.info_outline_rounded, icon: Icons.check_circle_outline_rounded,
color: AppColors.textSecondary, color: AppColors.success,
background: AppColors.surfaceAlt, background: AppColors.successSurface,
message: 'No sales were recorded today, so there is nothing ' message: 'Every bill has already been uploaded. Nothing is '
'to push.', 'waiting on this terminal.',
) )
else ...[ else ...[
const Text( const Text(
"Today's takings", 'Waiting to upload',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -103,12 +105,14 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
), ),
), ),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
_row('Bills', '${report.billCount}'), _row('Bills pending sync', '$pending'),
if (report != null) ...[
_row('Bills today', '${report.billCount}'),
_row('Items sold', report.itemCount.toStringAsFixed(0)), _row('Items sold', report.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(report.grossSales)), _row('Gross sales', Formatters.money(report.grossSales)),
_row('GST collected', Formatters.money(report.taxCollected)), _row('GST collected',
_row('Average basket', Formatters.money(report.taxCollected)),
Formatters.money(report.averageBasket)), ],
], ],
if (failed) ...[ if (failed) ...[
@@ -118,7 +122,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
color: AppColors.danger, color: AppColors.danger,
background: AppColors.dangerSurface, background: AppColors.dangerSurface,
message: _result?.error ?? message: _result?.error ??
'The push failed. The report is still saved on this ' 'Upload failed. Every bill is still stored on this '
'terminal and can be retried from Events.', 'terminal and can be retried from Events.',
), ),
], ],
@@ -149,14 +153,14 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
foregroundColor: AppColors.textSecondary, foregroundColor: AppColors.textSecondary,
), ),
child: Text( child: Text(
report.isEmpty ? 'Sign out' : 'Sign out without pushing', pending == 0 ? 'Sign out' : 'Sign out without syncing',
), ),
), ),
if (!report.isEmpty) if (pending > 0)
SizedBox( SizedBox(
width: 190, width: 190,
child: PrimaryButton( child: PrimaryButton(
label: failed ? 'Retry push' : 'Push & sign out', label: failed ? 'Retry sync' : 'Sync & sign out',
icon: Icons.cloud_upload_rounded, icon: Icons.cloud_upload_rounded,
busy: pushing, busy: pushing,
onPressed: _pushThenFinish, onPressed: _pushThenFinish,

View File

@@ -1,337 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart';
import '../../pos/providers/cart_controller.dart';
import '../widgets/welcome_illustration.dart';
/// Screen 1 — the terminal's resting state between sales.
class WelcomeScreen extends ConsumerWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(cashierSessionProvider);
final clock = ref.watch(clockProvider).value ?? DateTime.now();
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.primaryGradient),
child: SafeArea(
child: Column(
children: [
_TopBar(cashier: session.name, now: clock),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 920),
child: GlassCard(
blur: 18,
padding: EdgeInsets.all(
context.responsive(
compact: AppSpacing.xxl,
expanded: AppSpacing.giant,
),
),
radius: AppRadius.xxl,
shadows: AppColors.shadowLg,
child: context.isCompact
? const _StackedLayout()
: const _SideBySideLayout(),
),
),
).animate().fadeIn(duration: 350.ms).slideY(
begin: 0.04,
end: 0,
curve: Curves.easeOutCubic,
),
),
),
const _BottomHint(),
],
),
),
),
);
}
}
class _SideBySideLayout extends StatelessWidget {
const _SideBySideLayout();
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
Expanded(flex: 4, child: WelcomeIllustration(size: 260)),
SizedBox(width: AppSpacing.giant),
Expanded(flex: 5, child: _WelcomeContent()),
],
);
}
}
class _StackedLayout extends StatelessWidget {
const _StackedLayout();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: const [
WelcomeIllustration(size: 150),
SizedBox(height: AppSpacing.xxl),
_WelcomeContent(),
],
);
}
}
class _WelcomeContent extends ConsumerWidget {
const _WelcomeContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome to',
style: context.text.titleMedium?.copyWith(
color: AppColors.textSecondary,
letterSpacing: 1.4,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
'Nearle POS',
style: context.text.displaySmall?.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: AppSpacing.md),
Text(
'Start a new sale by identifying the shopper, '
'or skip straight to billing.',
style: context.text.bodyMedium
?.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: AppSpacing.xxxl),
_WelcomeAction(
icon: Icons.person_add_alt_1_rounded,
title: 'New Customer',
subtitle: 'Register and start earning loyalty points',
onTap: () => context.push(AppRoutes.registerCustomer),
primary: true,
),
const SizedBox(height: AppSpacing.md),
_WelcomeAction(
icon: Icons.badge_outlined,
title: 'Existing Customer',
subtitle: 'Look up by mobile number',
onTap: () => context.push(AppRoutes.existingCustomer),
),
const SizedBox(height: AppSpacing.md),
_WelcomeAction(
icon: Icons.directions_walk_rounded,
title: 'Skip Customer',
subtitle: 'Walk-in sale, no loyalty tracking',
onTap: () {
ref.read(cartControllerProvider.notifier).reset();
context.go(AppRoutes.pos);
},
),
],
);
}
}
/// A tall, unmistakable target — the cashier taps this hundreds of times a day.
class _WelcomeAction extends StatefulWidget {
const _WelcomeAction({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.primary = false,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool primary;
@override
State<_WelcomeAction> createState() => _WelcomeActionState();
}
class _WelcomeActionState extends State<_WelcomeAction> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final bg = widget.primary
? AppColors.primary
: (_hovered ? AppColors.primarySurface : AppColors.surface);
final fg =
widget.primary ? AppColors.textOnPrimary : AppColors.textPrimary;
final sub = widget.primary
? AppColors.textOnPrimary.withValues(alpha: 0.78)
: AppColors.textSecondary;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: AppMotion.fast,
transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
child: Material(
color: bg,
borderRadius: AppRadius.brLg,
child: InkWell(
onTap: widget.onTap,
borderRadius: AppRadius.brLg,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.lg,
),
decoration: BoxDecoration(
borderRadius: AppRadius.brLg,
border: Border.all(
color: widget.primary
? Colors.transparent
: AppColors.border,
),
boxShadow: widget.primary && _hovered
? AppColors.shadowMd
: null,
),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: widget.primary
? Colors.white.withValues(alpha: 0.18)
: AppColors.primarySurface,
borderRadius: AppRadius.brMd,
),
child: Icon(
widget.icon,
color: widget.primary
? AppColors.textOnPrimary
: AppColors.primary,
size: 22,
),
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: context.text.titleMedium?.copyWith(color: fg),
),
const SizedBox(height: 2),
Text(
widget.subtitle,
style: context.text.bodySmall?.copyWith(color: sub),
),
],
),
),
Icon(Icons.arrow_forward_rounded, color: sub, size: 20),
],
),
),
),
),
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({required this.cashier, required this.now});
final String cashier;
final DateTime now;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xxl,
vertical: AppSpacing.lg,
),
child: Row(
children: [
const Icon(Icons.storefront_rounded,
color: Colors.white, size: 26),
const SizedBox(width: AppSpacing.md),
Text(
AppConstants.storeName,
style: context.text.titleLarge?.copyWith(color: Colors.white),
),
const Spacer(),
Text(
'${Formatters.date(now)} ${Formatters.time(now)}',
style: context.text.bodyMedium
?.copyWith(color: Colors.white.withValues(alpha: 0.85)),
),
const SizedBox(width: AppSpacing.xxl),
CircleAvatar(
radius: 16,
backgroundColor: Colors.white.withValues(alpha: 0.2),
child: Text(
Formatters.initials(cashier),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.sm),
Text(cashier,
style: context.text.bodyMedium?.copyWith(color: Colors.white)),
],
),
);
}
}
class _BottomHint extends StatelessWidget {
const _BottomHint();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Text(
'Scan a barcode at any time to begin a walk-in sale',
style: context.text.bodySmall
?.copyWith(color: Colors.white.withValues(alpha: 0.7)),
),
);
}
}

View File

@@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../core/theme/app_colors.dart';
/// Vector shopping-cart illustration drawn in code.
///
/// Painting it avoids shipping a raster asset and keeps it crisp on 4K desktop
/// displays as well as tablet screens.
class WelcomeIllustration extends StatelessWidget {
const WelcomeIllustration({super.key, this.size = 240});
final double size;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CustomPaint(painter: _CartPainter()),
)
.animate(onPlay: (c) => c.repeat(reverse: true))
.moveY(begin: 0, end: -8, duration: 2400.ms, curve: Curves.easeInOut);
}
}
class _CartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final unit = w / 100;
// Soft backdrop disc.
canvas.drawCircle(
Offset(w * 0.5, h * 0.5),
w * 0.46,
Paint()..color = AppColors.primarySurface,
);
// Decorative arc.
canvas.drawArc(
Rect.fromCircle(center: Offset(w * 0.5, h * 0.5), radius: w * 0.46),
-1.1,
2.0,
false,
Paint()
..color = AppColors.primaryBorder
..style = PaintingStyle.stroke
..strokeWidth = unit * 1.6
..strokeCap = StrokeCap.round,
);
final stroke = Paint()
..color = AppColors.primary
..style = PaintingStyle.stroke
..strokeWidth = unit * 3
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final fill = Paint()..color = AppColors.primary.withValues(alpha: 0.16);
// Cart basket.
final basket = Path()
..moveTo(w * 0.30, h * 0.36)
..lineTo(w * 0.78, h * 0.36)
..lineTo(w * 0.70, h * 0.60)
..lineTo(w * 0.37, h * 0.60)
..close();
canvas.drawPath(basket, fill);
canvas.drawPath(basket, stroke);
// Handle running to the push bar.
canvas.drawPath(
Path()
..moveTo(w * 0.16, h * 0.26)
..lineTo(w * 0.24, h * 0.26)
..lineTo(w * 0.30, h * 0.36),
stroke,
);
// Basket ribs.
for (var i = 1; i <= 2; i++) {
final t = i / 3;
canvas.drawLine(
Offset(w * (0.30 + 0.48 * t), h * 0.36),
Offset(w * (0.37 + 0.33 * t), h * 0.60),
stroke..strokeWidth = unit * 1.6,
);
}
stroke.strokeWidth = unit * 3;
// Wheels.
for (final dx in [0.44, 0.66]) {
canvas.drawCircle(
Offset(w * dx, h * 0.70),
unit * 5,
Paint()..color = AppColors.surface,
);
canvas.drawCircle(Offset(w * dx, h * 0.70), unit * 5, stroke);
}
// Groceries poking out of the basket.
_item(canvas, Offset(w * 0.42, h * 0.30), unit * 5.5,
AppColors.tierGold.withValues(alpha: 0.9));
_item(canvas, Offset(w * 0.55, h * 0.27), unit * 6.5,
AppColors.success.withValues(alpha: 0.85));
_item(canvas, Offset(w * 0.67, h * 0.31), unit * 5,
AppColors.danger.withValues(alpha: 0.75));
// Receipt tape drifting away from the terminal.
final receipt = Path()
..moveTo(w * 0.80, h * 0.20)
..lineTo(w * 0.94, h * 0.20)
..lineTo(w * 0.94, h * 0.44)
..lineTo(w * 0.905, h * 0.40)
..lineTo(w * 0.87, h * 0.44)
..lineTo(w * 0.835, h * 0.40)
..lineTo(w * 0.80, h * 0.44)
..close();
canvas.drawPath(receipt, Paint()..color = AppColors.surface);
canvas.drawPath(
receipt,
Paint()
..color = AppColors.primaryLight
..style = PaintingStyle.stroke
..strokeWidth = unit * 1.4
..strokeJoin = StrokeJoin.round,
);
// Receipt lines.
final line = Paint()
..color = AppColors.primaryBorder
..strokeWidth = unit * 1.2
..strokeCap = StrokeCap.round;
for (var i = 0; i < 3; i++) {
final y = h * (0.25 + i * 0.05);
canvas.drawLine(Offset(w * 0.835, y), Offset(w * 0.905, y), line);
}
}
void _item(Canvas canvas, Offset center, double radius, Color color) {
canvas.drawCircle(center, radius, Paint()..color = color);
canvas.drawCircle(
center,
radius,
Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -7,8 +7,10 @@ import Foundation
import audioplayers_darwin import audioplayers_darwin
import printing import printing
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }

View File

@@ -232,6 +232,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router: go_router:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -376,6 +384,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.19.2"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -393,7 +409,7 @@ packages:
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -557,6 +573,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
url: "https://pub.dev"
source: hosted
version: "2.4.3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
url: "https://pub.dev"
source: hosted
version: "2.5.11"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
url: "https://pub.dev"
source: hosted
version: "2.4.3+1"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4
url: "https://pub.dev"
source: hosted
version: "3.5.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:

View File

@@ -27,6 +27,11 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
flutter_animate: ^4.5.0 flutter_animate: ^4.5.0
# Local persistence — SQLite is the source of truth on the terminal
sqflite: ^2.3.3
sqflite_common_ffi: ^2.3.3
path: ^1.9.0
# Peripherals: scanner beeps and thermal receipt printing # Peripherals: scanner beeps and thermal receipt printing
audioplayers: ^6.0.0 audioplayers: ^6.0.0
pdf: ^3.11.0 pdf: ^3.11.0

30
test/widget_test.dart Normal file
View File

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