diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1be6a22..e5380b6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ { context.read().ensureLoaded(); context.read().ensureLoaded(); + final syncService = context.read(); unawaited( DefaultExportService( barTabService: context.read(), + authToken: () => syncService.authToken, ).uploadToServer(), ); diff --git a/lib/app/app_lifecycle_lock.dart b/lib/app/app_lifecycle_lock.dart index 9be20e3..95aca7e 100644 --- a/lib/app/app_lifecycle_lock.dart +++ b/lib/app/app_lifecycle_lock.dart @@ -1,28 +1,44 @@ import 'package:flutter/material.dart'; +/// Locks once the app returns after being in the background for at least +/// [gracePeriod], so a quick trip to the image picker or installer doesn't. class AppLifecycleLockObserver extends WidgetsBindingObserver { final VoidCallback onLock; final VoidCallback? onBackground; final VoidCallback? onForeground; final bool Function()? shouldLock; + final Duration gracePeriod; + final DateTime Function() _clock; + + DateTime? _backgroundedAt; AppLifecycleLockObserver({ required this.onLock, this.onBackground, this.onForeground, this.shouldLock, - }); + this.gracePeriod = const Duration(seconds: 30), + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.inactive: + break; case AppLifecycleState.hidden: case AppLifecycleState.paused: case AppLifecycleState.detached: - if (shouldLock?.call() ?? true) onLock(); + if (_backgroundedAt != null) return; + _backgroundedAt = _clock(); onBackground?.call(); case AppLifecycleState.resumed: + final backgroundedAt = _backgroundedAt; + if (backgroundedAt == null) return; + _backgroundedAt = null; + + final away = _clock().difference(backgroundedAt); + if (away >= gracePeriod && (shouldLock?.call() ?? true)) onLock(); onForeground?.call(); } } diff --git a/lib/app/router.dart b/lib/app/router.dart index bd3b4f3..162975d 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -12,7 +12,6 @@ import '../views/bar/bar_screen_view.dart'; import '../views/dev_menu_view.dart'; import '../views/error_screen_view.dart'; import '../views/history_screen_view.dart'; -import '../views/import_names_view.dart'; import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; import '../views/product_list_view.dart'; @@ -35,24 +34,18 @@ String? _redirectApp({ } final location = state.uri.path; - final isConfigured = remoteSyncViewModel.isConfigured; + final returnTo = _returnLocation(state); - if (!isConfigured && location != '/register') { - return '/register'; + if (remoteSyncViewModel.isConfigured && location == '/register') { + return '/admin'; } - if (isConfigured && location == '/register') { - return '/bar'; - } - - if (!isConfigured) return null; - final goingToLock = location == '/lock'; final goingToAdminLock = location == '/admin/lock'; final mustBeOnLock = _mustBeOnLock(pinLockViewModel); - if (mustBeOnLock && !goingToLock) return '/lock'; - if (!mustBeOnLock && goingToLock) return '/bar'; + if (mustBeOnLock && !goingToLock) return _lockLocation('/lock', state); + if (!mustBeOnLock && goingToLock) return returnTo ?? '/bar'; final isAdminArea = _isAdminArea(location); final isDebugShortcutArea = _isDebugShortcutArea(location); @@ -61,14 +54,34 @@ String? _redirectApp({ !isDebugShortcutArea && !goingToAdminLock && !adminPinViewModel.isUnlocked) { - return '/admin/lock'; + return _lockLocation('/admin/lock', state); } - if (goingToAdminLock && adminPinViewModel.isUnlocked) return '/admin'; + if (goingToAdminLock && adminPinViewModel.isUnlocked) { + return returnTo ?? '/admin'; + } return null; } +/// Sends the user back where they were headed once the PIN is entered. +String _lockLocation(String lockPath, GoRouterState state) { + return Uri( + path: lockPath, + queryParameters: {'from': state.uri.toString()}, + ).toString(); +} + +String? _returnLocation(GoRouterState state) { + final from = state.uri.queryParameters['from']; + if (from == null || !from.startsWith('/')) return null; + + final path = Uri.parse(from).path; + if (path == '/lock' || path == '/admin/lock') return null; + + return from; +} + bool _mustBeOnLock(PinLockViewModel pinLockViewModel) { final needsPinSetup = !pinLockViewModel.isPinSet && pinLockViewModel.pinRequired; @@ -83,6 +96,9 @@ bool _mustBeOnLock(PinLockViewModel pinLockViewModel) { bool _isAdminArea(String location) { return location == '/admin' || location.startsWith('/admin/') || + location == '/settings' || + location == '/dev' || + location == '/register' || location == '/products' || location.startsWith('/products/') || location == '/history' || @@ -135,9 +151,7 @@ GoRouter createAppRouter({ mode: pinLockViewModel.isPinSet ? PinEntryMode.unlock : PinEntryMode.create, - onSuccess: () { - context.go('/bar'); - }, + onSuccess: () => context.go(_returnLocation(state) ?? '/bar'), ), ), @@ -153,19 +167,13 @@ GoRouter createAppRouter({ onVerify: adminPinViewModel.verify, onSet: adminPinViewModel.setPin, error: () => adminPinViewModel.error, - onSuccess: () => context.go('/admin'), + onSuccess: () => context.go(_returnLocation(state) ?? '/admin'), ), ), GoRoute( path: '/admin', builder: (context, state) => const AdminScreenView(), - routes: [ - GoRoute( - path: 'import', - builder: (context, state) => const ImportNamesView(), - ), - ], ), GoRoute( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 18b0021..4ae5aad 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -282,5 +282,37 @@ "@lastSync": {"placeholders": {"time": {"type": "String"}}}, "notSyncedYet": "Not synced yet", "tabClosedRemotely": "Tab {customer} closed remotely by {requester}", - "@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}} + "@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}, + "actionCannotBeUndone": "This cannot be undone.", + "disconnectFromServer": "Disconnect from server", + "disconnectFromServerDescription": "This unlinks the device from the remote server. The bar keeps working, but open tabs will no longer be synced. Continue?", + "disconnect": "Disconnect", + "remoteServer": "Remote server", + "connected": "Connected", + "deviceLinked": "Device linked to remote server", + "notConnected": "Not connected", + "linkToDashboard": "Link to remote dashboard", + "deviceAccessRevoked": "The server no longer accepts this device. Link it again in Admin.", + "duplicateTabName": "Name already in use", + "duplicateTabNameDescription": "A tab named {name} is already open. Use this name anyway?", + "@duplicateTabNameDescription": {"placeholders": {"name": {"type": "String"}}}, + "deleteTabWithItemsDescription": "This tab still has drinks on it. Deleting it returns them to stock and leaves nothing in history. Enter the admin PIN to continue.", + "connectingToServer": "Connecting to server…", + "failedToConnect": "Failed to connect", + "unknownError": "Unknown error", + "connectToRemoteServer": "Connect to remote server", + "selectVenueToLink": "Select your venue to link this device", + "selectVenue": "Select venue", + "refreshVenues": "Refresh venues", + "enterDeviceDetails": "Enter device details to complete pairing", + "deviceName": "Device name", + "deviceNameRequired": "Device name is required", + "registrationCode": "Registration code", + "enterRegistrationCode": "Enter the 6-character code", + "back": "Back", + "register": "Register", + "registeringDevice": "Registering device…", + "deviceRegistered": "Device registered!", + "openTabsWillSync": "Open tabs will sync to the dashboard.", + "done": "Done" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index affe4a3..c2cdd24 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1669,6 +1669,192 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Tab {customer} closed remotely by {requester}'** String tabClosedRemotely(String customer, String requester); + + /// No description provided for @actionCannotBeUndone. + /// + /// In en, this message translates to: + /// **'This cannot be undone.'** + String get actionCannotBeUndone; + + /// No description provided for @disconnectFromServer. + /// + /// In en, this message translates to: + /// **'Disconnect from server'** + String get disconnectFromServer; + + /// No description provided for @disconnectFromServerDescription. + /// + /// In en, this message translates to: + /// **'This unlinks the device from the remote server. The bar keeps working, but open tabs will no longer be synced. Continue?'** + String get disconnectFromServerDescription; + + /// No description provided for @disconnect. + /// + /// In en, this message translates to: + /// **'Disconnect'** + String get disconnect; + + /// No description provided for @remoteServer. + /// + /// In en, this message translates to: + /// **'Remote server'** + String get remoteServer; + + /// No description provided for @connected. + /// + /// In en, this message translates to: + /// **'Connected'** + String get connected; + + /// No description provided for @deviceLinked. + /// + /// In en, this message translates to: + /// **'Device linked to remote server'** + String get deviceLinked; + + /// No description provided for @notConnected. + /// + /// In en, this message translates to: + /// **'Not connected'** + String get notConnected; + + /// No description provided for @linkToDashboard. + /// + /// In en, this message translates to: + /// **'Link to remote dashboard'** + String get linkToDashboard; + + /// No description provided for @deviceAccessRevoked. + /// + /// In en, this message translates to: + /// **'The server no longer accepts this device. Link it again in Admin.'** + String get deviceAccessRevoked; + + /// No description provided for @duplicateTabName. + /// + /// In en, this message translates to: + /// **'Name already in use'** + String get duplicateTabName; + + /// No description provided for @duplicateTabNameDescription. + /// + /// In en, this message translates to: + /// **'A tab named {name} is already open. Use this name anyway?'** + String duplicateTabNameDescription(String name); + + /// No description provided for @deleteTabWithItemsDescription. + /// + /// In en, this message translates to: + /// **'This tab still has drinks on it. Deleting it returns them to stock and leaves nothing in history. Enter the admin PIN to continue.'** + String get deleteTabWithItemsDescription; + + /// No description provided for @connectingToServer. + /// + /// In en, this message translates to: + /// **'Connecting to server…'** + String get connectingToServer; + + /// No description provided for @failedToConnect. + /// + /// In en, this message translates to: + /// **'Failed to connect'** + String get failedToConnect; + + /// No description provided for @unknownError. + /// + /// In en, this message translates to: + /// **'Unknown error'** + String get unknownError; + + /// No description provided for @connectToRemoteServer. + /// + /// In en, this message translates to: + /// **'Connect to remote server'** + String get connectToRemoteServer; + + /// No description provided for @selectVenueToLink. + /// + /// In en, this message translates to: + /// **'Select your venue to link this device'** + String get selectVenueToLink; + + /// No description provided for @selectVenue. + /// + /// In en, this message translates to: + /// **'Select venue'** + String get selectVenue; + + /// No description provided for @refreshVenues. + /// + /// In en, this message translates to: + /// **'Refresh venues'** + String get refreshVenues; + + /// No description provided for @enterDeviceDetails. + /// + /// In en, this message translates to: + /// **'Enter device details to complete pairing'** + String get enterDeviceDetails; + + /// No description provided for @deviceName. + /// + /// In en, this message translates to: + /// **'Device name'** + String get deviceName; + + /// No description provided for @deviceNameRequired. + /// + /// In en, this message translates to: + /// **'Device name is required'** + String get deviceNameRequired; + + /// No description provided for @registrationCode. + /// + /// In en, this message translates to: + /// **'Registration code'** + String get registrationCode; + + /// No description provided for @enterRegistrationCode. + /// + /// In en, this message translates to: + /// **'Enter the 6-character code'** + String get enterRegistrationCode; + + /// No description provided for @back. + /// + /// In en, this message translates to: + /// **'Back'** + String get back; + + /// No description provided for @register. + /// + /// In en, this message translates to: + /// **'Register'** + String get register; + + /// No description provided for @registeringDevice. + /// + /// In en, this message translates to: + /// **'Registering device…'** + String get registeringDevice; + + /// No description provided for @deviceRegistered. + /// + /// In en, this message translates to: + /// **'Device registered!'** + String get deviceRegistered; + + /// No description provided for @openTabsWillSync. + /// + /// In en, this message translates to: + /// **'Open tabs will sync to the dashboard.'** + String get openTabsWillSync; + + /// No description provided for @done. + /// + /// In en, this message translates to: + /// **'Done'** + String get done; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ff6547f..47a400a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -864,4 +864,102 @@ class AppLocalizationsEn extends AppLocalizations { String tabClosedRemotely(String customer, String requester) { return 'Tab $customer closed remotely by $requester'; } + + @override + String get actionCannotBeUndone => 'This cannot be undone.'; + + @override + String get disconnectFromServer => 'Disconnect from server'; + + @override + String get disconnectFromServerDescription => + 'This unlinks the device from the remote server. The bar keeps working, but open tabs will no longer be synced. Continue?'; + + @override + String get disconnect => 'Disconnect'; + + @override + String get remoteServer => 'Remote server'; + + @override + String get connected => 'Connected'; + + @override + String get deviceLinked => 'Device linked to remote server'; + + @override + String get notConnected => 'Not connected'; + + @override + String get linkToDashboard => 'Link to remote dashboard'; + + @override + String get deviceAccessRevoked => + 'The server no longer accepts this device. Link it again in Admin.'; + + @override + String get duplicateTabName => 'Name already in use'; + + @override + String duplicateTabNameDescription(String name) { + return 'A tab named $name is already open. Use this name anyway?'; + } + + @override + String get deleteTabWithItemsDescription => + 'This tab still has drinks on it. Deleting it returns them to stock and leaves nothing in history. Enter the admin PIN to continue.'; + + @override + String get connectingToServer => 'Connecting to server…'; + + @override + String get failedToConnect => 'Failed to connect'; + + @override + String get unknownError => 'Unknown error'; + + @override + String get connectToRemoteServer => 'Connect to remote server'; + + @override + String get selectVenueToLink => 'Select your venue to link this device'; + + @override + String get selectVenue => 'Select venue'; + + @override + String get refreshVenues => 'Refresh venues'; + + @override + String get enterDeviceDetails => 'Enter device details to complete pairing'; + + @override + String get deviceName => 'Device name'; + + @override + String get deviceNameRequired => 'Device name is required'; + + @override + String get registrationCode => 'Registration code'; + + @override + String get enterRegistrationCode => 'Enter the 6-character code'; + + @override + String get back => 'Back'; + + @override + String get register => 'Register'; + + @override + String get registeringDevice => 'Registering device…'; + + @override + String get deviceRegistered => 'Device registered!'; + + @override + String get openTabsWillSync => 'Open tabs will sync to the dashboard.'; + + @override + String get done => 'Done'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 8a85153..f5f7cd2 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -876,4 +876,103 @@ class AppLocalizationsNl extends AppLocalizations { String tabClosedRemotely(String customer, String requester) { return 'Poef $customer extern gesloten door $requester'; } + + @override + String get actionCannotBeUndone => 'Dit kan niet ongedaan gemaakt worden.'; + + @override + String get disconnectFromServer => 'Loskoppelen van server'; + + @override + String get disconnectFromServerDescription => + 'Dit koppelt het toestel los van de server. De bar blijft werken, maar open poefs worden niet meer gesynchroniseerd. Doorgaan?'; + + @override + String get disconnect => 'Loskoppelen'; + + @override + String get remoteServer => 'Server'; + + @override + String get connected => 'Verbonden'; + + @override + String get deviceLinked => 'Toestel gekoppeld aan de server'; + + @override + String get notConnected => 'Niet verbonden'; + + @override + String get linkToDashboard => 'Koppelen aan het dashboard'; + + @override + String get deviceAccessRevoked => + 'De server aanvaardt dit toestel niet meer. Koppel het opnieuw via Beheer.'; + + @override + String get duplicateTabName => 'Naam al in gebruik'; + + @override + String duplicateTabNameDescription(String name) { + return 'Er is al een poef open met de naam $name. Toch deze naam gebruiken?'; + } + + @override + String get deleteTabWithItemsDescription => + 'Op deze poef staan nog drankjes. Verwijderen zet ze terug in de voorraad en laat niets na in de geschiedenis. Geef de beheer-PIN in om door te gaan.'; + + @override + String get connectingToServer => 'Verbinden met server…'; + + @override + String get failedToConnect => 'Verbinden mislukt'; + + @override + String get unknownError => 'Onbekende fout'; + + @override + String get connectToRemoteServer => 'Verbinden met server'; + + @override + String get selectVenueToLink => 'Kies je locatie om dit toestel te koppelen'; + + @override + String get selectVenue => 'Kies locatie'; + + @override + String get refreshVenues => 'Locaties vernieuwen'; + + @override + String get enterDeviceDetails => 'Vul de toestelgegevens in om te koppelen'; + + @override + String get deviceName => 'Toestelnaam'; + + @override + String get deviceNameRequired => 'Toestelnaam is verplicht'; + + @override + String get registrationCode => 'Registratiecode'; + + @override + String get enterRegistrationCode => 'Geef de code van 6 tekens in'; + + @override + String get back => 'Terug'; + + @override + String get register => 'Registreren'; + + @override + String get registeringDevice => 'Toestel registreren…'; + + @override + String get deviceRegistered => 'Toestel geregistreerd!'; + + @override + String get openTabsWillSync => + 'Open poefs worden gesynchroniseerd met het dashboard.'; + + @override + String get done => 'Klaar'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 23d1440..3714e2f 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -282,5 +282,36 @@ "@lastSync": {"placeholders": {"time": {"type": "String"}}}, "notSyncedYet": "Nog niet gesynchroniseerd", "tabClosedRemotely": "Poef {customer} extern gesloten door {requester}", - "@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}} + "@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}, + "actionCannotBeUndone": "Dit kan niet ongedaan gemaakt worden.", + "disconnectFromServer": "Loskoppelen van server", + "disconnectFromServerDescription": "Dit koppelt het toestel los van de server. De bar blijft werken, maar open poefs worden niet meer gesynchroniseerd. Doorgaan?", + "disconnect": "Loskoppelen", + "remoteServer": "Server", + "connected": "Verbonden", + "deviceLinked": "Toestel gekoppeld aan de server", + "notConnected": "Niet verbonden", + "linkToDashboard": "Koppelen aan het dashboard", + "deviceAccessRevoked": "De server aanvaardt dit toestel niet meer. Koppel het opnieuw via Beheer.", + "duplicateTabName": "Naam al in gebruik", + "duplicateTabNameDescription": "Er is al een poef open met de naam {name}. Toch deze naam gebruiken?", + "deleteTabWithItemsDescription": "Op deze poef staan nog drankjes. Verwijderen zet ze terug in de voorraad en laat niets na in de geschiedenis. Geef de beheer-PIN in om door te gaan.", + "connectingToServer": "Verbinden met server…", + "failedToConnect": "Verbinden mislukt", + "unknownError": "Onbekende fout", + "connectToRemoteServer": "Verbinden met server", + "selectVenueToLink": "Kies je locatie om dit toestel te koppelen", + "selectVenue": "Kies locatie", + "refreshVenues": "Locaties vernieuwen", + "enterDeviceDetails": "Vul de toestelgegevens in om te koppelen", + "deviceName": "Toestelnaam", + "deviceNameRequired": "Toestelnaam is verplicht", + "registrationCode": "Registratiecode", + "enterRegistrationCode": "Geef de code van 6 tekens in", + "back": "Terug", + "register": "Registreren", + "registeringDevice": "Toestel registreren…", + "deviceRegistered": "Toestel geregistreerd!", + "openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.", + "done": "Klaar" } diff --git a/lib/main.dart b/lib/main.dart index f535855..3c7f673 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -63,7 +63,7 @@ Future _runApp() async { final productService = DriftProductService(database: database); final barTabService = DriftBarTabService(database: database); final settingsService = DriftSettingsService(database: database); - final pinLockService = PinLockService(); + final pinLockService = PinLockService(maxLockout: const Duration(minutes: 1)); final apiClient = KoolTapApiClient(); final remoteSyncService = RemoteSyncService( diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index 01a8bec..aa5732f 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -19,22 +19,34 @@ abstract class ExportService { /// failed backup is logged but never interrupts the bar. class DefaultExportService implements ExportService { final BarTabService barTabService; + final Future Function()? authToken; final http.Client _client; - DefaultExportService({required this.barTabService, http.Client? client}) - : _client = client ?? http.Client(); + DefaultExportService({ + required this.barTabService, + this.authToken, + http.Client? client, + }) : _client = client ?? http.Client(); + + static const _timeout = Duration(seconds: 30); @override Future uploadToServer() async { try { final closedTabs = await barTabService.getClosedTabs(); final appVersion = (await PackageInfo.fromPlatform()).version; + final token = await authToken?.call(); - final response = await _client.post( - Uri.parse(kExportServerUrl), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)), - ); + final response = await _client + .post( + Uri.parse(kExportServerUrl), + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)), + ) + .timeout(_timeout); if (response.statusCode != 200 && response.statusCode != 201) { unawaited( diff --git a/lib/services/pin_lock_service.dart b/lib/services/pin_lock_service.dart index 6678d1a..4af8814 100644 --- a/lib/services/pin_lock_service.dart +++ b/lib/services/pin_lock_service.dart @@ -20,6 +20,7 @@ class PinLockService { this._saltKey = _defaultSaltKey, this._hashKey = _defaultHashKey, DateTime Function()? clock, + this.maxLockout = const Duration(minutes: 15), }) : _storage = storage ?? const FlutterSecureStorage(), _clock = clock ?? DateTime.now; @@ -31,13 +32,15 @@ class PinLockService { static const _freeAttempts = 5; static const _baseLockout = Duration(seconds: 30); - static const _maxLockout = Duration(minutes: 15); final FlutterSecureStorage _storage; final String _saltKey; final String _hashKey; final DateTime Function() _clock; + /// Kept short for the bar PIN so a bartender is never locked out for long. + final Duration maxLockout; + String get _failuresKey => '${_hashKey}_failures'; String get _lockedUntilKey => '${_hashKey}_locked_until'; @@ -113,7 +116,7 @@ class PinLockService { final exponent = min(failures - _freeAttempts, 10); final lockout = _baseLockout * (1 << exponent); - final capped = lockout > _maxLockout ? _maxLockout : lockout; + final capped = lockout > maxLockout ? maxLockout : lockout; await _storage.write( key: _lockedUntilKey, diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 23e128f..6beb0da 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -130,12 +130,13 @@ class DriftProductService implements ProductService { ); } + /// Only hidden, since open tabs and history still refer to it. @override Future deleteProduct(String id) async { - final query = database.delete(database.products) + final query = database.update(database.products) ..where((product) => product.id.equals(id)); - await query.go(); + await query.write(const ProductsCompanion(active: Value(false))); } @override diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 8192bee..3218748 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:io'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; import '../database/app_database.dart'; @@ -65,20 +67,29 @@ class RemoteSyncService { DateTime? _firstPendingChange; bool _syncQueued = false; + _Credentials? _cachedCredentials; + Future get venueId => secureStorage.read(key: _venueIdKey); - Future get isConfigured async { - final token = await secureStorage.read(key: _authTokenKey); - final venueId = await this.venueId; - return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false); - } + Future get isConfigured async => await _credentials() != null; + + Future get authToken async => (await _credentials())?.token; Future<_Credentials?> _credentials() async { + if (_cachedCredentials != null) return _cachedCredentials; + final deviceId = await secureStorage.read(key: _deviceIdKey); final token = await secureStorage.read(key: _authTokenKey); - if (deviceId == null || token == null) return null; + final venueId = await this.venueId; + if (deviceId == null || + token == null || + token.isEmpty || + venueId == null || + venueId.isEmpty) { + return null; + } - return (deviceId: deviceId, token: token); + return _cachedCredentials = (deviceId: deviceId, token: token); } void startPeriodicSync() { @@ -109,8 +120,6 @@ class RemoteSyncService { void resume() { _paused = false; - // Catch up right away instead of waiting for the next poll - _onTabsChanged(); } /// Pushes tab changes to the server soon after they happen instead of @@ -164,7 +173,7 @@ class RemoteSyncService { } final credentials = await _credentials(); - if (credentials == null || !await isConfigured) return; + if (credentials == null) return; _isSyncing = true; Duration nextDelay; @@ -176,6 +185,7 @@ class RemoteSyncService { _emit(RemoteSyncEventType.syncCompleted); } on UnauthorizedException { debugPrint('RemoteSyncService: token revoked'); + _cachedCredentials = null; await secureStorage.delete(key: _authTokenKey); stopPeriodicSync(); _emit(RemoteSyncEventType.unauthorized); @@ -183,7 +193,9 @@ class RemoteSyncService { } on RateLimitedException catch (e) { nextDelay = await _recordFailure(e, retryAfter: e.retryAfter); } catch (e, stack) { - unawaited(Sentry.captureException(e, stackTrace: stack)); + if (!_isConnectivityError(e)) { + unawaited(Sentry.captureException(e, stackTrace: stack)); + } nextDelay = await _recordFailure(e); } finally { _isSyncing = false; @@ -197,6 +209,13 @@ class RemoteSyncService { } } + /// Offline tablets are expected, not worth an error report. + bool _isConnectivityError(Object error) { + return error is IOException || + error is TimeoutException || + error is http.ClientException; + } + Future _recordFailure(Object error, {Duration? retryAfter}) async { debugPrint('RemoteSyncService: sync failed: $error'); _consecutiveFailures++; @@ -391,9 +410,11 @@ class RemoteSyncService { await secureStorage.write(key: _deviceIdKey, value: deviceId); await secureStorage.write(key: _authTokenKey, value: token); await secureStorage.write(key: _venueIdKey, value: venueId); + _cachedCredentials = (deviceId: deviceId, token: token); } Future clearCredentials() async { + _cachedCredentials = null; await secureStorage.delete(key: _deviceIdKey); await secureStorage.delete(key: _authTokenKey); await secureStorage.delete(key: _venueIdKey); diff --git a/lib/utils/app_update_util.dart b/lib/utils/app_update_util.dart index 8198d2c..9f4a7db 100644 --- a/lib/utils/app_update_util.dart +++ b/lib/utils/app_update_util.dart @@ -65,9 +65,7 @@ class AppUpdateUtil { final url = Uri.parse('$serverUrl/api/update?version=$version'); - final response = await http.get(url); - - debugPrint('Response from update server: ${response.body}'); + final response = await http.get(url).timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { throw Exception('Update server unavailable'); diff --git a/lib/utils/money.dart b/lib/utils/money.dart index 5c98cbe..3b2879f 100644 --- a/lib/utils/money.dart +++ b/lib/utils/money.dart @@ -2,6 +2,19 @@ import 'package:intl/intl.dart'; final _euro = NumberFormat.currency(locale: 'nl_BE', symbol: '€'); +final _euroInput = RegExp(r'^(\d{1,6})(?:[.,](\d{1,2}))?$'); + extension Cents on int { String get asEuro => _euro.format(this / 100); } + +/// Parses a typed price like `2,50` or `3` without going through doubles. +/// Returns null for anything else, including more than two decimals. +int? parseEuroToCents(String input) { + final match = _euroInput.firstMatch(input.trim()); + if (match == null) return null; + + final euros = int.parse(match.group(1)!); + final cents = int.parse((match.group(2) ?? '0').padRight(2, '0')); + return euros * 100 + cents; +} diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index 29f259c..3e0447c 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -37,6 +37,15 @@ class BarScreenViewModel extends ChangeNotifier { 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; diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart index 480ca38..9e83d43 100644 --- a/lib/viewmodels/remote_sync_view_model.dart +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -24,6 +24,11 @@ class RemoteSyncViewModel extends ChangeNotifier { bool _isConfigured = false; bool get isConfigured => _isConfigured; + bool _wasRevoked = false; + + /// The server rejected this device's token; it needs to be linked again. + bool get wasRevoked => _wasRevoked; + DateTime? _lastSyncAt; DateTime? get lastSyncAt => _lastSyncAt; @@ -54,6 +59,7 @@ class RemoteSyncViewModel extends ChangeNotifier { /// Called once registration stored new credentials. Future activate() async { await _refreshStatus(); + if (_isConfigured) _wasRevoked = false; notifyListeners(); if (!_isConfigured) return; @@ -90,6 +96,7 @@ class RemoteSyncViewModel extends ChangeNotifier { syncService.stopPeriodicSync(); await syncService.clearCredentials(); _isConfigured = false; + _wasRevoked = false; _venueId = null; _lastSyncAt = null; _lastError = null; @@ -105,8 +112,8 @@ class RemoteSyncViewModel extends ChangeNotifier { case RemoteSyncEventType.syncFailed: _lastError = event.message; case RemoteSyncEventType.unauthorized: - // The router sends the device back to registration. _isConfigured = false; + _wasRevoked = true; case RemoteSyncEventType.tabClosedRemotely: case RemoteSyncEventType.conflict: break; diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index ec2a748..fba4764 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -33,11 +33,8 @@ class AdminScreenView extends StatelessWidget { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Disconnect from server'), - content: const Text( - 'This will unlink the device from the remote server. ' - 'Open tabs will no longer be synced. Continue?', - ), + title: Text(l10n.disconnectFromServer), + content: Text(l10n.disconnectFromServerDescription), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -46,7 +43,7 @@ class AdminScreenView extends StatelessWidget { FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom(backgroundColor: Colors.red), - child: const Text('Disconnect'), + child: Text(l10n.disconnect), ), ], ), @@ -109,35 +106,33 @@ class AdminScreenView extends StatelessWidget { subtitle: l10n.closedTabs, onTap: () => context.push('/history'), ), - _AdminTile( - icon: Icons.file_upload_outlined, - title: l10n.importData, - subtitle: l10n.importNamesDescription, - onTap: () => context.push('/admin/import'), - ), ], ), const SizedBox(height: 8), _AdminSection( - title: 'Remote Server', + title: l10n.remoteServer, children: [ if (remoteSync.isConfigured) ...[ _AdminTile( icon: Icons.cloud_done_outlined, - title: 'Connected', - subtitle: 'Device linked to remote server', + title: l10n.connected, + subtitle: l10n.deviceLinked, trailing: TextButton( onPressed: () => _confirmUnlink(context), style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Disconnect'), + child: Text(l10n.disconnect), ), onTap: null, ), ] else _AdminTile( - icon: Icons.cloud_outlined, - title: 'Not connected', - subtitle: 'Link to remote dashboard', + icon: remoteSync.wasRevoked + ? Icons.cloud_off_rounded + : Icons.cloud_outlined, + title: l10n.notConnected, + subtitle: remoteSync.wasRevoked + ? l10n.deviceAccessRevoked + : l10n.linkToDashboard, onTap: () => context.push('/register'), ), ], diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index 2989e92..d7db75d 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -144,6 +144,15 @@ class _BarScreenViewState extends State { location: '/history', ), ], + if (context.watch().wasRevoked) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.deviceAccessRevoked, + color: Theme.of(context).colorScheme.error, + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.cloud_off_rounded), + ), + ], const SizedBox(width: 6), IconButton( tooltip: l10n.refresh, diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart index 84745bc..74e510c 100644 --- a/lib/views/dev_menu_view.dart +++ b/lib/views/dev_menu_view.dart @@ -45,6 +45,36 @@ class _DevMenuViewState extends State { ).showSnackBar(SnackBar(content: Text(l10n.devAction(message)))); } + Future _confirmThen( + String title, + Future Function() action, + ) async { + final l10n = AppLocalizations.of(context); + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(l10n.actionCannotBeUndone), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + ), + child: Text(l10n.confirm), + ), + ], + ), + ); + + if (confirmed == true) await action(); + } + @override void dispose() { _vm?.removeListener(_onVmChange); @@ -78,12 +108,19 @@ class _DevMenuViewState extends State { _Tile( icon: Icons.delete_sweep_rounded, title: l10n.clearOpenTabs, - onTap: vm.isLoading ? null : () => vm.clearOpenTabs(), + onTap: vm.isLoading + ? null + : () => _confirmThen(l10n.clearOpenTabs, vm.clearOpenTabs), ), _Tile( icon: Icons.history_rounded, title: l10n.clearClosedHistory, - onTap: vm.isLoading ? null : () => vm.clearClosedTabHistory(), + onTap: vm.isLoading + ? null + : () => _confirmThen( + l10n.clearClosedHistory, + vm.clearClosedTabHistory, + ), ), _Tile( icon: Icons.add_box_rounded, @@ -95,18 +132,33 @@ class _DevMenuViewState extends State { icon: Icons.restart_alt_rounded, title: l10n.clearAndReseedProducts, subtitle: l10n.wipesAndReseeds, - onTap: vm.isLoading ? null : () => vm.clearAndReseedProducts(), + onTap: vm.isLoading + ? null + : () => _confirmThen( + l10n.clearAndReseedProducts, + vm.clearAndReseedProducts, + ), ), _Tile( icon: Icons.delete_forever_rounded, title: l10n.clearAllProducts, - onTap: vm.isLoading ? null : () => vm.clearAllProducts(), + onTap: vm.isLoading + ? null + : () => _confirmThen( + l10n.clearAllProducts, + vm.clearAllProducts, + ), ), _Tile( icon: Icons.image_rounded, title: l10n.clearProductImages, subtitle: l10n.deletesProductImages, - onTap: vm.isLoading ? null : () => vm.clearProductImages(), + onTap: vm.isLoading + ? null + : () => _confirmThen( + l10n.clearProductImages, + vm.clearProductImages, + ), ), ], ), @@ -117,7 +169,9 @@ class _DevMenuViewState extends State { icon: Icons.lock_reset_rounded, title: l10n.resetPin, subtitle: l10n.removePinLock, - onTap: vm.isLoading ? null : () => vm.resetPin(), + onTap: vm.isLoading + ? null + : () => _confirmThen(l10n.resetPin, vm.resetPin), ), _Tile( icon: Icons.bug_report_rounded, diff --git a/lib/views/dialogs/duplicate_tab_name_dialog.dart b/lib/views/dialogs/duplicate_tab_name_dialog.dart new file mode 100644 index 0000000..e93a989 --- /dev/null +++ b/lib/views/dialogs/duplicate_tab_name_dialog.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; + +Future confirmDuplicateTabName(BuildContext context, String name) async { + final l10n = AppLocalizations.of(context); + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.duplicateTabName), + content: Text(l10n.duplicateTabNameDescription(name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.confirm), + ), + ], + ), + ); + + return confirmed == true; +} diff --git a/lib/views/dialogs/edit_tab_dialog.dart b/lib/views/dialogs/edit_tab_dialog.dart index 122c6f3..b1e2a9f 100644 --- a/lib/views/dialogs/edit_tab_dialog.dart +++ b/lib/views/dialogs/edit_tab_dialog.dart @@ -2,8 +2,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; +import '../../l10n/app_localizations_helpers.dart'; +import '../../models/app_error.dart'; import '../../models/bar_tab.dart'; +import '../../services/pin_lock_service.dart'; +import '../../viewmodels/admin_pin_view_model.dart'; import '../../viewmodels/bar_screen_view_model.dart'; +import 'duplicate_tab_name_dialog.dart'; Future showEditTabDialog(BuildContext context, BarTab tab) async { final l10n = AppLocalizations.of(context); @@ -13,10 +18,19 @@ Future showEditTabDialog(BuildContext context, BarTab tab) async { builder: (_) => _EditTabDialog(initialName: tab.customerName), ); - if (name == null || name.trim().isEmpty || !context.mounted) return; + final trimmed = name?.trim() ?? ''; + if (trimmed.isEmpty || trimmed == tab.customerName || !context.mounted) { + return; + } + + final viewModel = context.read(); + if (viewModel.hasOpenTabNamed(trimmed, exceptTabId: tab.id) && + !await confirmDuplicateTabName(context, trimmed)) { + return; + } try { - await context.read().renameTab(tab.id, name.trim()); + await viewModel.renameTab(tab.id, trimmed); } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( @@ -78,30 +92,37 @@ class _EditTabDialogState extends State<_EditTabDialog> { } } +/// Deleting a tab with drinks on it leaves no trace in history, so it needs +/// the admin PIN when one is set. Future confirmDeleteTab(BuildContext context, BarTab tab) async { final l10n = AppLocalizations.of(context); + final adminPinViewModel = context.read(); + final needsAdminPin = tab.items.isNotEmpty && adminPinViewModel.isPinSet; - final confirmed = await showDialog( + final result = await showDialog<_DeleteTabResult>( context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.deleteTab), - content: Text(l10n.deleteTabDescription), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text(l10n.delete), - ), - ], - ); - }, + builder: (_) => _DeleteTabDialog( + description: tab.items.isEmpty + ? l10n.deleteTabDescription + : l10n.deleteTabWithItemsDescription, + needsAdminPin: needsAdminPin, + ), ); - if (confirmed != true || !context.mounted) return; + if (result == null || !context.mounted) return; + + if (needsAdminPin) { + final error = await _verifyAdminPin(adminPinViewModel, result.pin); + if (error != null) { + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.errorText(error)))); + return; + } + } + + if (!context.mounted) return; try { await context.read().deleteTab(tab.id); @@ -112,3 +133,94 @@ Future confirmDeleteTab(BuildContext context, BarTab tab) async { ).showSnackBar(SnackBar(content: Text(l10n.couldNotDeleteTab))); } } + +Future _verifyAdminPin( + AdminPinViewModel adminPinViewModel, + String pin, +) async { + try { + final matches = await adminPinViewModel.adminPinService.verifyPin(pin); + return matches ? null : AppError.incorrectPin; + } on PinLockedOutException { + return AppError.tooManyPinAttempts; + } catch (_) { + return AppError.verifyPin; + } +} + +class _DeleteTabResult { + final String pin; + + const _DeleteTabResult(this.pin); +} + +class _DeleteTabDialog extends StatefulWidget { + final String description; + final bool needsAdminPin; + + const _DeleteTabDialog({ + required this.description, + required this.needsAdminPin, + }); + + @override + State<_DeleteTabDialog> createState() => _DeleteTabDialogState(); +} + +class _DeleteTabDialogState extends State<_DeleteTabDialog> { + final _pinController = TextEditingController(); + + @override + void dispose() { + _pinController.dispose(); + super.dispose(); + } + + void _confirm() { + Navigator.of(context).pop(_DeleteTabResult(_pinController.text)); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return AlertDialog( + title: Text(l10n.deleteTab), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.description), + if (widget.needsAdminPin) ...[ + const SizedBox(height: 16), + TextField( + controller: _pinController, + autofocus: true, + obscureText: true, + keyboardType: TextInputType.number, + maxLength: 4, + decoration: InputDecoration(labelText: l10n.adminPin), + onSubmitted: (_) => _confirm(), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _confirm, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(l10n.delete), + ), + ], + ); + } +} diff --git a/lib/views/dialogs/new_tab_dialog.dart b/lib/views/dialogs/new_tab_dialog.dart index 2f3ff64..3ae8eab 100644 --- a/lib/views/dialogs/new_tab_dialog.dart +++ b/lib/views/dialogs/new_tab_dialog.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../../viewmodels/bar_screen_view_model.dart'; +import 'duplicate_tab_name_dialog.dart'; /// Returns the name of the opened tab, or null when nothing was opened. Future showNewTabDialog(BuildContext context) async { @@ -42,8 +43,14 @@ Future showNewTabDialog(BuildContext context) async { final trimmed = name?.trim() ?? ''; if (trimmed.isEmpty || !context.mounted) return null; + final viewModel = context.read(); + if (viewModel.hasOpenTabNamed(trimmed) && + !await confirmDuplicateTabName(context, trimmed)) { + return null; + } + try { - await context.read().createTab(trimmed); + await viewModel.createTab(trimmed); return trimmed; } catch (_) { if (context.mounted) { diff --git a/lib/views/import_names_view.dart b/lib/views/import_names_view.dart deleted file mode 100644 index 829699c..0000000 --- a/lib/views/import_names_view.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; -import '../utils/navigation.dart'; - -class ImportNamesView extends StatelessWidget { - const ImportNamesView({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final scheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () => context.popOrGo('/admin'), - icon: const Icon(Icons.arrow_back), - ), - title: Text(l10n.importData), - ), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Padding( - padding: const EdgeInsets.all(24), - child: Card( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.file_upload_outlined, - size: 48, - color: scheme.primary, - ), - const SizedBox(height: 20), - Text( - l10n.importNames, - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - l10n.importPlaceholder, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart index a7eb8b4..b067427 100644 --- a/lib/views/product_form_view.dart +++ b/lib/views/product_form_view.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; import '../models/product.dart'; +import '../utils/money.dart'; import '../utils/navigation.dart'; import '../viewmodels/product_list_view_model.dart'; @@ -148,24 +149,10 @@ class _ProductFormViewState extends State { }); } - int _parsePriceToCents(String value) { - final normalized = value.replaceAll(',', '.'); - final euros = double.tryParse(normalized) ?? 0; - - return (euros * 100).round(); - } - Future _save() async { final l10n = AppLocalizations.of(context); if (!_formKey.currentState!.validate()) return; - if (_imagePath == null || _imagePath!.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.chooseImage))); - return; - } - if (_selectedCategory == null) { ScaffoldMessenger.of( context, @@ -179,7 +166,7 @@ class _ProductFormViewState extends State { final name = _nameController.text.trim(); final category = _selectedCategory!; - final priceInCents = _parsePriceToCents(_priceController.text); + final priceInCents = parseEuroToCents(_priceController.text)!; final stockQuantity = int.tryParse(_stockController.text) ?? 0; final lowStockThreshold = int.tryParse(_lowStockController.text) ?? 0; @@ -422,10 +409,7 @@ class _ProductFormViewState extends State { return l10n.enterPrice; } - final normalized = value.replaceAll(',', '.'); - final price = double.tryParse(normalized); - - if (price == null || price < 0) { + if (parseEuroToCents(value) == null) { return l10n.enterValidPrice; } diff --git a/lib/views/registration_view.dart b/lib/views/registration_view.dart index ef2b731..dd0f1ee 100644 --- a/lib/views/registration_view.dart +++ b/lib/views/registration_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; +import '../utils/navigation.dart'; import '../viewmodels/registration_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; @@ -25,6 +26,8 @@ class _RegistrationViewState extends State { }); } + AppLocalizations get _l10n => AppLocalizations.of(context); + @override void dispose() { _deviceNameController.dispose(); @@ -39,6 +42,14 @@ class _RegistrationViewState extends State { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + leading: IconButton( + tooltip: l10n.cancel, + onPressed: () => context.popOrGo('/admin'), + icon: const Icon(Icons.close_rounded), + ), + ), body: SafeArea( child: Center( child: SingleChildScrollView( @@ -87,7 +98,7 @@ class _RegistrationViewState extends State { const CircularProgressIndicator(strokeWidth: 3), const SizedBox(height: 16), Text( - 'Connecting to server…', + _l10n.connectingToServer, style: Theme.of(context).textTheme.titleMedium, ), ], @@ -106,12 +117,12 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 24), Text( - 'Failed to connect', + _l10n.failedToConnect, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Text( - vm.errorMessage ?? 'Unknown error', + vm.errorMessage ?? _l10n.unknownError, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of( @@ -123,7 +134,7 @@ class _RegistrationViewState extends State { FilledButton.icon( onPressed: () => vm.retry(), icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(_l10n.retry), ), ], ); @@ -142,13 +153,13 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 16), Text( - 'Connect to remote server', + _l10n.connectToRemoteServer, textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( - 'Select your venue to link this device', + _l10n.selectVenueToLink, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of( @@ -158,7 +169,7 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 32), Text( - 'Select venue', + _l10n.selectVenue, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: Theme.of( context, @@ -217,7 +228,7 @@ class _RegistrationViewState extends State { const SizedBox(height: 24), TextButton( onPressed: () => vm.retry(), - child: const Text('Refresh venues'), + child: Text(_l10n.refreshVenues), ), ], ); @@ -244,7 +255,7 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 8), Text( - 'Enter device details to complete pairing', + _l10n.enterDeviceDetails, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of( @@ -255,20 +266,19 @@ class _RegistrationViewState extends State { const SizedBox(height: 32), TextFormField( controller: _deviceNameController, - decoration: const InputDecoration( - labelText: 'Device name', + decoration: InputDecoration( + labelText: _l10n.deviceName, hintText: 'e.g. Bar terminal 1', border: OutlineInputBorder(), ), - validator: (v) => v == null || v.trim().isEmpty - ? 'Device name is required' - : null, + validator: (v) => + v == null || v.trim().isEmpty ? _l10n.deviceNameRequired : null, ), const SizedBox(height: 16), TextFormField( controller: _regCodeController, - decoration: const InputDecoration( - labelText: 'Registration code', + decoration: InputDecoration( + labelText: _l10n.registrationCode, hintText: '6-character code from dashboard', border: OutlineInputBorder(), counterText: '', @@ -276,7 +286,7 @@ class _RegistrationViewState extends State { maxLength: 6, textCapitalization: TextCapitalization.characters, validator: (v) => v == null || v.trim().length < 6 - ? 'Enter the 6-character code' + ? _l10n.enterRegistrationCode : null, ), const SizedBox(height: 8), @@ -298,7 +308,7 @@ class _RegistrationViewState extends State { onPressed: () { vm.reset(); }, - child: const Text('Back'), + child: Text(_l10n.back), ), const SizedBox(width: 12), Expanded( @@ -312,7 +322,7 @@ class _RegistrationViewState extends State { .toUpperCase(), ); }, - child: const Text('Register'), + child: Text(_l10n.register), ), ), ], @@ -330,7 +340,7 @@ class _RegistrationViewState extends State { const CircularProgressIndicator(strokeWidth: 3), const SizedBox(height: 24), Text( - 'Registering device…', + _l10n.registeringDevice, style: Theme.of(context).textTheme.titleMedium, ), ], @@ -349,7 +359,7 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 24), Text( - 'Device registered!', + _l10n.deviceRegistered, textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), @@ -365,7 +375,7 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 8), Text( - 'Open tabs will sync to the dashboard.', + _l10n.openTabsWillSync, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( @@ -378,7 +388,7 @@ class _RegistrationViewState extends State { // Once configured, the router redirects to /bar by itself. onPressed: () => context.read().activate(), icon: const Icon(Icons.arrow_forward), - label: const Text('Start using KoolTap'), + label: Text(_l10n.done), ), ], ); diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 80976cd..363998d 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -53,6 +53,12 @@ class _SettingsScreenViewState extends State { }); } + void _leave() { + final adminPinViewModel = context.read(); + context.popOrGo('/bar'); + adminPinViewModel.lock(); + } + void _cancelVersionHold() { _versionHoldTimer?.cancel(); _versionHoldTimer = null; @@ -225,7 +231,6 @@ class _SettingsScreenViewState extends State { return; } - adminPinViewModel.lock(); return; } @@ -250,10 +255,7 @@ class _SettingsScreenViewState extends State { if (!success) { _showError(l10n.errorText(adminPinViewModel.error)); - return; } - - adminPinViewModel.lock(); } @override @@ -263,184 +265,189 @@ class _SettingsScreenViewState extends State { final pinLockViewModel = context.watch(); final adminPinViewModel = context.watch(); - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () => context.popOrGo('/bar'), - icon: const Icon(Icons.arrow_back), + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) context.read().lock(); + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: _leave, + icon: const Icon(Icons.arrow_back), + ), + title: Text(l10n.settingsTitle), ), - title: Text(l10n.settingsTitle), - ), - body: Builder( - builder: (context) { - final isLoading = - settingsViewModel.isLoading || - pinLockViewModel.isLoading || - adminPinViewModel.isLoading; - final hasLoaded = - settingsViewModel.hasLoaded && - pinLockViewModel.hasLoaded && - adminPinViewModel.hasLoaded; + body: Builder( + builder: (context) { + final isLoading = + settingsViewModel.isLoading || + pinLockViewModel.isLoading || + adminPinViewModel.isLoading; + final hasLoaded = + settingsViewModel.hasLoaded && + pinLockViewModel.hasLoaded && + adminPinViewModel.hasLoaded; - if (isLoading && !hasLoaded) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2.5), - ); - } + if (isLoading && !hasLoaded) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2.5), + ); + } - final settings = settingsViewModel.settings; + final settings = settingsViewModel.settings; - return ListView( - padding: const EdgeInsets.symmetric(vertical: 12), - children: [ - _SettingsSection( - title: l10n.security, - children: [ - _SettingsSwitchTile( - icon: Icons.lock_outline_rounded, - title: l10n.pinRequired, - value: settings.pinRequired, - onChanged: (value) { - if (value) { - _enablePin(); - } else { - _disablePin(); - } - }, - ), - _SettingsSwitchTile( - icon: Icons.lock_clock_rounded, - title: l10n.autoLockOnExit, - value: settings.autoLockEnabled, - onChanged: (value) { - settingsViewModel.updateAutoLockEnabled(value); - }, - ), - if (settings.pinRequired) - _SettingsTile( - icon: Icons.pin_rounded, - title: l10n.changePin, - onTap: _changePin, - ), - _SettingsTile( - icon: Icons.admin_panel_settings_outlined, - title: l10n.adminPin, - subtitle: adminPinViewModel.isPinSet - ? l10n.adminPinConfigured - : l10n.adminPinNotSet, - onTap: _configureAdminPin, - ), - ], - ), - _SettingsSection( - title: l10n.appearance, - children: [ - _SettingsTile( - icon: Icons.language_rounded, - title: l10n.language, - subtitle: switch (settings.language) { - AppLanguage.system => l10n.languageSystem, - AppLanguage.english => l10n.languageEnglish, - AppLanguage.dutch => l10n.languageDutch, - }, - onTap: () async { - final selected = await _pickOption( - context, - options: AppLanguage.values, - selected: settings.language, - label: (language) => switch (language) { - AppLanguage.system => l10n.languageSystem, - AppLanguage.english => l10n.languageEnglish, - AppLanguage.dutch => l10n.languageDutch, - }, - ); - - if (selected != null) { - await settingsViewModel.updateLanguage(selected); - } - }, - ), - _SettingsTile( - icon: Icons.grid_view_rounded, - title: l10n.productGridRows, - subtitle: l10n.rowsCount(settings.barGridRows), - onTap: () async { - final selected = await _pickOption( - context, - options: const [2, 3, 4, 5, 6], - selected: settings.barGridRows, - label: l10n.rowsCount, - ); - - if (selected != null) { - await settingsViewModel.updateBarGridRows(selected); - } - }, - ), - _SettingsTile( - icon: Icons.brightness_6_rounded, - title: l10n.theme, - subtitle: switch (settings.themeMode) { - AppThemeMode.system => l10n.system, - AppThemeMode.light => l10n.light, - AppThemeMode.dark => l10n.dark, - AppThemeMode.ugly => l10n.ugly, - }, - onTap: () async { - final selected = await _pickOption( - context, - options: AppThemeMode.values, - selected: settings.themeMode, - label: (mode) => switch (mode) { - AppThemeMode.system => l10n.system, - AppThemeMode.light => l10n.light, - AppThemeMode.dark => l10n.dark, - AppThemeMode.ugly => l10n.ugly, - }, - ); - - if (selected != null) { - await settingsViewModel.updateThemeMode(selected); - } - }, - ), - ], - ), - if (Platform.isAndroid) + return ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ _SettingsSection( - title: l10n.updates, + title: l10n.security, children: [ + _SettingsSwitchTile( + icon: Icons.lock_outline_rounded, + title: l10n.pinRequired, + value: settings.pinRequired, + onChanged: (value) { + if (value) { + _enablePin(); + } else { + _disablePin(); + } + }, + ), + _SettingsSwitchTile( + icon: Icons.lock_clock_rounded, + title: l10n.autoLockOnExit, + value: settings.autoLockEnabled, + onChanged: (value) { + settingsViewModel.updateAutoLockEnabled(value); + }, + ), + if (settings.pinRequired) + _SettingsTile( + icon: Icons.pin_rounded, + title: l10n.changePin, + onTap: _changePin, + ), _SettingsTile( - icon: Icons.system_update_alt_rounded, - title: settingsViewModel.checkingForUpdates - ? l10n.checkingForUpdates - : l10n.checkForUpdates, - onTap: settingsViewModel.checkingForUpdates - ? null - : _checkForUpdates, + icon: Icons.admin_panel_settings_outlined, + title: l10n.adminPin, + subtitle: adminPinViewModel.isPinSet + ? l10n.adminPinConfigured + : l10n.adminPinNotSet, + onTap: _configureAdminPin, ), ], ), - const SizedBox(height: 40), - GestureDetector( - onTapDown: (_) => _startVersionHold(), - onTapUp: (_) => _cancelVersionHold(), - onTapCancel: _cancelVersionHold, - child: Center( - child: Text( - l10n.version(_appVersion), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + _SettingsSection( + title: l10n.appearance, + children: [ + _SettingsTile( + icon: Icons.language_rounded, + title: l10n.language, + subtitle: switch (settings.language) { + AppLanguage.system => l10n.languageSystem, + AppLanguage.english => l10n.languageEnglish, + AppLanguage.dutch => l10n.languageDutch, + }, + onTap: () async { + final selected = await _pickOption( + context, + options: AppLanguage.values, + selected: settings.language, + label: (language) => switch (language) { + AppLanguage.system => l10n.languageSystem, + AppLanguage.english => l10n.languageEnglish, + AppLanguage.dutch => l10n.languageDutch, + }, + ); + + if (selected != null) { + await settingsViewModel.updateLanguage(selected); + } + }, + ), + _SettingsTile( + icon: Icons.grid_view_rounded, + title: l10n.productGridRows, + subtitle: l10n.rowsCount(settings.barGridRows), + onTap: () async { + final selected = await _pickOption( + context, + options: const [2, 3, 4, 5, 6], + selected: settings.barGridRows, + label: l10n.rowsCount, + ); + + if (selected != null) { + await settingsViewModel.updateBarGridRows(selected); + } + }, + ), + _SettingsTile( + icon: Icons.brightness_6_rounded, + title: l10n.theme, + subtitle: switch (settings.themeMode) { + AppThemeMode.system => l10n.system, + AppThemeMode.light => l10n.light, + AppThemeMode.dark => l10n.dark, + AppThemeMode.ugly => l10n.ugly, + }, + onTap: () async { + final selected = await _pickOption( + context, + options: AppThemeMode.values, + selected: settings.themeMode, + label: (mode) => switch (mode) { + AppThemeMode.system => l10n.system, + AppThemeMode.light => l10n.light, + AppThemeMode.dark => l10n.dark, + AppThemeMode.ugly => l10n.ugly, + }, + ); + + if (selected != null) { + await settingsViewModel.updateThemeMode(selected); + } + }, + ), + ], + ), + if (Platform.isAndroid) + _SettingsSection( + title: l10n.updates, + children: [ + _SettingsTile( + icon: Icons.system_update_alt_rounded, + title: settingsViewModel.checkingForUpdates + ? l10n.checkingForUpdates + : l10n.checkForUpdates, + onTap: settingsViewModel.checkingForUpdates + ? null + : _checkForUpdates, + ), + ], + ), + const SizedBox(height: 40), + GestureDetector( + onTapDown: (_) => _startVersionHold(), + onTapUp: (_) => _cancelVersionHold(), + onTapCancel: _cancelVersionHold, + child: Center( + child: Text( + l10n.version(_appVersion), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), ), ), ), - ), - const SizedBox(height: 20), - ], - ); - }, + const SizedBox(height: 20), + ], + ); + }, + ), ), ); } diff --git a/make_release.ps1 b/make_release.ps1 index c5183b0..a4c445a 100644 --- a/make_release.ps1 +++ b/make_release.ps1 @@ -106,7 +106,7 @@ Write-Host "Uploading $version to $updateServer..." Add-Type -AssemblyName System.Net.Http $client = [Net.Http.HttpClient]::new() -$client.Timeout = [TimeSpan]::FromMinutes(5) +$client.Timeout = [TimeSpan]::FromMinutes(10) $client.DefaultRequestHeaders.Add('X-Admin-Token', $token) $apk = [IO.File]::OpenRead($apkPath) diff --git a/pubspec.yaml b/pubspec.yaml index be4d247..7b05de5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.2.10+0 +version: 1.2.13+0 environment: sdk: ^3.12.2 diff --git a/test/app_lifecycle_lock_test.dart b/test/app_lifecycle_lock_test.dart index bc244db..dee06ed 100644 --- a/test/app_lifecycle_lock_test.dart +++ b/test/app_lifecycle_lock_test.dart @@ -3,46 +3,60 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/app/app_lifecycle_lock.dart'; void main() { - test('locks when the app is no longer active', () { - var lockCount = 0; - final observer = AppLifecycleLockObserver(onLock: () => lockCount++); + late DateTime now; + late int lockCount; - for (final state in [ - AppLifecycleState.inactive, - AppLifecycleState.hidden, - AppLifecycleState.paused, - AppLifecycleState.detached, - ]) { - observer.didChangeAppLifecycleState(state); - } + AppLifecycleLockObserver observer({bool Function()? shouldLock}) { + return AppLifecycleLockObserver( + onLock: () => lockCount++, + shouldLock: shouldLock, + clock: () => now, + ); + } - expect(lockCount, 4); + setUp(() { + now = DateTime(2026, 1, 1, 20); + lockCount = 0; }); - test('does not lock while the app is active', () { - var lockCount = 0; - final observer = AppLifecycleLockObserver(onLock: () => lockCount++); + test('locks when the app returns after the grace period', () { + final lock = observer(); - observer.didChangeAppLifecycleState(AppLifecycleState.resumed); + lock.didChangeAppLifecycleState(AppLifecycleState.inactive); + lock.didChangeAppLifecycleState(AppLifecycleState.hidden); + lock.didChangeAppLifecycleState(AppLifecycleState.paused); + now = now.add(const Duration(seconds: 30)); + lock.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect(lockCount, 1); + }); + + test('does not lock after a short trip to the background', () { + final lock = observer(); + + lock.didChangeAppLifecycleState(AppLifecycleState.paused); + now = now.add(const Duration(seconds: 10)); + lock.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect(lockCount, 0); + }); + + test('does not lock when only inactive', () { + final lock = observer(); + + lock.didChangeAppLifecycleState(AppLifecycleState.inactive); + now = now.add(const Duration(minutes: 5)); + lock.didChangeAppLifecycleState(AppLifecycleState.resumed); expect(lockCount, 0); }); test('does not lock when auto-lock is disabled', () { - var lockCount = 0; - final observer = AppLifecycleLockObserver( - onLock: () => lockCount++, - shouldLock: () => false, - ); + final lock = observer(shouldLock: () => false); - for (final state in [ - AppLifecycleState.inactive, - AppLifecycleState.hidden, - AppLifecycleState.paused, - AppLifecycleState.detached, - ]) { - observer.didChangeAppLifecycleState(state); - } + lock.didChangeAppLifecycleState(AppLifecycleState.paused); + now = now.add(const Duration(minutes: 5)); + lock.didChangeAppLifecycleState(AppLifecycleState.resumed); expect(lockCount, 0); }); diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index dc3a389..f143497 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -131,4 +131,14 @@ void main() { expect(viewModel.loadError, isNull); }, ); + + test('finds an open tab by name, ignoring case and spaces', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('Jan'), _tab('Piet')]); + await pumpEventQueue(); + + expect(viewModel.hasOpenTabNamed(' jan '), true); + expect(viewModel.hasOpenTabNamed('Jan', exceptTabId: 'Jan'), false); + expect(viewModel.hasOpenTabNamed('Joris'), false); + }); } diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index da6f798..4bfeabb 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -565,5 +565,22 @@ void main() { expect(closedTabs.last.customerName, 'First'); }); }); + + group('hidden products', () { + test('a tab holding a deleted product can still be changed', () async { + final tab = await service.createTab(customerName: 'John'); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await DriftProductService(database: database).deleteProduct('prod-1'); + + final item = (await service.getTabById(tab.id))!.items.single; + await service.adjustTabItemQuantity(tabItemId: item.id, delta: -1); + await service.deleteTab(tab.id); + + expect(await service.getTabById(tab.id), isNull); + }); + }); }); } diff --git a/test/closed_tab_model_test.dart b/test/closed_tab_model_test.dart new file mode 100644 index 0000000..1e5b85a --- /dev/null +++ b/test/closed_tab_model_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/models/closed_tab.dart'; +import 'package:kooltap/models/closed_tab_item.dart'; +import 'package:kooltap/models/payment_method.dart'; + +ClosedTabItem _item({ + required String id, + int quantity = 1, + int unitPriceInCents = 400, + DateTime? removedAt, +}) { + return ClosedTabItem( + id: id, + closedTabId: 'closed-1', + productId: 'prod-1', + productName: 'Beer', + quantity: quantity, + unitPriceInCents: unitPriceInCents, + purchasedAt: DateTime(2026, 1, 1, 20), + removedAt: removedAt, + ); +} + +ClosedTab _tab(List items) { + return ClosedTab( + id: 'closed-1', + originalTabId: 'tab-1', + customerName: 'John', + closedAt: DateTime(2026, 1, 1, 23), + paymentMethod: PaymentMethod.cash, + items: items, + ); +} + +void main() { + group('ClosedTabItem', () { + test('charges quantity times unit price', () { + expect( + _item(id: 'a', quantity: 3, unitPriceInCents: 250).lineTotalInCents, + 750, + ); + }); + + test('charges nothing once removed', () { + final removed = _item(id: 'a', removedAt: DateTime(2026, 1, 1, 21)); + + expect(removed.isRemoved, true); + expect(removed.lineTotalInCents, 0); + }); + }); + + group('ClosedTab', () { + test('totals only the purchases that were not removed', () { + final tab = _tab([ + _item(id: 'a', unitPriceInCents: 400), + _item(id: 'b', unitPriceInCents: 400), + _item(id: 'c', unitPriceInCents: 600), + _item(id: 'd', unitPriceInCents: 400, removedAt: DateTime(2026)), + ]); + + expect(tab.totalInCents, 1400); + expect(tab.itemCount, 3); + }); + + test('is zero when every purchase was removed', () { + final tab = _tab([ + _item(id: 'a', removedAt: DateTime(2026)), + _item(id: 'b', removedAt: DateTime(2026)), + ]); + + expect(tab.totalInCents, 0); + expect(tab.itemCount, 0); + }); + }); + + group('PaymentMethod', () { + test('round-trips through its stored value', () { + for (final method in PaymentMethod.values) { + expect(PaymentMethod.fromValue(method.value), method); + } + }); + + test('falls back to cash for an unknown value', () { + expect(PaymentMethod.fromValue('bitcoin'), PaymentMethod.cash); + }); + }); +} diff --git a/test/delete_tab_dialog_test.dart b/test/delete_tab_dialog_test.dart new file mode 100644 index 0000000..cdb79bd --- /dev/null +++ b/test/delete_tab_dialog_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/tab_item.dart'; +import 'package:kooltap/services/admin_pin_service.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/pin_lock_service.dart'; +import 'package:kooltap/viewmodels/admin_pin_view_model.dart'; +import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltap/views/dialogs/edit_tab_dialog.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; + +class MockBarTabService extends Mock implements BarTabService {} + +class MockAdminPinService extends Mock implements AdminPinService {} + +BarTab _tab({required bool withItems}) { + return BarTab( + id: 'tab-1', + customerName: 'John', + openedAt: DateTime(2026), + items: [ + if (withItems) + const TabItem( + id: 'item-1', + tabId: 'tab-1', + productId: 'beer', + productName: 'Beer', + quantity: 2, + unitPriceInCents: 250, + ), + ], + ); +} + +void main() { + late MockBarTabService barTabService; + late MockAdminPinService adminPinService; + late AdminPinViewModel adminPinViewModel; + + setUp(() { + barTabService = MockBarTabService(); + adminPinService = MockAdminPinService(); + adminPinViewModel = AdminPinViewModel(adminPinService: adminPinService); + + when(() => barTabService.deleteTab(any())).thenAnswer((_) async {}); + when(() => adminPinService.hasPin()).thenAnswer((_) async => true); + when(() => adminPinService.verifyPin('1234')).thenAnswer((_) async => true); + when( + () => adminPinService.verifyPin('0000'), + ).thenAnswer((_) async => false); + }); + + Future openDialog(WidgetTester tester, BarTab tab) async { + await adminPinViewModel.load(); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => BarScreenViewModel(barTabService: barTabService), + ), + ChangeNotifierProvider.value( + value: adminPinViewModel, + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => confirmDeleteTab(context, tab), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + Future confirmWithPin(WidgetTester tester, String? pin) async { + if (pin != null) await tester.enterText(find.byType(TextField), pin); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + } + + testWidgets('deletes an empty tab without asking for a PIN', (tester) async { + await openDialog(tester, _tab(withItems: false)); + + expect(find.byType(TextField), findsNothing); + await confirmWithPin(tester, null); + + verify(() => barTabService.deleteTab('tab-1')).called(1); + }); + + testWidgets('deletes a tab with drinks after the right admin PIN', ( + tester, + ) async { + await openDialog(tester, _tab(withItems: true)); + await confirmWithPin(tester, '1234'); + + verify(() => barTabService.deleteTab('tab-1')).called(1); + expect(adminPinViewModel.isUnlocked, false); + }); + + testWidgets('keeps a tab with drinks after a wrong admin PIN', ( + tester, + ) async { + await openDialog(tester, _tab(withItems: true)); + await confirmWithPin(tester, '0000'); + + verifyNever(() => barTabService.deleteTab(any())); + }); + + testWidgets('keeps a tab with drinks while the PIN is locked out', ( + tester, + ) async { + when( + () => adminPinService.verifyPin(any()), + ).thenThrow(const PinLockedOutException(Duration(minutes: 1))); + + await openDialog(tester, _tab(withItems: true)); + await confirmWithPin(tester, '1234'); + + verifyNever(() => barTabService.deleteTab(any())); + }); + + testWidgets('cancelling deletes nothing', (tester) async { + await openDialog(tester, _tab(withItems: true)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => barTabService.deleteTab(any())); + }); + + testWidgets('does not ask for a PIN when no admin PIN is set', ( + tester, + ) async { + when(() => adminPinService.hasPin()).thenAnswer((_) async => false); + + await openDialog(tester, _tab(withItems: true)); + + expect(find.byType(TextField), findsNothing); + await confirmWithPin(tester, null); + + verify(() => barTabService.deleteTab('tab-1')).called(1); + }); +} diff --git a/test/export_service_test.dart b/test/export_service_test.dart new file mode 100644 index 0000000..41393aa --- /dev/null +++ b/test/export_service_test.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/payment_method.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/export_service.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +const _beer = Product( + id: 'beer', + name: 'Beer', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 250, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase database; + late DriftBarTabService tabs; + late List requests; + late http.Response Function() respond; + + DefaultExportService exportService({String? token}) { + return DefaultExportService( + barTabService: tabs, + authToken: () async => token, + client: MockClient((request) async { + requests.add(request); + return respond(); + }), + ); + } + + setUp(() async { + PackageInfo.setMockInitialValues( + appName: 'KoolTap', + packageName: 'com.kooltap', + version: '1.2.12', + buildNumber: '1', + buildSignature: '', + ); + + database = AppDatabase(NativeDatabase.memory()); + tabs = DriftBarTabService(database: database); + requests = []; + respond = () => http.Response('', 201); + + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: _beer.id, + name: _beer.name, + category: _beer.category, + stockQuantity: _beer.stockQuantity, + lowStockThreshold: _beer.lowStockThreshold, + priceInCents: _beer.priceInCents, + ), + ); + }); + + tearDown(() async { + await database.close(); + }); + + test('backs up the totals that were charged', () async { + final tab = await tabs.createTab(customerName: 'John'); + for (var i = 0; i < 3; i++) { + await tabs.addProductToTab(tabId: tab.id, product: _beer); + } + final item = (await tabs.getTabById(tab.id))!.items.single; + await tabs.adjustTabItemQuantity(tabItemId: item.id, delta: -1); + await tabs.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq); + + await exportService().uploadToServer(); + + final body = jsonDecode(requests.single.body) as Map; + final closed = (body['closedTabs'] as List).single as Map; + final items = closed['items'] as List; + + expect(body['appVersion'], '1.2.12'); + expect(closed['customerName'], 'John'); + expect(closed['paymentMethod'], 'payconiq'); + expect(closed['totalInCents'], 500); + expect(closed['itemCount'], 2); + expect(items, hasLength(3)); + expect(items.where((item) => item['isRemoved'] == true), hasLength(1)); + expect( + items.fold( + 0, + (sum, item) => sum + (item['lineTotalInCents'] as int), + ), + closed['totalInCents'], + ); + }); + + test('sends the device token when linked', () async { + await exportService(token: 'secret').uploadToServer(); + + expect(requests.single.headers['Authorization'], 'Bearer secret'); + }); + + test('sends no token when not linked', () async { + await exportService().uploadToServer(); + + expect(requests.single.headers.containsKey('Authorization'), false); + }); + + test('never throws when the server is unreachable', () async { + respond = () => throw http.ClientException('offline'); + + await expectLater(exportService().uploadToServer(), completes); + }); + + test('never throws when the server errors', () async { + respond = () => http.Response('oops', 500); + + await expectLater(exportService().uploadToServer(), completes); + }); +} diff --git a/test/money_test.dart b/test/money_test.dart index ba45d9a..d309623 100644 --- a/test/money_test.dart +++ b/test/money_test.dart @@ -19,4 +19,49 @@ void main() { expect(123456.asEuro, '€ 1.234,56'); }); }); + + group('parseEuroToCents', () { + test('accepts a comma or a dot as decimal separator', () { + expect(parseEuroToCents('2,50'), 250); + expect(parseEuroToCents('2.50'), 250); + }); + + test('accepts whole euros and a single decimal', () { + expect(parseEuroToCents('3'), 300); + expect(parseEuroToCents('3,5'), 350); + expect(parseEuroToCents('0'), 0); + }); + + test('ignores surrounding whitespace', () { + expect(parseEuroToCents(' 4,20 '), 420); + }); + + test('is exact where doubles would round', () { + expect(parseEuroToCents('0,29'), 29); + expect(parseEuroToCents('1,15'), 115); + expect(parseEuroToCents('4,35'), 435); + }); + + test('rejects more than two decimals instead of rounding', () { + expect(parseEuroToCents('2,505'), isNull); + }); + + test('rejects anything that is not a plain amount', () { + for (final input in [ + '', + '-1', + 'abc', + '1e3', + 'NaN', + 'Infinity', + '1.000,50', + '2,', + ',50', + '€2', + '1234567', + ]) { + expect(parseEuroToCents(input), isNull, reason: input); + } + }); + }); } diff --git a/test/product_service_test.dart b/test/product_service_test.dart index bcd4fc0..039a997 100644 --- a/test/product_service_test.dart +++ b/test/product_service_test.dart @@ -171,7 +171,7 @@ void main() { }); group('deleteProduct', () { - test('removes product from database', () async { + test('hides the product but keeps its row', () async { await database .into(database.products) .insert( @@ -187,8 +187,11 @@ void main() { await service.deleteProduct('delete-test'); - final products = await database.select(database.products).get(); - expect(products, isEmpty); + expect(await service.getProducts(), isEmpty); + final row = await service.getProductById('delete-test'); + expect(row!.active, isFalse); + + await service.increaseStock('delete-test', 1); }); }); diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart index 1fe0dcd..87a79d8 100644 --- a/test/remote_sync_service_test.dart +++ b/test/remote_sync_service_test.dart @@ -146,6 +146,21 @@ void main() { expect(server.syncRequests.last['cursor'], 'cursor-1'); }); + test('leaves undone drinks out of the snapshot total', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final item = (await barTabService.getTabById(tab.id))!.items.single; + await barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: -2); + + await service.sync(); + + final snapshot = (server.syncRequests.single['openTabs'] as List).single; + expect(snapshot['totalInCents'], 0); + expect(snapshot['itemCount'], 0); + expect(snapshot['items'], isEmpty); + }); + test('reports a server error as a failure, not a success', () async { server.syncResponse = http.Response('oops', 500); @@ -222,6 +237,31 @@ void main() { expect(eventTypes(), contains(RemoteSyncEventType.tabClosedRemotely)); }); + test('charges what the dashboard saw when it asked', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + for (var i = 0; i < 3; i++) { + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + } + await service.sync(); + final seen = (server.syncRequests.last['openTabs'] as List).single; + + server.respondWith({ + 'commands': [ + _closeCommand( + id: 'cmd-1', + tabId: tab.id, + expectedVersion: seen['version'] as int, + ), + ], + }); + await service.sync(); + + final closed = (await barTabService.getClosedTabs()).single; + expect(closed.totalInCents, seen['totalInCents']); + expect(closed.totalInCents, 1200); + expect((await barTabService.getTabById(tab.id))!.totalInCents, 0); + }); + test('refuses to close a tab that changed since the request', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); diff --git a/test/tab_money_test.dart b/test/tab_money_test.dart new file mode 100644 index 0000000..946ff22 --- /dev/null +++ b/test/tab_money_test.dart @@ -0,0 +1,390 @@ +import 'dart:math'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/payment_method.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/product_service.dart'; + +const _beer = Product( + id: 'beer', + name: 'Beer', + category: 'Drinks', + stockQuantity: 50, + lowStockThreshold: 5, + priceInCents: 250, +); + +const _wine = Product( + id: 'wine', + name: 'Wine', + category: 'Drinks', + stockQuantity: 20, + lowStockThreshold: 5, + priceInCents: 450, +); + +const _water = Product( + id: 'water', + name: 'Tap water', + category: 'Drinks', + stockQuantity: 0, + lowStockThreshold: 0, + infiniteStock: true, + priceInCents: 0, +); + +/// End-to-end checks on what customers are charged and what leaves the +/// fridge, against a real (in-memory) database. +void main() { + late AppDatabase database; + late DriftBarTabService tabs; + late DriftProductService products; + + Future insertProduct(Product product) { + return database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: product.id, + name: product.name, + category: product.category, + stockQuantity: product.stockQuantity, + lowStockThreshold: product.lowStockThreshold, + infiniteStock: Value(product.infiniteStock), + priceInCents: product.priceInCents, + ), + ); + } + + Future stockOf(String productId) async { + return (await products.getProductById(productId))!.stockQuantity; + } + + Future quantityOnOpenTabs(String productId) async { + final open = await tabs.getOpenTabs(); + return open + .expand((tab) => tab.items) + .where((item) => item.productId == productId) + .fold(0, (sum, item) => sum + item.quantity); + } + + Future soldInHistory(String productId) async { + final closed = await tabs.getClosedTabs(); + return closed + .expand((tab) => tab.items) + .where((item) => item.productId == productId && !item.isRemoved) + .fold(0, (sum, item) => sum + item.quantity); + } + + Future itemId(String tabId, String productId) async { + final tab = (await tabs.getTabById(tabId))!; + return tab.items.firstWhere((item) => item.productId == productId).id; + } + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + tabs = DriftBarTabService(database: database); + products = DriftProductService(database: database); + + await insertProduct(_beer); + await insertProduct(_wine); + await insertProduct(_water); + }); + + tearDown(() async { + await database.close(); + }); + + group('closing a tab', () { + test('charges exactly what the open tab showed', () async { + final tab = await tabs.createTab(customerName: 'John'); + for (var i = 0; i < 3; i++) { + await tabs.addProductToTab(tabId: tab.id, product: _beer); + } + await tabs.addProductToTab(tabId: tab.id, product: _wine); + await tabs.adjustTabItemQuantity( + tabItemId: await itemId(tab.id, 'beer'), + delta: -1, + ); + + final open = (await tabs.getTabById(tab.id))!; + expect(open.totalInCents, 2 * 250 + 450); + + await tabs.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq); + + final closed = (await tabs.getClosedTabs()).single; + expect(closed.totalInCents, open.totalInCents); + expect(closed.itemCount, open.itemCount); + expect(closed.paymentMethod, PaymentMethod.payconiq); + expect(closed.items.where((item) => item.isRemoved), hasLength(1)); + }); + + test('stores each payment method as chosen', () async { + for (final method in PaymentMethod.values) { + final tab = await tabs.createTab(customerName: method.name); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + await tabs.closeTab(tab.id, paymentMethod: method); + } + + final closed = await tabs.getClosedTabs(); + expect( + {for (final tab in closed) tab.customerName: tab.paymentMethod}, + {for (final method in PaymentMethod.values) method.name: method}, + ); + }); + + test('does not record anything for an empty tab', () async { + final tab = await tabs.createTab(customerName: 'John'); + + await tabs.closeTab(tab.id); + + expect(await tabs.getClosedTabs(), isEmpty); + }); + + test('records a tab whose drinks were all undone at zero', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + await tabs.adjustTabItemQuantity( + tabItemId: await itemId(tab.id, 'beer'), + delta: -1, + ); + + await tabs.closeTab(tab.id); + + final closed = (await tabs.getClosedTabs()).single; + expect(closed.totalInCents, 0); + expect(closed.items.single.isRemoved, true); + }); + + test('never charges the same drink twice', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + await tabs.closeTab(tab.id); + + await tabs.addProductToTab(tabId: tab.id, product: _wine); + await tabs.closeTab(tab.id); + await tabs.closeTab(tab.id); + + final closed = await tabs.getClosedTabs(); + expect(closed, hasLength(2)); + expect( + closed.map((tab) => tab.totalInCents), + unorderedEquals([250, 450]), + ); + expect((await tabs.getTabById(tab.id))!.items, isEmpty); + }); + + test('keeps the tab open under the same name', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + + await tabs.closeTab(tab.id); + + final reopened = (await tabs.getTabById(tab.id))!; + expect(reopened.customerName, 'John'); + expect(reopened.totalInCents, 0); + }); + + test('a failed version check changes nothing', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + final staleVersion = (await tabs.getTabById(tab.id))!.version; + await tabs.addProductToTab(tabId: tab.id, product: _beer); + + await expectLater( + tabs.closeTab(tab.id, expectedVersion: staleVersion), + throwsA(isA()), + ); + + expect(await tabs.getClosedTabs(), isEmpty); + expect((await tabs.getTabById(tab.id))!.totalInCents, 500); + }); + }); + + group('prices', () { + test('a tab keeps the price a drink was rung up at', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + + await products.updateProduct(_beer.copyWith(priceInCents: 300)); + await tabs.closeTab(tab.id); + + expect((await tabs.getClosedTabs()).single.totalInCents, 250); + }); + + test('a new tab uses the current price', () async { + final updated = _beer.copyWith(priceInCents: 300); + await products.updateProduct(updated); + + final tab = await tabs.createTab(customerName: 'Jane'); + await tabs.addProductToTab(tabId: tab.id, product: updated); + + expect((await tabs.getTabById(tab.id))!.totalInCents, 300); + }); + + test('free products add nothing to the total', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _water); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + + final open = (await tabs.getTabById(tab.id))!; + expect(open.totalInCents, 250); + expect(open.itemCount, 2); + }); + }); + + group('stock', () { + test('every bottle is either in the fridge, on a tab or sold', () async { + final random = Random(42); + final tabIds = [ + for (final name in ['A', 'B', 'C', 'D']) + (await tabs.createTab(customerName: name)).id, + ]; + + for (var step = 0; step < 300; step++) { + final tabId = tabIds[random.nextInt(tabIds.length)]; + final product = random.nextBool() ? _beer : _wine; + + try { + switch (random.nextInt(10)) { + case < 6: + await tabs.addProductToTab(tabId: tabId, product: product); + case < 8: + final tab = (await tabs.getTabById(tabId))!; + if (tab.items.isEmpty) continue; + final item = tab.items[random.nextInt(tab.items.length)]; + await tabs.adjustTabItemQuantity( + tabItemId: item.id, + delta: random.nextInt(5) - 2, + ); + case 8: + await tabs.closeTab(tabId); + default: + await tabs.deleteTab(tabId); + tabIds.remove(tabId); + tabIds.add((await tabs.createTab(customerName: 'New')).id); + } + } on InsufficientStockException { + // Running out is part of the scenario. + } + + for (final product in [_beer, _wine]) { + final accounted = + await stockOf(product.id) + + await quantityOnOpenTabs(product.id) + + await soldInHistory(product.id); + expect(accounted, product.stockQuantity, reason: 'step $step'); + expect(await stockOf(product.id), greaterThanOrEqualTo(0)); + } + } + }); + + test('can never sell more than is in stock', () async { + final tab = await tabs.createTab(customerName: 'John'); + var sold = 0; + + for (var i = 0; i < _wine.stockQuantity + 5; i++) { + try { + await tabs.addProductToTab(tabId: tab.id, product: _wine); + sold++; + } on InsufficientStockException { + // Expected once the wine runs out. + } + } + + expect(sold, _wine.stockQuantity); + expect(await stockOf('wine'), 0); + expect((await tabs.getTabById(tab.id))!.totalInCents, 20 * 450); + }); + + test('simultaneous orders for the last bottle sell it once', () async { + await products.updateProduct(_wine.copyWith(stockQuantity: 1)); + final first = await tabs.createTab(customerName: 'A'); + final second = await tabs.createTab(customerName: 'B'); + + final results = await Future.wait([ + for (final tab in [first, second]) + tabs + .addProductToTab(tabId: tab.id, product: _wine) + .then((_) => true) + .catchError( + (_) => false, + test: (e) => e is InsufficientStockException, + ), + ]); + + expect(results.where((ok) => ok), hasLength(1)); + expect(await stockOf('wine'), 0); + expect(await quantityOnOpenTabs('wine'), 1); + }); + + test('raising a quantity past the stock adds nothing', () async { + await products.updateProduct(_wine.copyWith(stockQuantity: 2)); + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _wine); + + await expectLater( + tabs.adjustTabItemQuantity( + tabItemId: await itemId(tab.id, 'wine'), + delta: 5, + ), + throwsA(isA()), + ); + + expect(await stockOf('wine'), 1); + expect((await tabs.getTabById(tab.id))!.items.single.quantity, 1); + }); + + test('lowering past zero only returns what was on the tab', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + + final applied = await tabs.adjustTabItemQuantity( + tabItemId: await itemId(tab.id, 'beer'), + delta: -10, + ); + + expect(applied, -2); + expect(await stockOf('beer'), _beer.stockQuantity); + }); + + test('closing a tab does not touch stock', () async { + final tab = await tabs.createTab(customerName: 'John'); + await tabs.addProductToTab(tabId: tab.id, product: _beer); + + await tabs.closeTab(tab.id); + + expect(await stockOf('beer'), _beer.stockQuantity - 1); + }); + + test('infinite stock is never counted down or run out', () async { + final tab = await tabs.createTab(customerName: 'John'); + + for (var i = 0; i < 5; i++) { + await tabs.addProductToTab(tabId: tab.id, product: _water); + } + await tabs.deleteTab(tab.id); + + expect(await stockOf('water'), 0); + }); + + test('a failed add leaves no item behind', () async { + await products.updateProduct(_wine.copyWith(stockQuantity: 0)); + final tab = await tabs.createTab(customerName: 'John'); + + await expectLater( + tabs.addProductToTab(tabId: tab.id, product: _wine), + throwsA(isA()), + ); + + final open = (await tabs.getTabById(tab.id))!; + expect(open.items, isEmpty); + expect(open.totalInCents, 0); + }); + }); +}