import '../utils/app_config.dart'; import 'server_tab.dart'; enum CommandType { closeTab('close_tab'); final String value; const CommandType(this.value); static CommandType? fromValue(String value) { for (final type in CommandType.values) { if (type.value == value) return type; } return null; } } enum CommandResult { completed, conflict, failed, expired } class RemoteCommand { final String id; final String rawType; final String tabId; final int expectedVersion; final String paymentMethod; final String requestedBy; final DateTime createdAt; const RemoteCommand({ required this.id, required this.rawType, required this.tabId, required this.expectedVersion, required this.paymentMethod, required this.requestedBy, required this.createdAt, }); factory RemoteCommand.fromJson(Map json) { return RemoteCommand( id: json['id'] as String, rawType: json['type'] as String, tabId: json['tabId'] as String, expectedVersion: json['expectedVersion'] as int, paymentMethod: json['paymentMethod'] as String? ?? 'cash', requestedBy: json['requestedBy'] as String? ?? '', createdAt: DateTime.parse(json['createdAt'] as String), ); } /// Null when the server sends a command this app version doesn't know. CommandType? get type => CommandType.fromValue(rawType); bool get isExpired { final cutoff = DateTime.now().subtract( const Duration(hours: kCommandExpiryHours), ); return createdAt.isBefore(cutoff); } } /// A tab event the server refused to store. Re-sending won't help. class RejectedEvent { final String? id; final String reason; const RejectedEvent({this.id, required this.reason}); factory RejectedEvent.fromJson(Map json) { return RejectedEvent( id: json['id'] as String?, reason: json['reason'] as String? ?? '', ); } } enum StockAdjustmentType { /// `stock += quantity`; quantity may be negative (breakage, own use). add('add'), /// `stock = quantity`, a stock count. Starts tracking an untracked product. set('set'); final String value; const StockAdjustmentType(this.value); static StockAdjustmentType? fromValue(String value) { for (final type in StockAdjustmentType.values) { if (type.value == value) return type; } return null; } } /// A stock change made on the dashboard. The server resends it on every sync /// until it has received the outcome, so it must be applied only once. class StockAdjustment { final String id; final String productId; final String rawType; final int quantity; final String? note; final String requestedBy; final DateTime createdAt; const StockAdjustment({ required this.id, required this.productId, required this.rawType, required this.quantity, this.note, required this.requestedBy, required this.createdAt, }); factory StockAdjustment.fromJson(Map json) { return StockAdjustment( id: json['id'] as String, productId: json['productId'] as String, rawType: json['type'] as String, quantity: json['quantity'] as int, note: json['note'] as String?, requestedBy: json['requestedBy'] as String? ?? '', createdAt: DateTime.parse(json['createdAt'] as String), ); } /// Null for a type this app version doesn't know; such an adjustment is /// neither applied nor reported. StockAdjustmentType? get type => StockAdjustmentType.fromValue(rawType); } /// The server's stock of one product, after applying the events it /// acknowledged. The server owns the stock once it sends these. class ServerStock { final String productId; /// Null when the stock of this product is not tracked. May be negative. final int? stock; /// When the stock was last counted (set). Line events that happened before /// it are already in the count and don't change the stock. final DateTime? countedAt; const ServerStock({required this.productId, this.stock, this.countedAt}); factory ServerStock.fromJson(Map json) { final countedAt = json['countedAt'] as String?; return ServerStock( productId: json['productId'] as String, stock: json['stock'] as int?, countedAt: countedAt == null ? null : DateTime.parse(countedAt), ); } } /// A product as the website manages it. class ServerProduct { final String id; final String name; final String? category; final int priceInCents; /// Null: no low stock warning. final int? lowStockThreshold; /// Deleted on the website: hidden, kept for open tabs and the history. final bool archived; const ServerProduct({ required this.id, required this.name, this.category, required this.priceInCents, this.lowStockThreshold, this.archived = false, }); factory ServerProduct.fromJson(Map json) { return ServerProduct( id: json['id'] as String, name: json['name'] as String, category: json['category'] as String?, priceInCents: json['priceInCents'] as int, lowStockThreshold: json['lowStockThreshold'] as int?, archived: json['archived'] as bool? ?? false, ); } } class SyncResponse { final List commands; final String? cursor; final List plannedTabNames; final List acknowledgedEventIds; final List rejectedEvents; /// Oldest first. final List stockAdjustments; final List acknowledgedStockAdjustmentResultIds; final List rejectedStockAdjustmentResults; /// The venue's open tabs after applying the acknowledged events: the /// source of truth. Null from a server that doesn't hold the tabs yet. final List? tabs; /// Settlements made anywhere in the venue since [settlementsCursor] was /// last sent, oldest first. final List settlements; final String? settlementsCursor; /// The stock of the venue's products: the source of truth. Null from a /// server that doesn't own the stock yet; the tablet then counts its own /// sales and applies [stockAdjustments] itself. final List? stock; /// Every product of the venue, managed on the website. Null from a server /// that doesn't manage the catalog; the tablet then manages its own. final List? catalog; const SyncResponse({ required this.commands, this.cursor, this.plannedTabNames = const [], this.acknowledgedEventIds = const [], this.rejectedEvents = const [], this.stockAdjustments = const [], this.acknowledgedStockAdjustmentResultIds = const [], this.rejectedStockAdjustmentResults = const [], this.tabs, this.settlements = const [], this.settlementsCursor, this.stock, this.catalog, }); factory SyncResponse.fromJson(Map json) { return SyncResponse( commands: [ for (final command in json['commands'] as List? ?? []) RemoteCommand.fromJson(command as Map), ], cursor: json['cursor'] as String?, plannedTabNames: [ for (final name in json['plannedTabNames'] as List? ?? []) name as String, ], acknowledgedEventIds: [ for (final id in json['acknowledgedEventIds'] as List? ?? []) id as String, ], rejectedEvents: [ for (final event in json['rejectedEvents'] as List? ?? []) RejectedEvent.fromJson(event as Map), ], stockAdjustments: [ for (final adjustment in json['stockAdjustments'] as List? ?? []) ?_parseStockAdjustment(adjustment), ], acknowledgedStockAdjustmentResultIds: [ for (final id in json['acknowledgedStockAdjustmentResultIds'] as List? ?? []) id as String, ], rejectedStockAdjustmentResults: [ for (final result in json['rejectedStockAdjustmentResults'] as List? ?? []) RejectedEvent.fromJson(result as Map), ], tabs: switch (json['tabs']) { final List tabs => [ for (final tab in tabs) ServerTab.fromJson(tab as Map), ], _ => null, }, settlements: [ for (final settlement in json['settlements'] as List? ?? []) ServerSettlement.fromJson(settlement as Map), ], settlementsCursor: json['settlementsCursor'] as String?, stock: switch (json['stock']) { final List stock => [ for (final entry in stock) ?_parseServerStock(entry), ], _ => null, }, catalog: switch (json['catalog']) { final List catalog => [ for (final product in catalog) ServerProduct.fromJson(product as Map), ], _ => null, }, ); } /// A malformed entry only leaves that product's local stock as it is. static ServerStock? _parseServerStock(Object? json) { try { return ServerStock.fromJson(json! as Map); } catch (_) { return null; } } /// A malformed adjustment is skipped instead of failing every sync from /// now on, since the server keeps sending it. static StockAdjustment? _parseStockAdjustment(Object? json) { try { return StockAdjustment.fromJson(json! as Map); } catch (_) { return null; } } } /// One line on a tab as shown in the dashboard, e.g. "10× Cola". class SnapshotTabItem { final String productName; final int quantity; final int unitPriceInCents; const SnapshotTabItem({ required this.productName, required this.quantity, required this.unitPriceInCents, }); Map toJson() => { 'productName': productName, 'quantity': quantity, 'unitPriceInCents': unitPriceInCents, }; } class SnapshotTab { final String id; final String customerName; final int version; final int totalInCents; final int itemCount; final List items; const SnapshotTab({ required this.id, required this.customerName, required this.version, required this.totalInCents, required this.itemCount, this.items = const [], }); Map toJson() => { 'id': id, 'customerName': customerName, 'version': version, 'totalInCents': totalInCents, 'itemCount': itemCount, 'items': items.map((item) => item.toJson()).toList(), }; } class SyncRequest { final String deviceId; final String? cursor; final List openTabs; /// Outbox tab events, oldest first, as their stored JSON objects. final List> events; /// The complete product catalog with stock: a product left out is removed /// from the dashboard. final List> products; /// Unacknowledged stock adjustment outcomes, oldest first. final List> stockAdjustmentResults; /// Where the settlement feed left off; null asks for the recent ones. final String? settlementsCursor; const SyncRequest({ required this.deviceId, this.cursor, required this.openTabs, this.events = const [], this.products = const [], this.stockAdjustmentResults = const [], this.settlementsCursor, }); Map toJson() => { 'deviceId': deviceId, if (cursor != null) 'cursor': cursor, 'openTabs': openTabs.map((tab) => tab.toJson()).toList(), 'events': events, 'products': products, 'stockAdjustmentResults': stockAdjustmentResults, if (settlementsCursor != null) 'settlementsCursor': settlementsCursor, }; } class CommandAck { final String commandId; final CommandResult status; final String? result; const CommandAck({ required this.commandId, required this.status, this.result, }); Map toJson() => { 'commandId': commandId, 'status': status.name, if (result != null) 'result': result, }; } enum RemoteSyncEventType { tabClosedRemotely, syncCompleted, syncFailed, conflict, unauthorized, } class RemoteSyncEvent { final RemoteSyncEventType type; final String? tabCustomerName; final String? requestedBy; final String? message; /// A failed sync that only lacked a connection: the bar keeps working and /// everything is sent once it is back. final bool offline; const RemoteSyncEvent({ required this.type, this.tabCustomerName, this.requestedBy, this.message, this.offline = false, }); }