fix: alot

This commit is contained in:
2026-10-19 21:41:34 +02:00
parent 30dd74af90
commit b0074aeb3f
40 changed files with 1979 additions and 405 deletions
+1
View File
@@ -7,6 +7,7 @@
<application
android:label="KoolTap"
android:name="${applicationName}"
android:allowBackup="false"
android:icon="@mipmap/launcher_icon">
<provider
android:name="androidx.core.content.FileProvider"
+3
View File
@@ -8,6 +8,7 @@ import '../database/app_database.dart';
import '../services/bar_tab_service.dart';
import '../services/default_product_seeder.dart';
import '../services/export_service.dart';
import '../services/remote_sync_service.dart';
import '../viewmodels/bar_screen_view_model.dart';
import '../viewmodels/product_list_view_model.dart';
@@ -38,9 +39,11 @@ class _AppBootstrapState extends State<AppBootstrap> {
context.read<ProductListViewModel>().ensureLoaded();
context.read<BarScreenViewModel>().ensureLoaded();
final syncService = context.read<RemoteSyncService>();
unawaited(
DefaultExportService(
barTabService: context.read<BarTabService>(),
authToken: () => syncService.authToken,
).uploadToServer(),
);
+18 -2
View File
@@ -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();
}
}
+32 -24
View File
@@ -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(
+33 -1
View File
@@ -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"
}
+186
View File
@@ -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
+98
View File
@@ -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';
}
+99
View File
@@ -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';
}
+32 -1
View File
@@ -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"
}
+1 -1
View File
@@ -63,7 +63,7 @@ Future<void> _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(
+19 -7
View File
@@ -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<String?> 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<void> 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(
+5 -2
View File
@@ -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,
+3 -2
View File
@@ -130,12 +130,13 @@ class DriftProductService implements ProductService {
);
}
/// Only hidden, since open tabs and history still refer to it.
@override
Future<void> 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
+32 -11
View File
@@ -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<String?> get venueId => secureStorage.read(key: _venueIdKey);
Future<bool> get isConfigured async {
final token = await secureStorage.read(key: _authTokenKey);
final venueId = await this.venueId;
return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false);
}
Future<bool> get isConfigured async => await _credentials() != null;
Future<String?> 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<Duration> _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<void> clearCredentials() async {
_cachedCredentials = null;
await secureStorage.delete(key: _deviceIdKey);
await secureStorage.delete(key: _authTokenKey);
await secureStorage.delete(key: _venueIdKey);
+1 -3
View File
@@ -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');
+13
View File
@@ -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;
}
@@ -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;
+8 -1
View File
@@ -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<void> 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;
+14 -19
View File
@@ -33,11 +33,8 @@ class AdminScreenView extends StatelessWidget {
final confirmed = await showDialog<bool>(
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'),
),
],
+9
View File
@@ -144,6 +144,15 @@ class _BarScreenViewState extends State<BarScreenView> {
location: '/history',
),
],
if (context.watch<RemoteSyncViewModel>().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,
+60 -6
View File
@@ -45,6 +45,36 @@ class _DevMenuViewState extends State<DevMenuView> {
).showSnackBar(SnackBar(content: Text(l10n.devAction(message))));
}
Future<void> _confirmThen(
String title,
Future<void> Function() action,
) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<DevMenuView> {
_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<DevMenuView> {
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<DevMenuView> {
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,
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
Future<bool> confirmDuplicateTabName(BuildContext context, String name) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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;
}
+132 -20
View File
@@ -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<void> showEditTabDialog(BuildContext context, BarTab tab) async {
final l10n = AppLocalizations.of(context);
@@ -13,10 +18,19 @@ Future<void> 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<BarScreenViewModel>();
if (viewModel.hasOpenTabNamed(trimmed, exceptTabId: tab.id) &&
!await confirmDuplicateTabName(context, trimmed)) {
return;
}
try {
await context.read<BarScreenViewModel>().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<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
final l10n = AppLocalizations.of(context);
final adminPinViewModel = context.read<AdminPinViewModel>();
final needsAdminPin = tab.items.isNotEmpty && adminPinViewModel.isPinSet;
final confirmed = await showDialog<bool>(
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<BarScreenViewModel>().deleteTab(tab.id);
@@ -112,3 +133,94 @@ Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
).showSnackBar(SnackBar(content: Text(l10n.couldNotDeleteTab)));
}
}
Future<AppError?> _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),
),
],
);
}
}
+8 -1
View File
@@ -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<String?> showNewTabDialog(BuildContext context) async {
@@ -42,8 +43,14 @@ Future<String?> showNewTabDialog(BuildContext context) async {
final trimmed = name?.trim() ?? '';
if (trimmed.isEmpty || !context.mounted) return null;
final viewModel = context.read<BarScreenViewModel>();
if (viewModel.hasOpenTabNamed(trimmed) &&
!await confirmDuplicateTabName(context, trimmed)) {
return null;
}
try {
await context.read<BarScreenViewModel>().createTab(trimmed);
await viewModel.createTab(trimmed);
return trimmed;
} catch (_) {
if (context.mounted) {
-59
View File
@@ -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,
),
],
),
),
),
),
),
),
);
}
}
+3 -19
View File
@@ -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<ProductFormView> {
});
}
int _parsePriceToCents(String value) {
final normalized = value.replaceAll(',', '.');
final euros = double.tryParse(normalized) ?? 0;
return (euros * 100).round();
}
Future<void> _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<ProductFormView> {
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<ProductFormView> {
return l10n.enterPrice;
}
final normalized = value.replaceAll(',', '.');
final price = double.tryParse(normalized);
if (price == null || price < 0) {
if (parseEuroToCents(value) == null) {
return l10n.enterValidPrice;
}
+33 -23
View File
@@ -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<RegistrationView> {
});
}
AppLocalizations get _l10n => AppLocalizations.of(context);
@override
void dispose() {
_deviceNameController.dispose();
@@ -39,6 +42,14 @@ class _RegistrationViewState extends State<RegistrationView> {
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<RegistrationView> {
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<RegistrationView> {
),
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<RegistrationView> {
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<RegistrationView> {
),
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<RegistrationView> {
),
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<RegistrationView> {
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<RegistrationView> {
),
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<RegistrationView> {
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<RegistrationView> {
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<RegistrationView> {
onPressed: () {
vm.reset();
},
child: const Text('Back'),
child: Text(_l10n.back),
),
const SizedBox(width: 12),
Expanded(
@@ -312,7 +322,7 @@ class _RegistrationViewState extends State<RegistrationView> {
.toUpperCase(),
);
},
child: const Text('Register'),
child: Text(_l10n.register),
),
),
],
@@ -330,7 +340,7 @@ class _RegistrationViewState extends State<RegistrationView> {
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<RegistrationView> {
),
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<RegistrationView> {
),
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<RegistrationView> {
// Once configured, the router redirects to /bar by itself.
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
icon: const Icon(Icons.arrow_forward),
label: const Text('Start using KoolTap'),
label: Text(_l10n.done),
),
],
);
+176 -169
View File
@@ -53,6 +53,12 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
});
}
void _leave() {
final adminPinViewModel = context.read<AdminPinViewModel>();
context.popOrGo('/bar');
adminPinViewModel.lock();
}
void _cancelVersionHold() {
_versionHoldTimer?.cancel();
_versionHoldTimer = null;
@@ -225,7 +231,6 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
return;
}
adminPinViewModel.lock();
return;
}
@@ -250,10 +255,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
if (!success) {
_showError(l10n.errorText(adminPinViewModel.error));
return;
}
adminPinViewModel.lock();
}
@override
@@ -263,184 +265,189 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
final pinLockViewModel = context.watch<PinLockViewModel>();
final adminPinViewModel = context.watch<AdminPinViewModel>();
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<AdminPinViewModel>().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),
],
);
},
),
),
);
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+43 -29
View File
@@ -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);
});
+10
View File
@@ -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);
});
}
+17
View File
@@ -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);
});
});
});
}
+87
View File
@@ -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<ClosedTabItem> 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);
});
});
}
+156
View File
@@ -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<void> openDialog(WidgetTester tester, BarTab tab) async {
await adminPinViewModel.load();
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<BarScreenViewModel>(
create: (_) => BarScreenViewModel(barTabService: barTabService),
),
ChangeNotifierProvider<AdminPinViewModel>.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<void> 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);
});
}
+128
View File
@@ -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<http.Request> 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<String, dynamic>;
final closed = (body['closedTabs'] as List).single as Map<String, dynamic>;
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<int>(
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);
});
}
+45
View File
@@ -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);
}
});
});
}
+6 -3
View File
@@ -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);
});
});
+40
View File
@@ -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);
+390
View File
@@ -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<void> 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<int> stockOf(String productId) async {
return (await products.getProductById(productId))!.stockQuantity;
}
Future<int> quantityOnOpenTabs(String productId) async {
final open = await tabs.getOpenTabs();
return open
.expand((tab) => tab.items)
.where((item) => item.productId == productId)
.fold<int>(0, (sum, item) => sum + item.quantity);
}
Future<int> soldInHistory(String productId) async {
final closed = await tabs.getClosedTabs();
return closed
.expand((tab) => tab.items)
.where((item) => item.productId == productId && !item.isRemoved)
.fold<int>(0, (sum, item) => sum + item.quantity);
}
Future<String> 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<TabVersionConflictException>()),
);
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<InsufficientStockException>()),
);
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<InsufficientStockException>()),
);
final open = (await tabs.getTabById(tab.id))!;
expect(open.items, isEmpty);
expect(open.totalInCents, 0);
});
});
}