import 'package:uuid/uuid.dart'; import 'payment_method.dart'; /// Types of [TabEvent] the server understands. enum TabEventType { tabOpened('tab_opened'), tabRenamed('tab_renamed'), tabDeleted('tab_deleted'), lineAdded('line_added'), lineRemoved('line_removed'), settled('settled'), /// A stock count or tracking change made on the tablet. Not about a tab: /// it has no `tabId`. stockSet('stock_set'), /// A product created, changed or deleted (archived) on the tablet. Not /// about a tab either. productCreated('product_created'), productUpdated('product_updated'), productArchived('product_archived'); /// The product events, which the catalog replays instead of the tabs. static const products = {productCreated, productUpdated, productArchived}; final String value; const TabEventType(this.value); static TabEventType? fromValue(String value) { for (final type in TabEventType.values) { if (type.value == value) return type; } return null; } } /// Something that happened on a tab. Together the events form the operation /// log the server builds the venue's tabs from, and the tablet replays the /// ones the server hasn't applied yet on top of the server's tabs. [payload] /// is the JSON object that is stored in the outbox and sent as is. class TabEvent { final String id; final Map payload; const TabEvent({required this.id, required this.payload}); } /// The order a removal takes drinks off: `orderId`, or (when null) the /// drinks rung up before orders were tracked. typedef OrderRef = ({String? orderId}); /// One settled line in a `settled` event. typedef SettledLine = ({ String productId, String productName, int quantity, int unitPriceInCents, }); /// Builds the events, so their JSON shape lives in one place. class TabEventFactory { final _uuid = const Uuid(); final DateTime Function() _now; TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now; TabEvent tabOpened({ required String tabId, required String customerName, required DateTime openedAt, String? email, }) { return _event( TabEventType.tabOpened, tabId: tabId, customerName: customerName, fields: { 'openedAt': openedAt.toUtc().toIso8601String(), 'email': ?email, }, ); } /// [customerName] is the new name. TabEvent tabRenamed({required String tabId, required String customerName}) { return _event( TabEventType.tabRenamed, tabId: tabId, customerName: customerName, fields: const {}, ); } /// Written after a `line_removed` for every line still on the tab. TabEvent tabDeleted({required String tabId, required String customerName}) { return _event( TabEventType.tabDeleted, tabId: tabId, customerName: customerName, fields: const {}, ); } /// [quantity] is how many pieces were added; always positive. [lineId] is /// the local line, so the server can keep its id. [orderId] is the order /// they are part of. TabEvent lineAdded({ required String tabId, required String customerName, required String lineId, required String productId, required String productName, required int unitPriceInCents, required int quantity, required String? orderId, }) { return _lineEvent( TabEventType.lineAdded, tabId: tabId, customerName: customerName, lineId: lineId, productId: productId, productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, order: (orderId: orderId), ); } /// [quantity] is how many pieces were removed; always positive. With /// [order] they are taken off that order only; without, the latest drinks /// of the line (as before orders). TabEvent lineRemoved({ required String tabId, required String customerName, required String lineId, required String productId, required String productName, required int unitPriceInCents, required int quantity, OrderRef? order, }) { return _lineEvent( TabEventType.lineRemoved, tabId: tabId, customerName: customerName, lineId: lineId, productId: productId, productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, order: order, ); } TabEvent settled({ required String tabId, required String customerName, required int amountInCents, required PaymentMethod paymentMethod, required List items, String? commandId, }) { return _event( TabEventType.settled, tabId: tabId, customerName: customerName, fields: { 'amountInCents': amountInCents, 'paymentMethod': paymentMethodValue(paymentMethod), 'commandId': ?commandId, 'items': [ for (final item in items) { 'productId': item.productId, 'productName': item.productName, 'quantity': item.quantity, 'unitPriceInCents': item.unitPriceInCents, }, ], }, ); } /// The stock of [productId] was set to [quantity] on this tablet (a count, /// or a new product's starting stock); null stops tracking it. TabEvent stockSet({required String productId, required int? quantity}) { final id = _uuid.v4(); return TabEvent( id: id, payload: { 'id': id, 'type': TabEventType.stockSet.value, 'productId': productId, 'quantity': quantity, 'occurredAt': _now().toUtc().toIso8601String(), }, ); } /// A product created on this tablet: [fields] holds `name`, `category` /// (null: none), `priceInCents` and `lowStockThreshold`. The id is the /// tablet's, so the product can be sold before the server knows it. TabEvent productCreated({ required String productId, required Map fields, }) { return _productEvent(TabEventType.productCreated, productId, fields); } /// Only the [fields] that changed; `imageId` is a picture uploaded first. TabEvent productUpdated({ required String productId, required Map fields, }) { return _productEvent(TabEventType.productUpdated, productId, fields); } TabEvent productArchived({required String productId}) { return _productEvent(TabEventType.productArchived, productId, null); } TabEvent _productEvent( TabEventType type, String productId, Map? fields, ) { final id = _uuid.v4(); return TabEvent( id: id, payload: { 'id': id, 'type': type.value, 'productId': productId, 'product': ?fields, 'occurredAt': _now().toUtc().toIso8601String(), }, ); } /// The server's payment method names: `cash`, `payconiq`, /// `payment_pending` or `other`. static String paymentMethodValue(PaymentMethod method) { return switch (method) { PaymentMethod.cash => 'cash', PaymentMethod.payconiq => 'payconiq', PaymentMethod.paymentPending => 'payment_pending', }; } TabEvent _lineEvent( TabEventType type, { required String tabId, required String customerName, required String lineId, required String productId, required String productName, required int unitPriceInCents, required int quantity, required OrderRef? order, }) { if (quantity < 1) { throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1'); } return _event( type, tabId: tabId, customerName: customerName, fields: { 'lineId': lineId, // Lines taken over from a tablet's own tabs have no product: the // server then matches them by name and price, and refuses an empty id if (productId.isNotEmpty) 'productId': productId, 'productName': productName, 'unitPriceInCents': unitPriceInCents, 'quantity': quantity, // Also when null: a removal then only takes drinks without an order, // where a removal without `orderId` takes the latest of the line if (order != null) 'orderId': order.orderId, }, ); } TabEvent _event( TabEventType type, { required String tabId, required String customerName, required Map fields, }) { final id = _uuid.v4(); return TabEvent( id: id, payload: { 'id': id, 'type': type.value, 'tabId': tabId, 'customerName': customerName, 'occurredAt': _now().toUtc().toIso8601String(), ...fields, }, ); } }