Files
kooltab/lib/viewmodels/bar_screen_view_model.dart
T

240 lines
6.5 KiB
Dart

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<List<BarTab>>? _subscription;
List<BarTab> _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<String> _openedTabIds = {};
/// Every tab the venue knows, open or not.
List<BarTab> get allTabs => _tabs;
/// The tabs on the bar screen: opened here, with drinks on them, or
/// selected.
List<BarTab> get tabs => [
for (final tab in _tabs)
if (_isOpen(tab)) tab,
];
/// The tabs that can be opened from the Open Tab dialog.
List<BarTab> 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<BarTab> 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<void> createTab(String customerName, {String? email}) {
return _guard('createTab', () async {
final tab = await barTabService.createTab(
customerName: customerName,
email: email,
);
openTab(tab.id);
});
}
Future<void> renameTab(String tabId, String customerName) {
return _guard(
'renameTab',
() => barTabService.renameTab(tabId: tabId, customerName: customerName),
);
}
Future<void> deleteTab(String tabId) {
return _guard('deleteTab', () => barTabService.deleteTab(tabId));
}
Future<void> 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<void> 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<void> 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<T> _guard<T>(String action, Future<T> 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();
}
}