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

@@ -35,14 +35,14 @@ final transactionRepositoryProvider = Provider<TransactionRepository>(
final remoteCatalogueProvider =
Provider<RemoteCatalogueSource>((ref) => RemoteCatalogueSource());
final remoteReportSinkProvider =
Provider<RemoteReportSink>((ref) => RemoteReportSink());
final remoteOrderSinkProvider =
Provider<RemoteOrderSink>((ref) => RemoteOrderSink());
final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider),
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 '../../presentation/auth/providers/auth_controller.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/pos/screens/pos_dashboard_screen.dart';
import '../../presentation/receipt/screens/receipt_screen.dart';
import '../../presentation/welcome/screens/welcome_screen.dart';
class AppRoutes {
const AppRoutes._();
static const String login = '/login';
static const String welcome = '/';
static const String registerCustomer = '/customer/new';
static const String existingCustomer = '/customer/find';
static const String pos = '/pos';
static const String payment = '/pos/payment';
static const String receipt = '/pos/receipt';
/// The terminal itself. Signing in lands here directly — customer capture
/// happens at checkout, not before the sale.
static const String pos = '/';
static const String payment = '/payment';
static const String receipt = '/receipt';
}
/// Router with an authentication guard.
@@ -48,7 +45,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final atLogin = state.matchedLocation == AppRoutes.login;
if (!signedIn) return atLogin ? null : AppRoutes.login;
if (atLogin) return AppRoutes.welcome;
if (atLogin) return AppRoutes.pos;
return null;
},
routes: [
@@ -57,48 +54,24 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'login',
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(
path: AppRoutes.pos,
name: 'pos',
pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()),
routes: [
GoRoute(
path: 'payment',
name: 'payment',
pageBuilder: (context, state) =>
_slide(state, const PaymentScreen()),
),
GoRoute(
path: 'receipt',
name: 'receipt',
pageBuilder: (context, state) => _fade(
state,
ReceiptScreen(transaction: state.extra! as SaleTransaction),
),
),
],
),
GoRoute(
path: AppRoutes.payment,
name: 'payment',
pageBuilder: (context, state) => _slide(state, const PaymentScreen()),
),
GoRoute(
path: AppRoutes.receipt,
name: 'receipt',
pageBuilder: (context, state) => _fade(
state,
ReceiptScreen(transaction: state.extra! as SaleTransaction),
),
),
],
errorBuilder: (context, state) => Scaffold(

View File

@@ -1,136 +1,172 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
import 'seed_data.dart';
import '../local/app_database.dart';
import '../local/catalogue_dao.dart';
import '../local/order_dao.dart';
/// On-terminal storage.
/// Terminal-side storage facade.
///
/// The terminal starts with an **empty catalogue**: nothing can be billed
/// until the cashier imports products. Everything after that — sales, parked
/// bills, queued events — lives here and survives without a connection.
///
/// Swapping this for Hive or SQLite changes nothing above the data layer.
/// SQLite is the source of truth. The catalogue is additionally held in memory
/// because barcode resolution happens on every scan and the product grid reads
/// it constantly — but every write goes to disk first, so nothing depends on
/// the process staying alive.
class LocalStore {
LocalStore._();
static final LocalStore instance = LocalStore._();
late CatalogueDao catalogue;
late OrderDao orders;
final Map<String, Product> _products = {};
final Map<String, Customer> _customers = {};
final List<SaleTransaction> _transactions = [];
final List<ParkedBill> _parked = [];
final List<SyncEvent> _events = [];
int _invoiceSequence = 0;
DateTime? _lastImportAt;
String? _catalogueRevision;
int _unsyncedOrders = 0;
/// Nothing to seed — the catalogue arrives via import.
Future<void> init() async {}
bool _ready = false;
/// Test helper. Clears everything and optionally loads the demo catalogue
/// so fixtures don't have to run an import first.
bool get isReady => _ready;
/// 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 {
_products.clear();
_customers.clear();
_transactions.clear();
_parked.clear();
_events.clear();
_invoiceSequence = 0;
_lastImportAt = null;
_catalogueRevision = null;
if (!_ready) await init(inMemory: true);
await AppDatabase.instance.clear();
if (withCatalogue) {
importCatalogue(
products: SeedData.products(),
customers: SeedData.customers(),
revision: 'seed',
at: DateTime.now(),
// Imported here rather than at the top of the file so the seed data is
// only pulled in by tests and the simulated remote source.
await catalogue.replaceCatalogue(
products: _seedProducts(),
customers: _seedCustomers(),
);
await catalogue.setMeta(
MetaKeys.lastImportAt,
'${DateTime.now().millisecondsSinceEpoch}',
);
await catalogue.setMeta(MetaKeys.catalogueRevision, 'seed');
}
await hydrate();
}
// -------------------------------------------------------------- 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;
DateTime? get lastImportAt => _lastImportAt;
String? get catalogueRevision => _catalogueRevision;
int get unsyncedOrders => _unsyncedOrders;
/// Replaces the catalogue wholesale.
///
/// 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({
Future<void> importCatalogue({
required List<Product> products,
required List<Customer> customers,
required String revision,
required DateTime at,
}) {
final priorStock = {
for (final p in _products.values) p.id: p.stock,
};
_products
..clear()
..addEntries(products.map((p) {
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;
}) async {
await catalogue.replaceCatalogue(products: products, customers: customers);
await catalogue.setMeta(
MetaKeys.lastImportAt,
'${at.millisecondsSinceEpoch}',
);
await catalogue.setMeta(MetaKeys.catalogueRevision, revision);
await hydrate();
}
// --------------------------------------------------------------- Products
List<Product> get products => _products.values.toList(growable: false);
void putProduct(Product p) => _products[p.id] = p;
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
List<Customer> get customers => _customers.values.toList(growable: false);
void putCustomer(Customer c) => _customers[c.id] = c;
Customer? customerById(String id) => _customers[id];
// ----------------------------------------------------------- Transactions
List<SaleTransaction> get transactions =>
List.unmodifiable(_transactions.reversed);
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;
Future<void> putCustomer(Customer c) async {
await catalogue.upsertCustomer(c);
_customers[c.id] = c;
}
bool get hasUnsyncedEvents =>
_events.any((e) => e.status != SyncStatus.synced);
// ----------------------------------------------------------------- Orders
/// 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/product.dart';
import 'local_store.dart';
import 'seed_data.dart';
/// 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
/// same, so only this class changes.
class RemoteCatalogueSource {
RemoteCatalogueSource();
RemoteCatalogueSource() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
}
/// Flipped from Settings to exercise the offline path.
bool simulateOffline = false;
@@ -75,24 +81,28 @@ class RemoteCatalogueSource {
}
}
/// Stands in for the back-office reporting API.
class RemoteReportSink {
RemoteReportSink();
/// Stands in for the back-office order intake API.
class RemoteOrderSink {
RemoteOrderSink();
bool simulateOffline = false;
/// Pushes one payload. Throws on failure so the caller can keep the event
/// queued rather than marking it sent.
Future<String> push(Map<String, Object?> payload) async {
await Future<void>.delayed(const Duration(milliseconds: 900));
/// Uploads a batch of orders and returns the ids the server accepted.
///
/// Throws on transport failure so the caller leaves every row at
/// `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) {
throw const CatalogueSyncException(
'Could not reach the reporting server. '
'The report is still saved on this terminal.',
'Could not reach the order server. Every bill is still stored on '
'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,
createdAt: DateTime.now(),
);
_store.putCustomer(created);
await _store.putCustomer(created);
return created;
}
@override
Future<Customer> update(Customer customer) async {
_store.putCustomer(customer);
await _store.putCustomer(customer);
return customer;
}
@@ -72,7 +72,7 @@ class CustomerRepositoryImpl implements CustomerRepository {
visitCount: current.visitCount + 1,
lastVisitAt: DateTime.now(),
);
_store.putCustomer(updated);
await _store.putCustomer(updated);
return updated;
}

View File

@@ -1,8 +1,9 @@
import '../../core/utils/extensions.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.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 {
ProductRepositoryImpl(this._store);
@@ -19,12 +20,8 @@ class ProductRepositoryImpl implements ProductRepository {
.toList();
@override
Future<Product?> findByBarcode(String barcode) async {
final needle = barcode.trim();
return _store.products.firstWhereOrNull(
(p) => p.barcode == needle && p.isActive,
);
}
Future<Product?> findByBarcode(String barcode) async =>
_store.productByBarcode(barcode);
@override
Future<Product?> findById(String id) async => _store.productById(id);
@@ -34,15 +31,13 @@ class ProductRepositoryImpl implements ProductRepository {
final q = query.trim();
if (q.isEmpty) return getAll();
final results = _store.products.where((p) => p.isActive && p.matches(q));
// 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()
final ranked = _store.products.where((p) => p.isActive && p.matches(q)).toList()
..sort((a, b) => _score(b, q).compareTo(_score(a, q)));
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) {
final lq = q.toLowerCase();
if (p.barcode == q) return 100;
@@ -54,15 +49,9 @@ class ProductRepositoryImpl implements ProductRepository {
}
@override
Future<void> decrementStock(Map<String, double> quantities) async {
quantities.forEach((id, qty) {
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));
});
}
Future<void> decrementStock(Map<String, double> quantities) =>
_store.applyStockMovement(quantities);
@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 '../../domain/entities/shift_report.dart';
import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
import '../../domain/repositories/sync_repository.dart';
import '../datasources/local_store.dart';
import '../datasources/remote_catalogue_source.dart';
import '../local/order_dao.dart';
class SyncRepositoryImpl implements SyncRepository {
SyncRepositoryImpl(this._store, this._catalogue, this._reports);
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
final LocalStore _store;
final RemoteCatalogueSource _catalogue;
final RemoteReportSink _reports;
final RemoteOrderSink _orderSink;
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
bool get hasCatalogue => _store.hasCatalogue;
@@ -26,134 +32,216 @@ class SyncRepositoryImpl implements SyncRepository {
String? get catalogueRevision => _store.catalogueRevision;
@override
List<SyncEvent> get events => _store.events;
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
@override
bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
void _log(SyncEvent e) => _events.add(e);
// ------------------------------------------------------- Morning: import
@override
Future<SyncEvent> importCatalogue({
void Function(double progress, String stage)? onProgress,
}) async {
final event = SyncEvent(
id: _uuid.v4(),
type: SyncEventType.catalogueImport,
status: SyncStatus.syncing,
createdAt: DateTime.now(),
summary: 'Catalogue import started',
);
_store.addEvent(event);
final id = _uuid.v4();
final started = DateTime.now();
try {
final snapshot = await _catalogue.fetch(onProgress: onProgress);
_store.importCatalogue(
await _store.importCatalogue(
products: snapshot.products,
customers: snapshot.customers,
revision: snapshot.revision,
at: snapshot.fetchedAt,
);
final done = event.copyWith(
final event = SyncEvent(
id: id,
type: SyncEventType.catalogueImport,
status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(),
attempts: 1,
);
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}',
summary: '${snapshot.products.length} products saved to SQLite '
'· ${snapshot.revision}',
payload: {
'products': snapshot.products.length,
'customers': snapshot.customers.length,
'revision': snapshot.revision,
},
syncedAt: done.syncedAt,
attempts: 1,
);
_store.updateEvent(settled);
return settled;
_log(event);
return event;
} catch (e) {
final failed = event.copyWith(
final event = SyncEvent(
id: id,
type: SyncEventType.catalogueImport,
status: SyncStatus.failed,
createdAt: started,
summary: 'Catalogue import failed',
error: e.toString(),
attempts: 1,
);
_store.updateEvent(failed);
return failed;
_log(event);
return event;
}
}
// --------------------------------------------------- Business hours: read
@override
ShiftReport buildShiftReport({
required DateTime businessDate,
Future<int> unsyncedCount() => _store.orders.unsyncedCount();
@override
Future<List<SaleTransaction>> unsyncedOrders() => _store.orders.unsynced();
@override
Future<ShiftReport> todayReport({
required String terminalId,
required String cashierName,
}) {
}) async {
final today = DateTime.now();
final orders = await _store.orders.forBusinessDate(today);
return ShiftReport.fromTransactions(
transactions: _store.transactions,
businessDate: businessDate,
transactions: orders,
businessDate: today,
terminalId: terminalId,
cashierName: cashierName,
);
}
@override
Future<SyncEvent> pushShiftReport(ShiftReport report) async {
// Queued first, so the data is durable before the network is touched.
final queued = SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.pending,
createdAt: DateTime.now(),
summary: '${report.billCount} bills · '
'${Formatters.money(report.grossSales)} · '
'${Formatters.date(report.businessDate)}',
payload: report.toPayload(),
);
_store.addEvent(queued);
return _attempt(queued);
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async {
final rows = await _store.orders.syncRows(limit: limit);
return rows
.map((r) => OrderSyncRow(
orderId: r['id']! as String,
invoiceNumber: r['invoice_number']! as String,
total: (r['total']! as num).toDouble(),
createdAt:
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
isSynced: (r['sync_status']! as int) == OrderDao.synced,
syncedAt: r['synced_at'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
attempts: (r['sync_attempts'] as int?) ?? 0,
error: r['sync_error'] as String?,
))
.toList();
}
// ------------------------------------------------------ End of day: sync
@override
Future<SyncEvent> retry(String eventId) async {
final matches = _store.events.where((e) => e.id == eventId).toList();
if (matches.isEmpty) {
throw StateError('No queued event with id $eventId');
}
final event = matches.first;
if (event.type == SyncEventType.catalogueImport) {
return importCatalogue();
}
return _attempt(event);
}
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
}) async {
onProgress?.call(0.05, 'Collecting unsynced bills…');
/// Sends one event, leaving it queued if the push fails.
Future<SyncEvent> _attempt(SyncEvent event) async {
_store.updateEvent(event.copyWith(status: SyncStatus.syncing));
final pending = await _store.orders.unsynced();
if (pending.isEmpty) {
onProgress?.call(1, 'Nothing to upload');
return const SyncOutcome(attempted: 0, uploaded: 0);
}
final started = DateTime.now();
final ids = pending.map((o) => o.id).toList();
onProgress?.call(0.35, 'Uploading ${pending.length} bills…');
try {
await _reports.push(event.payload);
final done = event.copyWith(
final accepted = await _orderSink.pushOrders(
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,
createdAt: started,
syncedAt: DateTime.now(),
attempts: event.attempts + 1,
clearError: true,
);
_store.updateEvent(done);
return done.copyWith();
summary: '${accepted.length} of ${pending.length} bills uploaded',
attempts: 1,
));
return SyncOutcome(attempted: pending.length, uploaded: accepted.length);
} 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,
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(),
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/repositories/transaction_repository.dart';
import '../datasources/local_store.dart';
/// All bill persistence goes straight to SQLite.
class TransactionRepositoryImpl implements TransactionRepository {
TransactionRepositoryImpl(this._store);
@@ -10,40 +10,33 @@ class TransactionRepositoryImpl implements TransactionRepository {
@override
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;
}
@override
Future<List<SaleTransaction>> history({int limit = 50}) async =>
_store.transactions.take(limit).toList();
Future<List<SaleTransaction>> history({int limit = 50}) =>
_store.orders.recent(limit: limit);
@override
Future<SaleTransaction?> findByInvoice(String invoiceNumber) async =>
_store.transactions
.firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
Future<SaleTransaction?> findByInvoice(String invoiceNumber) =>
_store.orders.byInvoice(invoiceNumber);
@override
Future<int> nextInvoiceSequence() async => _store.nextInvoiceSequence();
Future<int> nextInvoiceSequence() => _store.catalogue.nextInvoiceSequence();
@override
Future<void> park(ParkedBill bill) async => _store.addParked(bill);
Future<void> park(ParkedBill bill) => _store.orders.park(bill);
@override
Future<List<ParkedBill>> parkedBills() async => _store.parked;
Future<List<ParkedBill>> parkedBills() => _store.orders.parkedBills();
@override
Future<void> removeParked(String id) async => _store.removeParked(id);
Future<void> removeParked(String id) => _store.orders.removeParked(id);
@override
Future<double> salesTotalForDay(DateTime day) async {
return _store.transactions
.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;
}
Future<double> salesTotalForDay(DateTime day) =>
_store.orders.salesTotalForDay(day);
}

View File

@@ -1,38 +1,79 @@
import '../entities/shift_report.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 {
/// True once products have been pulled onto this terminal.
bool get hasCatalogue;
DateTime? get lastImportAt;
String? get catalogueRevision;
/// Pulls the catalogue and writes it locally.
///
/// Records a [SyncEventType.catalogueImport] event whether or not it
/// succeeds, so the log reflects every attempt.
/// Morning step — downloads products and writes them to SQLite.
Future<SyncEvent> importCatalogue({
void Function(double progress, String stage)? onProgress,
});
/// Builds the day's report from locally stored sales.
ShiftReport buildShiftReport({
required DateTime businessDate,
/// How many bills are still held locally.
Future<int> unsyncedCount();
Future<List<SaleTransaction>> unsyncedOrders();
/// Today's trading totals, read back from SQLite.
Future<ShiftReport> todayReport({
required String terminalId,
required String cashierName,
});
/// Queues the report and attempts to push it.
///
/// On failure the event is kept in [SyncStatus.failed] so nothing is lost.
Future<SyncEvent> pushShiftReport(ShiftReport report);
/// End-of-day step — uploads pending orders and flips the accepted ones to
/// `sync_status = 1`. Failures leave every row untouched at 0.
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
});
/// Retries a previously failed or pending push.
Future<SyncEvent> retry(String eventId);
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
List<SyncEvent> get events;
bool get hasUnsyncedEvents;
}

View File

@@ -12,6 +12,8 @@ Future<void> main() async {
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 SoundService.instance.preload();

View File

@@ -44,7 +44,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
password: _password.text,
);
if (ok && mounted) context.go(AppRoutes.welcome);
if (ok && mounted) context.go(AppRoutes.pos);
}
@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/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/sync_event.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../sync/providers/sync_controller.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 {
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
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 pushing = ref.watch(reportPushProvider);
final r = report.value;
return ModulePage(
children: [
@@ -35,112 +36,110 @@ class EventsView extends ConsumerWidget {
children: [
StatTile(
label: 'Bills Today',
value: '${report.billCount}',
value: '${r?.billCount ?? 0}',
icon: Icons.receipt_long_rounded,
caption: report.firstBillAt == null
caption: r?.firstBillAt == null
? 'no sales yet'
: '${Formatters.time(report.firstBillAt!)} '
'${Formatters.time(report.lastBillAt!)}',
: '${Formatters.time(r!.firstBillAt!)} '
'${Formatters.time(r.lastBillAt!)}',
),
StatTile(
label: 'Items Sold',
value: report.itemCount.toStringAsFixed(0),
value: (r?.itemCount ?? 0).toStringAsFixed(0),
icon: Icons.shopping_basket_rounded,
color: AppColors.info,
caption: 'units across all bills',
),
StatTile(
label: "Today's Sales",
value: Formatters.money(report.grossSales),
value: Formatters.money(r?.grossSales ?? 0),
icon: Icons.payments_rounded,
color: AppColors.success,
caption: 'gross takings',
),
StatTile(
label: 'Average Basket',
value: Formatters.money(report.averageBasket),
icon: Icons.trending_up_rounded,
color: AppColors.tierGold,
caption: 'per bill',
label: 'Awaiting Sync',
value: '$pending',
icon: Icons.cloud_off_rounded,
color: pending > 0 ? AppColors.warning : AppColors.success,
caption: pending > 0
? 'held on this terminal'
: 'everything uploaded',
),
],
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Shift report',
subtitle: '${Formatters.date(report.businessDate)} · '
'${report.cashierName} · ${report.terminalId}',
title: 'Upload bills to server',
subtitle: r == null
? 'Reading today\u2019s trading from SQLite\u2026'
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
'\u00b7 ${r.terminalId}',
action: TagChip(
report.isEmpty ? 'Nothing to send' : 'Ready to push',
color: report.isEmpty ? AppColors.textSecondary : AppColors.warning,
pending > 0 ? '$pending pending' : 'All synced',
color: pending > 0 ? AppColors.warning : AppColors.success,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_row('Bills', '${report.billCount}'),
_row('Items sold', report.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(report.grossSales)),
_row('Net of tax', Formatters.money(report.netOfTax)),
_row('GST collected', Formatters.money(report.taxCollected)),
_row('Discount given', Formatters.money(report.discountGiven)),
_row('Round off', Formatters.money(report.roundOff)),
_row('Points issued', '${report.loyaltyPointsIssued}'),
_row('Points redeemed', '${report.loyaltyPointsRedeemed}'),
if (report.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'By payment method',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
if (r != null && !r.isEmpty) ...[
_row('Bills', '${r.billCount}'),
_row('Items sold', r.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(r.grossSales)),
_row('GST collected', Formatters.money(r.taxCollected)),
_row('Discount given', Formatters.money(r.discountGiven)),
_row('Average basket', Formatters.money(r.averageBasket)),
if (r.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
for (final e in r.paymentBreakdown.entries)
ProgressRow(
label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value),
fraction:
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
color: _methodColor(e.key),
),
],
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncRunning) ...[
Text(
syncState.stage,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.sm),
for (final e in report.paymentBreakdown.entries)
ProgressRow(
label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value),
fraction: report.grossSales <= 0
? 0
: e.value / report.grossSales,
color: _methodColor(e.key),
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),
],
const SizedBox(height: AppSpacing.xl),
if (syncState is SyncFinished)
_outcomeBanner(syncState.outcome),
PrimaryButton(
label: 'Push report to server',
label: pending > 0
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
: 'Nothing to sync',
icon: Icons.cloud_upload_rounded,
large: true,
busy: pushing,
onPressed: report.isEmpty
busy: syncState is SyncRunning,
onPressed: pending == 0
? null
: () async {
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.',
),
));
},
: () => ref.read(orderSyncProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
const Row(
@@ -151,8 +150,9 @@ class EventsView extends ConsumerWidget {
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'A failed push never discards data. The report stays '
'queued below and can be retried at any time.',
'Bills are written to SQLite the moment a sale '
'completes. A failed upload changes nothing on disk — '
'every bill stays until the server confirms it.',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
@@ -168,64 +168,113 @@ class EventsView extends ConsumerWidget {
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Event log',
subtitle: '${events.length} recorded · '
'${events.where((e) => e.status != SyncStatus.synced).length} '
'outstanding',
child: events.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg),
child: Text(
'No sync activity yet. Importing the catalogue or pushing '
'a report will appear here.',
style: TextStyle(color: AppColors.textTertiary),
),
)
: ResponsiveTable(
columns: const [
TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true, priority: 1),
TableCol('Status', flex: 2, numeric: true),
],
rows: events
.map((e) => [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
e.type.isInbound
? Icons.cloud_download_rounded
: Icons.cloud_upload_rounded,
size: 15,
color: AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(child: Cell(e.type.label, bold: true)),
],
),
Cell(
e.error ?? e.summary,
color: e.error != null
? AppColors.danger
: AppColors.textSecondary,
),
Cell(Formatters.time(e.createdAt),
color: AppColors.textTertiary),
e.status == SyncStatus.failed
? _RetryButton(eventId: e.id)
: TagChip(
e.status.label,
color: statusColor(e.status),
),
])
.toList(),
),
title: 'Orders',
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
child: ResponsiveTable(
columns: const [
TableCol('Invoice', flex: 3),
TableCol('Time', flex: 2, priority: 1),
TableCol('Total', flex: 2, numeric: true),
TableCol('Sync', flex: 2, numeric: true),
],
rows: rows
.map((o) => [
Cell(o.invoiceNumber, bold: true, mono: true),
Cell(Formatters.time(o.createdAt),
color: AppColors.textTertiary),
Cell(Formatters.money(o.total), mono: true, bold: true),
TagChip(
o.isSynced ? 'Synced' : 'Pending',
color:
o.isSynced ? AppColors.success : AppColors.warning,
),
])
.toList(),
),
),
if (events.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Sync history',
subtitle: 'This session',
child: ResponsiveTable(
columns: const [
TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true),
],
rows: events
.map((e) => [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
e.type.isInbound
? Icons.cloud_download_rounded
: Icons.cloud_upload_rounded,
size: 15,
color: AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(child: Cell(e.type.label, bold: true)),
],
),
Cell(
e.error ?? e.summary,
color: e.error != null
? AppColors.danger
: AppColors.textSecondary,
),
Cell(Formatters.time(e.createdAt),
color: AppColors.textTertiary),
])
.toList(),
),
),
],
],
);
}
Widget _outcomeBanner(SyncOutcome outcome) {
final ok = outcome.isSuccess;
final uploaded = outcome.uploaded;
final attempted = outcome.attempted;
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
size: 18,
color: ok ? AppColors.success : AppColors.danger,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
ok
? '$uploaded of $attempted bills uploaded and marked synced.'
: '${outcome.error}',
style: TextStyle(
fontSize: 13,
height: 1.45,
color: ok ? AppColors.success : AppColors.danger,
),
),
),
],
),
);
}
static Color _methodColor(PaymentMethod m) => switch (m) {
PaymentMethod.cash => AppColors.success,
PaymentMethod.card => AppColors.info,
@@ -242,6 +291,7 @@ class EventsView extends ConsumerWidget {
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
@@ -254,34 +304,9 @@ class EventsView extends ConsumerWidget {
style: const TextStyle(
fontSize: 13.5,
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/utils/formatters.dart';
import '../../../domain/entities/store_account.dart';
import '../../../domain/entities/sync_event.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
@@ -196,10 +195,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
Widget _connectivityCard() {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
final outstanding = ref
.watch(syncEventsProvider)
.where((e) => e.status != SyncStatus.synced)
.length;
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
return PanelCard(
title: 'Connectivity & sync',
@@ -213,7 +209,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'Last import',
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
),
_row('Outstanding pushes', '$outstanding'),
_row('Unsynced bills', '$outstanding'),
_toggle(
'Simulate offline',
'Forces import and push to fail, so you can confirm nothing is '
@@ -222,7 +218,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
(v) {
setState(() => _offline = 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: [
_row('Application', '${AppConstants.appName} 1.0.0'),
_row('Terminal', 'TERM-01'),
_row('Data store', 'Local — offline first'),
_row('Data store', 'SQLite (on device)'),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
@@ -256,20 +252,24 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 148,
Flexible(
flex: 3,
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
Expanded(
const SizedBox(width: AppSpacing.md),
Flexible(
flex: 4,
child: Text(
value,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,

View File

@@ -8,6 +8,7 @@ import '../../../domain/entities/transaction.dart';
import '../../../domain/usecases/checkout_sale.dart';
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../sync/providers/sync_controller.dart';
/// UI state for the payment screen.
class PaymentState {
@@ -161,9 +162,11 @@ class PaymentController extends StateNotifier<PaymentState> {
unawaited(receipts.openCashDrawer());
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(visibleProductsProvider);
_ref.read(orderVersionProvider.notifier).state++;
return result;
} 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_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.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/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../../pos/providers/cart_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 {
const PaymentScreen({super.key});
/// Below this the two columns stack into one scrolling page.
static const double twoColumnAbove = 1080;
@override
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
}
@@ -27,8 +35,9 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
String _cashBuffer = '';
void _syncCash() {
final value = double.tryParse(_cashBuffer) ?? 0;
ref.read(paymentControllerProvider.notifier).setCashTendered(value);
ref
.read(paymentControllerProvider.notifier)
.setCashTendered(double.tryParse(_cashBuffer) ?? 0);
}
void _appendCash(String d) {
@@ -40,7 +49,8 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
void _backspaceCash() {
if (_cashBuffer.isEmpty) return;
setState(() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1));
setState(
() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1));
_syncCash();
}
@@ -53,7 +63,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
final result = await ref.read(paymentControllerProvider.notifier).confirm();
if (result == null || !mounted) return;
// Sale is banked — clear the terminal and show the receipt.
ref.read(cartControllerProvider.notifier).reset();
context.go(AppRoutes.receipt, extra: result.transaction);
}
@@ -71,221 +80,321 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.pop(),
tooltip: 'Back to bill',
),
),
body: Padding(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: context.isCompact
? SingleChildScrollView(
child: Column(children: [
_amountCard(controller, state),
const SizedBox(height: AppSpacing.lg),
_methodsCard(controller, state),
const SizedBox(height: AppSpacing.lg),
_tenderCard(controller, state),
]),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
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),
body: LayoutBuilder(
builder: (context, constraints) {
final twoColumn = constraints.maxWidth >= PaymentScreen.twoColumnAbove;
final pad =
constraints.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
final left = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_amountCard(controller, state, cart.grandTotal, cart.lineCount),
const SizedBox(height: AppSpacing.md),
_customerCard(),
const SizedBox(height: AppSpacing.md),
_methodsCard(controller, state),
],
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),
);
final right = _tenderCard(controller, state);
if (!twoColumn) {
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: Column(
children: [left, const SizedBox(height: AppSpacing.md), right],
),
);
}
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),
);
}
// ------------------------------------------------------------- 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),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_mini('Items', '${cart.lineCount}'),
_dot(),
_mini('Bill total', Formatters.money(cart.grandTotal)),
if (state.settled > 0) ...[
_dot(),
_mini('Settled', Formatters.money(state.settled)),
],
]),
]),
);
}
Widget _methodsCard(PaymentController controller, PaymentState state) {
// ------------------------------------------------------------ Amount due
Widget _amountCard(
PaymentController controller,
PaymentState state,
double total,
int lineCount,
) {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.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(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('Payment method', style: context.text.titleMedium),
const SizedBox(height: AppSpacing.lg),
Wrap(
spacing: AppSpacing.md,
runSpacing: AppSpacing.md,
children: PaymentMethod.values
.where((m) => m != PaymentMethod.loyalty)
.map((m) => _MethodTile(
method: m,
selected: state.activeMethod == m,
onTap: () {
setState(() => _cashBuffer = '');
controller.selectMethod(m);
},
))
.toList(),
const Text(
'Payment method',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final m in PaymentMethod.values)
if (m != PaymentMethod.loyalty)
_MethodTile(
method: m,
selected: state.activeMethod == m,
onTap: () {
setState(() => _cashBuffer = '');
controller.selectMethod(m);
},
),
],
),
if (state.splits.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xl),
const Divider(),
const SizedBox(height: AppSpacing.md),
Row(children: [
Text('Split tenders', style: context.text.titleSmall),
const Spacer(),
TextButton(
onPressed: controller.clearSplits,
style: TextButton.styleFrom(
foregroundColor: AppColors.danger),
child: const Text('Clear all'),
),
]),
const SizedBox(height: AppSpacing.sm),
...state.splits.asMap().entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(children: [
const Divider(height: AppSpacing.xxl),
Row(
children: [
const Expanded(
child: Text(
'Split tenders',
style:
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: controller.clearSplits,
style: TextButton.styleFrom(
foregroundColor: AppColors.danger,
minimumSize: const Size(0, 32),
),
child: const Text('Clear'),
),
],
),
for (final e in state.splits.asMap().entries)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
children: [
Text(e.value.method.emoji,
style: const TextStyle(fontSize: 16)),
style: const TextStyle(fontSize: 15)),
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),
style: AppTypography.money(14.5)),
style: AppTypography.money(13.5)),
IconButton(
onPressed: () => controller.removeSplit(e.key),
icon: const Icon(Icons.close_rounded, size: 17),
icon: const Icon(Icons.close_rounded, size: 16),
color: AppColors.textTertiary,
constraints:
const BoxConstraints(minWidth: 30, minHeight: 30),
padding: EdgeInsets.zero,
tooltip: 'Remove tender',
),
]),
)),
],
),
),
],
],
),
);
}
// ---------------------------------------------------------------- Tender
Widget _tenderCard(PaymentController controller, PaymentState state) {
final isCash = state.activeMethod.needsChange;
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.xl),
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: isCash
? _cashTender(controller, state)
child: state.activeMethod.needsChange
? _cashTender(controller)
: _referenceTender(controller, state),
);
}
Widget _cashTender(PaymentController controller, PaymentState state) {
final change = controller.changeDue;
Widget _cashTender(PaymentController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('Cash received', style: context.text.titleMedium),
const Text(
'Cash received',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.lg,
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
child: Row(children: [
const Text('',
style: TextStyle(fontSize: 24, color: AppColors.textTertiary)),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
_cashBuffer.isEmpty ? '0' : _cashBuffer,
style: AppTypography.money(30),
child: Row(
children: [
const Text('',
style:
TextStyle(fontSize: 22, color: AppColors.textTertiary)),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_cashBuffer.isEmpty ? '0' : _cashBuffer,
style: AppTypography.money(28),
),
),
),
),
]),
],
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
@@ -296,58 +405,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue),
),
...[50, 100, 200, 500, 2000].map(
(note) => ActionChip(
for (final note in const [50, 100, 200, 500, 2000])
ActionChip(
label: Text('$note'),
onPressed: () => _setCash(
(double.tryParse(_cashBuffer) ?? 0) + note,
),
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
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),
const SizedBox(height: AppSpacing.md),
_changeRow(controller.changeDue),
const SizedBox(height: AppSpacing.md),
Center(
child: NumericKeypad(
allowDecimal: true,
@@ -356,61 +424,100 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
onBackspace: _backspaceCash,
),
),
const SizedBox(height: AppSpacing.md),
OutlinedButton.icon(
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),
label: const Text('Add as split payment'),
),
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
],
);
}
Widget _changeRow(double change) {
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) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(children: [
Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 22)),
const SizedBox(width: AppSpacing.sm),
Text('${state.activeMethod.label} payment',
style: context.text.titleMedium),
]),
const SizedBox(height: AppSpacing.xxl),
Center(
child: Column(children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brXl,
Row(
children: [
Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'${state.activeMethod.label} payment',
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
alignment: Alignment.center,
child: Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 52)),
),
const SizedBox(height: AppSpacing.lg),
Text(
'Charge ${Formatters.money(controller.balanceDue)} '
'on the ${state.activeMethod.label.toLowerCase()} terminal',
textAlign: TextAlign.center,
style: context.text.bodyMedium,
),
]),
],
),
const SizedBox(height: AppSpacing.xxl),
Center(
child: Container(
width: 104,
height: 104,
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brXl,
),
alignment: Alignment.center,
child: Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 46)),
),
),
const SizedBox(height: AppSpacing.lg),
Text(
'Charge ${Formatters.money(controller.balanceDue)} on the '
'${state.activeMethod.label.toLowerCase()} terminal',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.xxl),
if (state.activeMethod.needsReference)
TextField(
@@ -425,37 +532,103 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
prefixIcon: const Icon(Icons.tag_rounded),
),
),
const SizedBox(height: AppSpacing.xxxl),
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'),
),
const SizedBox(height: AppSpacing.lg),
_splitButton(controller),
],
);
}
Widget _mini(String label, String value) => Column(children: [
Text(label,
Widget _splitButton(PaymentController controller, {double? amount}) {
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(
fontSize: 11,
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,
child: AnimatedContainer(
duration: AppMotion.fast,
width: 118,
width: 104,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.lg,
horizontal: AppSpacing.sm,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
borderRadius: AppRadius.brMd,
@@ -491,19 +664,24 @@ class _MethodTile extends StatelessWidget {
color: selected ? AppColors.primary : AppColors.border,
),
),
child: Column(children: [
Text(method.emoji, style: const TextStyle(fontSize: 24)),
const SizedBox(height: AppSpacing.sm),
Text(
method.label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColors.textPrimary,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(method.emoji, style: const TextStyle(fontSize: 22)),
const SizedBox(height: AppSpacing.xs),
Text(
method.label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
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_typography.dart';
import '../../../core/utils/formatters.dart';
import '../../../domain/entities/sync_event.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/widgets/sign_out_dialog.dart';
import '../providers/cart_controller.dart';
@@ -232,10 +231,7 @@ class _Section extends ConsumerWidget {
final active = ref.watch(activeModuleProvider);
final cartCount = ref.watch(cartItemCountProvider);
final ready = ref.watch(catalogueReadyProvider);
final outstanding = ref
.watch(syncEventsProvider)
.where((e) => e.status != SyncStatus.synced)
.length;
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -82,61 +82,104 @@ class _Header extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.md,
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.md,
),
child: Row(children: [
Text('Cart', style: context.text.headlineSmall),
const SizedBox(width: AppSpacing.sm),
if (cart.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
),
child: Row(
children: [
Flexible(
child: Text(
'${cart.lineCount}',
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w800,
fontSize: 13,
'Cart',
overflow: TextOverflow.ellipsis,
style: context.text.titleLarge,
),
),
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
),
child: Text(
'${cart.lineCount}',
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w700,
fontSize: 12.5,
),
),
),
),
const Spacer(),
if (controller.canUndo)
IconButton(
tooltip: 'Undo (F8)',
onPressed: controller.undo,
icon: const Icon(Icons.undo_rounded, size: 19),
color: AppColors.textSecondary,
),
if (cart.isNotEmpty) ...[
TextButton.icon(
onPressed: () async {
await controller.park();
ref.invalidate(parkedBillsProvider);
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(
onPressed: controller.clear,
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Clear'),
),
],
const Spacer(),
// Icon-only actions: labelled buttons overflowed the 380px panel.
if (controller.canUndo)
_IconAction(
icon: Icons.undo_rounded,
tooltip: 'Undo (F8)',
color: AppColors.textSecondary,
onTap: controller.undo,
),
if (cart.isNotEmpty) ...[
_IconAction(
icon: Icons.pause_circle_outline_rounded,
tooltip: 'Park bill',
color: AppColors.warning,
onTap: () async {
await controller.park();
ref.invalidate(parkedBillsProvider);
if (context.mounted) context.showSnack('Bill parked');
},
),
_IconAction(
icon: Icons.delete_outline_rounded,
tooltip: 'Clear bill',
color: AppColors.danger,
onTap: controller.clear,
),
],
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
tooltip: 'Close',
color: AppColors.textSecondary,
onTap: () => Navigator.of(context).pop(),
),
],
if (inSheet)
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
),
]),
),
);
}
}
/// 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(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [
Text(label,
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
)),
),
),
),
if (hint != null) ...[
const SizedBox(width: AppSpacing.xs),
Text('($hint)',
@@ -318,6 +366,7 @@ class _Row extends StatelessWidget {
color: AppColors.textTertiary,
)),
],
const SizedBox(width: AppSpacing.sm),
const Spacer(),
Text(
value,

View File

@@ -22,7 +22,11 @@ class CartFab extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Material(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width - AppSpacing.xxxl,
),
child: Material(
color: AppColors.primary,
borderRadius: AppRadius.brLg,
elevation: 0,
@@ -57,15 +61,18 @@ class CartFab extends ConsumerWidget {
),
),
const SizedBox(width: AppSpacing.md),
const Text(
'View bill',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
const Flexible(
child: Text(
'View bill',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: AppSpacing.xl),
const SizedBox(width: AppSpacing.lg),
Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(19, color: Colors.white),
@@ -77,6 +84,7 @@ class CartFab extends ConsumerWidget {
),
),
),
),
),
).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_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/status_pill.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
/// Strip above the product grid showing who the sale belongs to.
@@ -85,7 +83,7 @@ class CustomerBar extends ConsumerWidget {
),
const SizedBox(width: AppSpacing.sm),
OutlinedButton.icon(
onPressed: () => context.push(AppRoutes.existingCustomer),
onPressed: () => showCustomerCaptureSheet(context),
icon: const Icon(Icons.sync_alt_rounded, size: 17),
label: Text(customer == null ? 'Add Customer' : 'Change'),
style: OutlinedButton.styleFrom(

View File

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

View File

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

View File

@@ -88,10 +88,13 @@ class _ProductCardState extends State<ProductCard> {
),
),
const SizedBox(height: AppSpacing.xs + 2),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Scales down rather than overflowing on small tiles.
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
Formatters.money(p.price),
style: AppTypography.money(17,
@@ -112,8 +115,9 @@ class _ProductCardState extends State<ProductCard> {
),
),
),
],
],
],
),
),
const SizedBox(height: AppSpacing.xs),
Text(

View File

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

View File

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

View File

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