import 'package:collection/collection.dart'; import 'package:intl/intl.dart'; import '../l10n/app_localizations.dart'; import '../models/bar_tab.dart'; import '../models/closed_tab.dart'; import '../models/closed_tab_item.dart'; import '../models/tab_item.dart'; /// Drinks rung up without an order (before orders were tracked) that follow /// each other this closely form one order, as on the server. const orderGap = Duration(minutes: 5); /// One drink, for grouping: [line] is what it is shown as, [lineKey] which /// drinks are counted on one row. class OrderDrink { final L line; final Object lineKey; final String id; final DateTime addedAt; final String? orderId; final bool isRemoved; const OrderDrink({ required this.line, required this.lineKey, required this.id, required this.addedAt, required this.orderId, required this.isRemoved, }); } /// The drinks of one line within an order. class TabOrderRow { final L line; /// Still on the tab (or paid for, in history). final int quantity; /// Taken off again. final int removed; const TabOrderRow({ required this.line, required this.quantity, required this.removed, }); } /// Drinks ordered together, shown under one "12:32 today" divider. class TabOrder { /// Null for drinks from before orders, grouped by time instead. final String? orderId; /// When its first drink was rung up. final DateTime startedAt; final List> rows; const TabOrder({ required this.orderId, required this.startedAt, required this.rows, }); int get itemCount => rows.fold(0, (total, row) => total + row.quantity); } /// Splits [drinks] into their orders, oldest first. Drinks rung up at the /// same moment keep the order they are given in. List> groupOrders(Iterable> drinks) { final sorted = drinks.toList(); mergeSort(sorted, compare: (a, b) => a.addedAt.compareTo(b.addedAt)); final orders = <_OrderBuilder>[]; final byId = >{}; _OrderBuilder? unordered; DateTime? unorderedLastAt; for (final drink in sorted) { final _OrderBuilder order; final orderId = drink.orderId; if (orderId != null) { order = byId.putIfAbsent(orderId, () { final created = _OrderBuilder(orderId, drink.addedAt); orders.add(created); return created; }); } else { if (unordered == null || drink.addedAt.difference(unorderedLastAt!) > orderGap) { unordered = _OrderBuilder(null, drink.addedAt); orders.add(unordered); } unorderedLastAt = drink.addedAt; order = unordered; } order.add(drink); } // Started in the order of their first drink: already oldest first return [for (final order in orders) order.build()]; } class _OrderBuilder { final String? orderId; final DateTime startedAt; final _rows = {}; _OrderBuilder(this.orderId, this.startedAt); void add(OrderDrink drink) { final row = _rows[drink.lineKey]; _rows[drink.lineKey] = ( line: row?.line ?? drink.line, quantity: (row?.quantity ?? 0) + (drink.isRemoved ? 0 : 1), removed: (row?.removed ?? 0) + (drink.isRemoved ? 1 : 0), ); } TabOrder build() => TabOrder( orderId: orderId, startedAt: startedAt, rows: [ for (final row in _rows.values) TabOrderRow( line: row.line, quantity: row.quantity, removed: row.removed, ), ], ); } /// The orders of an open tab. Without [includeRemoved] only what is still on /// it: rows and orders whose drinks were all taken off again are left out. /// Drinks of an item without purchase records count as ordered when the tab /// was opened. List> ordersOfTab(BarTab tab, {bool includeRemoved = false}) { final orders = groupOrders([ for (final item in tab.items) if (item.purchases.isEmpty) for (var index = 0; index < item.quantity; index++) OrderDrink( line: item, lineKey: item.id, id: '${item.id}-$index', addedAt: tab.openedAt, orderId: null, isRemoved: false, ) else for (final purchase in item.purchases) OrderDrink( line: item, lineKey: item.id, id: purchase.id, addedAt: purchase.purchasedAt, orderId: purchase.orderId, isRemoved: purchase.isRemoved, ), ]); if (includeRemoved) return orders; return [ for (final order in orders) if (order.itemCount > 0) TabOrder( orderId: order.orderId, startedAt: order.startedAt, rows: [ for (final row in order.rows) if (row.quantity > 0) row, ], ), ]; } /// The orders of a paid tab, with the drinks taken off before paying. List> ordersOfClosedTab(ClosedTab tab) { return groupOrders([ // The items come newest first; drinks of the same moment keep their // order, so start from the oldest for (final item in tab.items.reversed) for (var index = 0; index < item.quantity; index++) OrderDrink( line: item, lineKey: (item.productId, item.productName, item.unitPriceInCents), id: '${item.id}-$index', addedAt: item.purchasedAt ?? tab.closedAt, orderId: item.orderId, isRemoved: item.isRemoved, ), ]); } /// "12:32 today", "12:32 yesterday", or the date with the time. String orderTimeLabel( AppLocalizations l10n, String locale, DateTime startedAt, { DateTime? now, }) { final local = startedAt.toLocal(); final today = now ?? DateTime.now(); final day = DateTime(local.year, local.month, local.day); final days = DateTime( today.year, today.month, today.day, ).difference(day).inDays; final time = DateFormat.Hm(locale).format(local); return switch (days) { 0 => l10n.orderStartedToday(time), 1 => l10n.orderStartedYesterday(time), _ => l10n.orderStartedOn(DateFormat.yMMMd(locale).format(local), time), }; }