import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:uuid/uuid.dart'; import '../models/app_error.dart'; import '../models/bar_tab.dart'; import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; import '../services/bar_tab_service.dart'; /// Tabs come from a database stream, so every change (local or remote) shows /// up without manual reloads. Actions report to Sentry and rethrow; the view /// decides how to tell the user. class BarScreenViewModel extends ChangeNotifier { /// Drinks rung up on the same tab within this time of the previous one are /// one order; a later drink starts a new order. static const orderTimeout = Duration(minutes: 5); final BarTabService barTabService; final DateTime Function() _now; final _uuid = const Uuid(); BarScreenViewModel({required this.barTabService, DateTime Function()? now}) : _now = now ?? DateTime.now; /// The order being rung up: selecting a tab (again) or a pause of /// [orderTimeout] starts a new one. ({String tabId, String orderId, DateTime lastAddedAt})? _order; StreamSubscription>? _subscription; List _tabs = []; String? _selectedTabId; bool _hasLoaded = false; AppError? _loadError; /// Tabs opened on this tablet that have no drinks yet. Only kept in memory: /// after a restart the list starts empty again. final Set _openedTabIds = {}; /// Every tab the venue knows, open or not. List get allTabs => _tabs; /// The tabs on the bar screen: opened here, with drinks on them, or /// selected. List get tabs => [ for (final tab in _tabs) if (_isOpen(tab)) tab, ]; /// The tabs that can be opened from the Open Tab dialog. List get closedTabs => [ for (final tab in _tabs) if (!_isOpen(tab)) tab, ]; bool _isOpen(BarTab tab) => tab.itemCount > 0 || _openedTabIds.contains(tab.id) || tab.id == _selectedTabId; String? get selectedTabId => _selectedTabId; bool get isLoading => !_hasLoaded; AppError? get loadError => _loadError; BarTab? get selectedTab => _tabs.firstWhereOrNull((tab) => tab.id == _selectedTabId); bool hasOpenTabNamed(String name, {String? exceptTabId}) { final normalized = name.trim().toLowerCase(); return _tabs.any( (tab) => tab.id != exceptTabId && tab.customerName.trim().toLowerCase() == normalized, ); } void ensureLoaded() { if (_subscription != null) return; _subscription = barTabService.watchOpenTabs().listen( _onTabs, onError: _onLoadError, ); } void _onTabs(List tabs) { // A tab whose drinks are all gone (settled elsewhere, or taken off) is // closed again; while it is selected it stays on screen final previousCounts = {for (final tab in _tabs) tab.id: tab.itemCount}; for (final tab in tabs) { if ((previousCounts[tab.id] ?? 0) > 0 && tab.itemCount == 0) { _openedTabIds.remove(tab.id); } } _tabs = tabs; _hasLoaded = true; _loadError = null; final ids = {for (final tab in tabs) tab.id}; _openedTabIds.retainAll(ids); if (!ids.contains(_selectedTabId)) _selectedTabId = null; notifyListeners(); } void _onLoadError(Object error, StackTrace stack) { debugPrint('BarScreenViewModel: load error: $error'); unawaited(Sentry.captureException(error, stackTrace: stack)); _hasLoaded = true; _loadError = AppError.loadBar; notifyListeners(); } void selectTab(String tabId) { _selectedTabId = tabId; _order = null; notifyListeners(); } /// Leaves no tab selected, so products can't be added by accident. void deselectTab() { _selectedTabId = null; _order = null; notifyListeners(); } /// The order a drink rung up on [tabId] now is part of. String _orderFor(String tabId) { final now = _now(); final order = _order; final orderId = order != null && order.tabId == tabId && now.difference(order.lastAddedAt) < orderTimeout ? order.orderId : _uuid.v4(); _order = (tabId: tabId, orderId: orderId, lastAddedAt: now); return orderId; } /// Puts [tabId] on the bar screen and selects it. void openTab(String tabId) { _openedTabIds.add(tabId); selectTab(tabId); } Future createTab(String customerName, {String? email}) { return _guard('createTab', () async { final tab = await barTabService.createTab( customerName: customerName, email: email, ); openTab(tab.id); }); } Future renameTab(String tabId, String customerName) { return _guard( 'renameTab', () => barTabService.renameTab(tabId: tabId, customerName: customerName), ); } Future deleteTab(String tabId) { return _guard('deleteTab', () => barTabService.deleteTab(tabId)); } Future addProductToSelectedTab(Product product) async { final tab = selectedTab; if (tab == null) return; await _guard( 'addProductToSelectedTab', () => barTabService.addProductToTab( tabId: tab.id, product: product, orderId: _orderFor(tab.id), ), ); } /// More drinks go into the current order; taking drinks off takes them off /// order [orderId], the one they are shown in. Future changeItemQuantity( TabItem item, int delta, String? orderId, ) async { if (delta == 0) return; await _guard( 'changeItemQuantity', () => barTabService.adjustTabItemQuantity( tabItemId: item.id, delta: delta, order: (orderId: delta > 0 ? _orderFor(item.tabId) : orderId), ), ); } Future closeSelectedTab({ PaymentMethod paymentMethod = PaymentMethod.cash, }) async { final tab = selectedTab; if (tab == null) return; await _guard( 'closeSelectedTab', () => barTabService.closeTab(tab.id, paymentMethod: paymentMethod), ); // A paid tab goes back to the Open Tab dialog _openedTabIds.remove(tab.id); deselectTab(); } Future _guard(String action, Future Function() body) async { try { return await body(); } catch (e, stack) { debugPrint('BarScreenViewModel: $action error: $e'); unawaited(Sentry.captureException(e, stackTrace: stack)); rethrow; } } @override void dispose() { _subscription?.cancel(); super.dispose(); } }