fix: alot
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
<application
|
<application
|
||||||
android:label="KoolTap"
|
android:label="KoolTap"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/launcher_icon">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../database/app_database.dart';
|
|||||||
import '../services/bar_tab_service.dart';
|
import '../services/bar_tab_service.dart';
|
||||||
import '../services/default_product_seeder.dart';
|
import '../services/default_product_seeder.dart';
|
||||||
import '../services/export_service.dart';
|
import '../services/export_service.dart';
|
||||||
|
import '../services/remote_sync_service.dart';
|
||||||
import '../viewmodels/bar_screen_view_model.dart';
|
import '../viewmodels/bar_screen_view_model.dart';
|
||||||
import '../viewmodels/product_list_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<ProductListViewModel>().ensureLoaded();
|
||||||
context.read<BarScreenViewModel>().ensureLoaded();
|
context.read<BarScreenViewModel>().ensureLoaded();
|
||||||
|
|
||||||
|
final syncService = context.read<RemoteSyncService>();
|
||||||
unawaited(
|
unawaited(
|
||||||
DefaultExportService(
|
DefaultExportService(
|
||||||
barTabService: context.read<BarTabService>(),
|
barTabService: context.read<BarTabService>(),
|
||||||
|
authToken: () => syncService.authToken,
|
||||||
).uploadToServer(),
|
).uploadToServer(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
||||||
final VoidCallback onLock;
|
final VoidCallback onLock;
|
||||||
final VoidCallback? onBackground;
|
final VoidCallback? onBackground;
|
||||||
final VoidCallback? onForeground;
|
final VoidCallback? onForeground;
|
||||||
final bool Function()? shouldLock;
|
final bool Function()? shouldLock;
|
||||||
|
final Duration gracePeriod;
|
||||||
|
final DateTime Function() _clock;
|
||||||
|
|
||||||
|
DateTime? _backgroundedAt;
|
||||||
|
|
||||||
AppLifecycleLockObserver({
|
AppLifecycleLockObserver({
|
||||||
required this.onLock,
|
required this.onLock,
|
||||||
this.onBackground,
|
this.onBackground,
|
||||||
this.onForeground,
|
this.onForeground,
|
||||||
this.shouldLock,
|
this.shouldLock,
|
||||||
});
|
this.gracePeriod = const Duration(seconds: 30),
|
||||||
|
DateTime Function()? clock,
|
||||||
|
}) : _clock = clock ?? DateTime.now;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
|
break;
|
||||||
case AppLifecycleState.hidden:
|
case AppLifecycleState.hidden:
|
||||||
case AppLifecycleState.paused:
|
case AppLifecycleState.paused:
|
||||||
case AppLifecycleState.detached:
|
case AppLifecycleState.detached:
|
||||||
if (shouldLock?.call() ?? true) onLock();
|
if (_backgroundedAt != null) return;
|
||||||
|
_backgroundedAt = _clock();
|
||||||
onBackground?.call();
|
onBackground?.call();
|
||||||
case AppLifecycleState.resumed:
|
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();
|
onForeground?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-24
@@ -12,7 +12,6 @@ import '../views/bar/bar_screen_view.dart';
|
|||||||
import '../views/dev_menu_view.dart';
|
import '../views/dev_menu_view.dart';
|
||||||
import '../views/error_screen_view.dart';
|
import '../views/error_screen_view.dart';
|
||||||
import '../views/history_screen_view.dart';
|
import '../views/history_screen_view.dart';
|
||||||
import '../views/import_names_view.dart';
|
|
||||||
import '../views/pin_lock_view.dart';
|
import '../views/pin_lock_view.dart';
|
||||||
import '../views/product_form_view.dart';
|
import '../views/product_form_view.dart';
|
||||||
import '../views/product_list_view.dart';
|
import '../views/product_list_view.dart';
|
||||||
@@ -35,24 +34,18 @@ String? _redirectApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
final location = state.uri.path;
|
final location = state.uri.path;
|
||||||
final isConfigured = remoteSyncViewModel.isConfigured;
|
final returnTo = _returnLocation(state);
|
||||||
|
|
||||||
if (!isConfigured && location != '/register') {
|
if (remoteSyncViewModel.isConfigured && location == '/register') {
|
||||||
return '/register';
|
return '/admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isConfigured && location == '/register') {
|
|
||||||
return '/bar';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConfigured) return null;
|
|
||||||
|
|
||||||
final goingToLock = location == '/lock';
|
final goingToLock = location == '/lock';
|
||||||
final goingToAdminLock = location == '/admin/lock';
|
final goingToAdminLock = location == '/admin/lock';
|
||||||
final mustBeOnLock = _mustBeOnLock(pinLockViewModel);
|
final mustBeOnLock = _mustBeOnLock(pinLockViewModel);
|
||||||
|
|
||||||
if (mustBeOnLock && !goingToLock) return '/lock';
|
if (mustBeOnLock && !goingToLock) return _lockLocation('/lock', state);
|
||||||
if (!mustBeOnLock && goingToLock) return '/bar';
|
if (!mustBeOnLock && goingToLock) return returnTo ?? '/bar';
|
||||||
|
|
||||||
final isAdminArea = _isAdminArea(location);
|
final isAdminArea = _isAdminArea(location);
|
||||||
final isDebugShortcutArea = _isDebugShortcutArea(location);
|
final isDebugShortcutArea = _isDebugShortcutArea(location);
|
||||||
@@ -61,14 +54,34 @@ String? _redirectApp({
|
|||||||
!isDebugShortcutArea &&
|
!isDebugShortcutArea &&
|
||||||
!goingToAdminLock &&
|
!goingToAdminLock &&
|
||||||
!adminPinViewModel.isUnlocked) {
|
!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;
|
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) {
|
bool _mustBeOnLock(PinLockViewModel pinLockViewModel) {
|
||||||
final needsPinSetup =
|
final needsPinSetup =
|
||||||
!pinLockViewModel.isPinSet && pinLockViewModel.pinRequired;
|
!pinLockViewModel.isPinSet && pinLockViewModel.pinRequired;
|
||||||
@@ -83,6 +96,9 @@ bool _mustBeOnLock(PinLockViewModel pinLockViewModel) {
|
|||||||
bool _isAdminArea(String location) {
|
bool _isAdminArea(String location) {
|
||||||
return location == '/admin' ||
|
return location == '/admin' ||
|
||||||
location.startsWith('/admin/') ||
|
location.startsWith('/admin/') ||
|
||||||
|
location == '/settings' ||
|
||||||
|
location == '/dev' ||
|
||||||
|
location == '/register' ||
|
||||||
location == '/products' ||
|
location == '/products' ||
|
||||||
location.startsWith('/products/') ||
|
location.startsWith('/products/') ||
|
||||||
location == '/history' ||
|
location == '/history' ||
|
||||||
@@ -135,9 +151,7 @@ GoRouter createAppRouter({
|
|||||||
mode: pinLockViewModel.isPinSet
|
mode: pinLockViewModel.isPinSet
|
||||||
? PinEntryMode.unlock
|
? PinEntryMode.unlock
|
||||||
: PinEntryMode.create,
|
: PinEntryMode.create,
|
||||||
onSuccess: () {
|
onSuccess: () => context.go(_returnLocation(state) ?? '/bar'),
|
||||||
context.go('/bar');
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -153,19 +167,13 @@ GoRouter createAppRouter({
|
|||||||
onVerify: adminPinViewModel.verify,
|
onVerify: adminPinViewModel.verify,
|
||||||
onSet: adminPinViewModel.setPin,
|
onSet: adminPinViewModel.setPin,
|
||||||
error: () => adminPinViewModel.error,
|
error: () => adminPinViewModel.error,
|
||||||
onSuccess: () => context.go('/admin'),
|
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
builder: (context, state) => const AdminScreenView(),
|
builder: (context, state) => const AdminScreenView(),
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: 'import',
|
|
||||||
builder: (context, state) => const ImportNamesView(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
+33
-1
@@ -282,5 +282,37 @@
|
|||||||
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||||
"notSyncedYet": "Not synced yet",
|
"notSyncedYet": "Not synced yet",
|
||||||
"tabClosedRemotely": "Tab {customer} closed remotely by {requester}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1669,6 +1669,192 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Tab {customer} closed remotely by {requester}'**
|
/// **'Tab {customer} closed remotely by {requester}'**
|
||||||
String tabClosedRemotely(String customer, String 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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -864,4 +864,102 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String tabClosedRemotely(String customer, String requester) {
|
String tabClosedRemotely(String customer, String requester) {
|
||||||
return 'Tab $customer closed remotely by $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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -876,4 +876,103 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String tabClosedRemotely(String customer, String requester) {
|
String tabClosedRemotely(String customer, String requester) {
|
||||||
return 'Poef $customer extern gesloten door $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
@@ -282,5 +282,36 @@
|
|||||||
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||||
"notSyncedYet": "Nog niet gesynchroniseerd",
|
"notSyncedYet": "Nog niet gesynchroniseerd",
|
||||||
"tabClosedRemotely": "Poef {customer} extern gesloten door {requester}",
|
"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
@@ -63,7 +63,7 @@ Future<void> _runApp() async {
|
|||||||
final productService = DriftProductService(database: database);
|
final productService = DriftProductService(database: database);
|
||||||
final barTabService = DriftBarTabService(database: database);
|
final barTabService = DriftBarTabService(database: database);
|
||||||
final settingsService = DriftSettingsService(database: database);
|
final settingsService = DriftSettingsService(database: database);
|
||||||
final pinLockService = PinLockService();
|
final pinLockService = PinLockService(maxLockout: const Duration(minutes: 1));
|
||||||
final apiClient = KoolTapApiClient();
|
final apiClient = KoolTapApiClient();
|
||||||
|
|
||||||
final remoteSyncService = RemoteSyncService(
|
final remoteSyncService = RemoteSyncService(
|
||||||
|
|||||||
@@ -19,22 +19,34 @@ abstract class ExportService {
|
|||||||
/// failed backup is logged but never interrupts the bar.
|
/// failed backup is logged but never interrupts the bar.
|
||||||
class DefaultExportService implements ExportService {
|
class DefaultExportService implements ExportService {
|
||||||
final BarTabService barTabService;
|
final BarTabService barTabService;
|
||||||
|
final Future<String?> Function()? authToken;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
||||||
DefaultExportService({required this.barTabService, http.Client? client})
|
DefaultExportService({
|
||||||
: _client = client ?? http.Client();
|
required this.barTabService,
|
||||||
|
this.authToken,
|
||||||
|
http.Client? client,
|
||||||
|
}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
|
static const _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> uploadToServer() async {
|
Future<void> uploadToServer() async {
|
||||||
try {
|
try {
|
||||||
final closedTabs = await barTabService.getClosedTabs();
|
final closedTabs = await barTabService.getClosedTabs();
|
||||||
final appVersion = (await PackageInfo.fromPlatform()).version;
|
final appVersion = (await PackageInfo.fromPlatform()).version;
|
||||||
|
final token = await authToken?.call();
|
||||||
|
|
||||||
final response = await _client.post(
|
final response = await _client
|
||||||
Uri.parse(kExportServerUrl),
|
.post(
|
||||||
headers: {'Content-Type': 'application/json'},
|
Uri.parse(kExportServerUrl),
|
||||||
body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)),
|
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) {
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PinLockService {
|
|||||||
this._saltKey = _defaultSaltKey,
|
this._saltKey = _defaultSaltKey,
|
||||||
this._hashKey = _defaultHashKey,
|
this._hashKey = _defaultHashKey,
|
||||||
DateTime Function()? clock,
|
DateTime Function()? clock,
|
||||||
|
this.maxLockout = const Duration(minutes: 15),
|
||||||
}) : _storage = storage ?? const FlutterSecureStorage(),
|
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||||
_clock = clock ?? DateTime.now;
|
_clock = clock ?? DateTime.now;
|
||||||
|
|
||||||
@@ -31,13 +32,15 @@ class PinLockService {
|
|||||||
|
|
||||||
static const _freeAttempts = 5;
|
static const _freeAttempts = 5;
|
||||||
static const _baseLockout = Duration(seconds: 30);
|
static const _baseLockout = Duration(seconds: 30);
|
||||||
static const _maxLockout = Duration(minutes: 15);
|
|
||||||
|
|
||||||
final FlutterSecureStorage _storage;
|
final FlutterSecureStorage _storage;
|
||||||
final String _saltKey;
|
final String _saltKey;
|
||||||
final String _hashKey;
|
final String _hashKey;
|
||||||
final DateTime Function() _clock;
|
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 _failuresKey => '${_hashKey}_failures';
|
||||||
String get _lockedUntilKey => '${_hashKey}_locked_until';
|
String get _lockedUntilKey => '${_hashKey}_locked_until';
|
||||||
|
|
||||||
@@ -113,7 +116,7 @@ class PinLockService {
|
|||||||
|
|
||||||
final exponent = min(failures - _freeAttempts, 10);
|
final exponent = min(failures - _freeAttempts, 10);
|
||||||
final lockout = _baseLockout * (1 << exponent);
|
final lockout = _baseLockout * (1 << exponent);
|
||||||
final capped = lockout > _maxLockout ? _maxLockout : lockout;
|
final capped = lockout > maxLockout ? maxLockout : lockout;
|
||||||
|
|
||||||
await _storage.write(
|
await _storage.write(
|
||||||
key: _lockedUntilKey,
|
key: _lockedUntilKey,
|
||||||
|
|||||||
@@ -130,12 +130,13 @@ class DriftProductService implements ProductService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only hidden, since open tabs and history still refer to it.
|
||||||
@override
|
@override
|
||||||
Future<void> deleteProduct(String id) async {
|
Future<void> deleteProduct(String id) async {
|
||||||
final query = database.delete(database.products)
|
final query = database.update(database.products)
|
||||||
..where((product) => product.id.equals(id));
|
..where((product) => product.id.equals(id));
|
||||||
|
|
||||||
await query.go();
|
await query.write(const ProductsCompanion(active: Value(false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
@@ -65,20 +67,29 @@ class RemoteSyncService {
|
|||||||
DateTime? _firstPendingChange;
|
DateTime? _firstPendingChange;
|
||||||
bool _syncQueued = false;
|
bool _syncQueued = false;
|
||||||
|
|
||||||
|
_Credentials? _cachedCredentials;
|
||||||
|
|
||||||
Future<String?> get venueId => secureStorage.read(key: _venueIdKey);
|
Future<String?> get venueId => secureStorage.read(key: _venueIdKey);
|
||||||
|
|
||||||
Future<bool> get isConfigured async {
|
Future<bool> get isConfigured async => await _credentials() != null;
|
||||||
final token = await secureStorage.read(key: _authTokenKey);
|
|
||||||
final venueId = await this.venueId;
|
Future<String?> get authToken async => (await _credentials())?.token;
|
||||||
return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<_Credentials?> _credentials() async {
|
Future<_Credentials?> _credentials() async {
|
||||||
|
if (_cachedCredentials != null) return _cachedCredentials;
|
||||||
|
|
||||||
final deviceId = await secureStorage.read(key: _deviceIdKey);
|
final deviceId = await secureStorage.read(key: _deviceIdKey);
|
||||||
final token = await secureStorage.read(key: _authTokenKey);
|
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() {
|
void startPeriodicSync() {
|
||||||
@@ -109,8 +120,6 @@ class RemoteSyncService {
|
|||||||
|
|
||||||
void resume() {
|
void resume() {
|
||||||
_paused = false;
|
_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
|
/// Pushes tab changes to the server soon after they happen instead of
|
||||||
@@ -164,7 +173,7 @@ class RemoteSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final credentials = await _credentials();
|
final credentials = await _credentials();
|
||||||
if (credentials == null || !await isConfigured) return;
|
if (credentials == null) return;
|
||||||
|
|
||||||
_isSyncing = true;
|
_isSyncing = true;
|
||||||
Duration nextDelay;
|
Duration nextDelay;
|
||||||
@@ -176,6 +185,7 @@ class RemoteSyncService {
|
|||||||
_emit(RemoteSyncEventType.syncCompleted);
|
_emit(RemoteSyncEventType.syncCompleted);
|
||||||
} on UnauthorizedException {
|
} on UnauthorizedException {
|
||||||
debugPrint('RemoteSyncService: token revoked');
|
debugPrint('RemoteSyncService: token revoked');
|
||||||
|
_cachedCredentials = null;
|
||||||
await secureStorage.delete(key: _authTokenKey);
|
await secureStorage.delete(key: _authTokenKey);
|
||||||
stopPeriodicSync();
|
stopPeriodicSync();
|
||||||
_emit(RemoteSyncEventType.unauthorized);
|
_emit(RemoteSyncEventType.unauthorized);
|
||||||
@@ -183,7 +193,9 @@ class RemoteSyncService {
|
|||||||
} on RateLimitedException catch (e) {
|
} on RateLimitedException catch (e) {
|
||||||
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
|
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
if (!_isConnectivityError(e)) {
|
||||||
|
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||||
|
}
|
||||||
nextDelay = await _recordFailure(e);
|
nextDelay = await _recordFailure(e);
|
||||||
} finally {
|
} finally {
|
||||||
_isSyncing = false;
|
_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 {
|
Future<Duration> _recordFailure(Object error, {Duration? retryAfter}) async {
|
||||||
debugPrint('RemoteSyncService: sync failed: $error');
|
debugPrint('RemoteSyncService: sync failed: $error');
|
||||||
_consecutiveFailures++;
|
_consecutiveFailures++;
|
||||||
@@ -391,9 +410,11 @@ class RemoteSyncService {
|
|||||||
await secureStorage.write(key: _deviceIdKey, value: deviceId);
|
await secureStorage.write(key: _deviceIdKey, value: deviceId);
|
||||||
await secureStorage.write(key: _authTokenKey, value: token);
|
await secureStorage.write(key: _authTokenKey, value: token);
|
||||||
await secureStorage.write(key: _venueIdKey, value: venueId);
|
await secureStorage.write(key: _venueIdKey, value: venueId);
|
||||||
|
_cachedCredentials = (deviceId: deviceId, token: token);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearCredentials() async {
|
Future<void> clearCredentials() async {
|
||||||
|
_cachedCredentials = null;
|
||||||
await secureStorage.delete(key: _deviceIdKey);
|
await secureStorage.delete(key: _deviceIdKey);
|
||||||
await secureStorage.delete(key: _authTokenKey);
|
await secureStorage.delete(key: _authTokenKey);
|
||||||
await secureStorage.delete(key: _venueIdKey);
|
await secureStorage.delete(key: _venueIdKey);
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ class AppUpdateUtil {
|
|||||||
|
|
||||||
final url = Uri.parse('$serverUrl/api/update?version=$version');
|
final url = Uri.parse('$serverUrl/api/update?version=$version');
|
||||||
|
|
||||||
final response = await http.get(url);
|
final response = await http.get(url).timeout(const Duration(seconds: 15));
|
||||||
|
|
||||||
debugPrint('Response from update server: ${response.body}');
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Update server unavailable');
|
throw Exception('Update server unavailable');
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
final _euro = NumberFormat.currency(locale: 'nl_BE', symbol: '€');
|
final _euro = NumberFormat.currency(locale: 'nl_BE', symbol: '€');
|
||||||
|
|
||||||
|
final _euroInput = RegExp(r'^(\d{1,6})(?:[.,](\d{1,2}))?$');
|
||||||
|
|
||||||
extension Cents on int {
|
extension Cents on int {
|
||||||
String get asEuro => _euro.format(this / 100);
|
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 =>
|
BarTab? get selectedTab =>
|
||||||
_tabs.firstWhereOrNull((tab) => tab.id == _selectedTabId);
|
_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() {
|
void ensureLoaded() {
|
||||||
if (_subscription != null) return;
|
if (_subscription != null) return;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
|||||||
bool _isConfigured = false;
|
bool _isConfigured = false;
|
||||||
bool get isConfigured => _isConfigured;
|
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? _lastSyncAt;
|
||||||
DateTime? get lastSyncAt => _lastSyncAt;
|
DateTime? get lastSyncAt => _lastSyncAt;
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
|||||||
/// Called once registration stored new credentials.
|
/// Called once registration stored new credentials.
|
||||||
Future<void> activate() async {
|
Future<void> activate() async {
|
||||||
await _refreshStatus();
|
await _refreshStatus();
|
||||||
|
if (_isConfigured) _wasRevoked = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
if (!_isConfigured) return;
|
if (!_isConfigured) return;
|
||||||
@@ -90,6 +96,7 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
|||||||
syncService.stopPeriodicSync();
|
syncService.stopPeriodicSync();
|
||||||
await syncService.clearCredentials();
|
await syncService.clearCredentials();
|
||||||
_isConfigured = false;
|
_isConfigured = false;
|
||||||
|
_wasRevoked = false;
|
||||||
_venueId = null;
|
_venueId = null;
|
||||||
_lastSyncAt = null;
|
_lastSyncAt = null;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
@@ -105,8 +112,8 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
|||||||
case RemoteSyncEventType.syncFailed:
|
case RemoteSyncEventType.syncFailed:
|
||||||
_lastError = event.message;
|
_lastError = event.message;
|
||||||
case RemoteSyncEventType.unauthorized:
|
case RemoteSyncEventType.unauthorized:
|
||||||
// The router sends the device back to registration.
|
|
||||||
_isConfigured = false;
|
_isConfigured = false;
|
||||||
|
_wasRevoked = true;
|
||||||
case RemoteSyncEventType.tabClosedRemotely:
|
case RemoteSyncEventType.tabClosedRemotely:
|
||||||
case RemoteSyncEventType.conflict:
|
case RemoteSyncEventType.conflict:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -33,11 +33,8 @@ class AdminScreenView extends StatelessWidget {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Disconnect from server'),
|
title: Text(l10n.disconnectFromServer),
|
||||||
content: const Text(
|
content: Text(l10n.disconnectFromServerDescription),
|
||||||
'This will unlink the device from the remote server. '
|
|
||||||
'Open tabs will no longer be synced. Continue?',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
@@ -46,7 +43,7 @@ class AdminScreenView extends StatelessWidget {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
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,
|
subtitle: l10n.closedTabs,
|
||||||
onTap: () => context.push('/history'),
|
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),
|
const SizedBox(height: 8),
|
||||||
_AdminSection(
|
_AdminSection(
|
||||||
title: 'Remote Server',
|
title: l10n.remoteServer,
|
||||||
children: [
|
children: [
|
||||||
if (remoteSync.isConfigured) ...[
|
if (remoteSync.isConfigured) ...[
|
||||||
_AdminTile(
|
_AdminTile(
|
||||||
icon: Icons.cloud_done_outlined,
|
icon: Icons.cloud_done_outlined,
|
||||||
title: 'Connected',
|
title: l10n.connected,
|
||||||
subtitle: 'Device linked to remote server',
|
subtitle: l10n.deviceLinked,
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: () => _confirmUnlink(context),
|
onPressed: () => _confirmUnlink(context),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: const Text('Disconnect'),
|
child: Text(l10n.disconnect),
|
||||||
),
|
),
|
||||||
onTap: null,
|
onTap: null,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
_AdminTile(
|
_AdminTile(
|
||||||
icon: Icons.cloud_outlined,
|
icon: remoteSync.wasRevoked
|
||||||
title: 'Not connected',
|
? Icons.cloud_off_rounded
|
||||||
subtitle: 'Link to remote dashboard',
|
: Icons.cloud_outlined,
|
||||||
|
title: l10n.notConnected,
|
||||||
|
subtitle: remoteSync.wasRevoked
|
||||||
|
? l10n.deviceAccessRevoked
|
||||||
|
: l10n.linkToDashboard,
|
||||||
onTap: () => context.push('/register'),
|
onTap: () => context.push('/register'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
location: '/history',
|
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),
|
const SizedBox(width: 6),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.refresh,
|
tooltip: l10n.refresh,
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ class _DevMenuViewState extends State<DevMenuView> {
|
|||||||
).showSnackBar(SnackBar(content: Text(l10n.devAction(message))));
|
).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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_vm?.removeListener(_onVmChange);
|
_vm?.removeListener(_onVmChange);
|
||||||
@@ -78,12 +108,19 @@ class _DevMenuViewState extends State<DevMenuView> {
|
|||||||
_Tile(
|
_Tile(
|
||||||
icon: Icons.delete_sweep_rounded,
|
icon: Icons.delete_sweep_rounded,
|
||||||
title: l10n.clearOpenTabs,
|
title: l10n.clearOpenTabs,
|
||||||
onTap: vm.isLoading ? null : () => vm.clearOpenTabs(),
|
onTap: vm.isLoading
|
||||||
|
? null
|
||||||
|
: () => _confirmThen(l10n.clearOpenTabs, vm.clearOpenTabs),
|
||||||
),
|
),
|
||||||
_Tile(
|
_Tile(
|
||||||
icon: Icons.history_rounded,
|
icon: Icons.history_rounded,
|
||||||
title: l10n.clearClosedHistory,
|
title: l10n.clearClosedHistory,
|
||||||
onTap: vm.isLoading ? null : () => vm.clearClosedTabHistory(),
|
onTap: vm.isLoading
|
||||||
|
? null
|
||||||
|
: () => _confirmThen(
|
||||||
|
l10n.clearClosedHistory,
|
||||||
|
vm.clearClosedTabHistory,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Tile(
|
_Tile(
|
||||||
icon: Icons.add_box_rounded,
|
icon: Icons.add_box_rounded,
|
||||||
@@ -95,18 +132,33 @@ class _DevMenuViewState extends State<DevMenuView> {
|
|||||||
icon: Icons.restart_alt_rounded,
|
icon: Icons.restart_alt_rounded,
|
||||||
title: l10n.clearAndReseedProducts,
|
title: l10n.clearAndReseedProducts,
|
||||||
subtitle: l10n.wipesAndReseeds,
|
subtitle: l10n.wipesAndReseeds,
|
||||||
onTap: vm.isLoading ? null : () => vm.clearAndReseedProducts(),
|
onTap: vm.isLoading
|
||||||
|
? null
|
||||||
|
: () => _confirmThen(
|
||||||
|
l10n.clearAndReseedProducts,
|
||||||
|
vm.clearAndReseedProducts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Tile(
|
_Tile(
|
||||||
icon: Icons.delete_forever_rounded,
|
icon: Icons.delete_forever_rounded,
|
||||||
title: l10n.clearAllProducts,
|
title: l10n.clearAllProducts,
|
||||||
onTap: vm.isLoading ? null : () => vm.clearAllProducts(),
|
onTap: vm.isLoading
|
||||||
|
? null
|
||||||
|
: () => _confirmThen(
|
||||||
|
l10n.clearAllProducts,
|
||||||
|
vm.clearAllProducts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Tile(
|
_Tile(
|
||||||
icon: Icons.image_rounded,
|
icon: Icons.image_rounded,
|
||||||
title: l10n.clearProductImages,
|
title: l10n.clearProductImages,
|
||||||
subtitle: l10n.deletesProductImages,
|
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,
|
icon: Icons.lock_reset_rounded,
|
||||||
title: l10n.resetPin,
|
title: l10n.resetPin,
|
||||||
subtitle: l10n.removePinLock,
|
subtitle: l10n.removePinLock,
|
||||||
onTap: vm.isLoading ? null : () => vm.resetPin(),
|
onTap: vm.isLoading
|
||||||
|
? null
|
||||||
|
: () => _confirmThen(l10n.resetPin, vm.resetPin),
|
||||||
),
|
),
|
||||||
_Tile(
|
_Tile(
|
||||||
icon: Icons.bug_report_rounded,
|
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;
|
||||||
|
}
|
||||||
@@ -2,8 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../l10n/app_localizations_helpers.dart';
|
||||||
|
import '../../models/app_error.dart';
|
||||||
import '../../models/bar_tab.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 '../../viewmodels/bar_screen_view_model.dart';
|
||||||
|
import 'duplicate_tab_name_dialog.dart';
|
||||||
|
|
||||||
Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
|
Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
@@ -13,10 +18,19 @@ Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
|
|||||||
builder: (_) => _EditTabDialog(initialName: tab.customerName),
|
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 {
|
try {
|
||||||
await context.read<BarScreenViewModel>().renameTab(tab.id, name.trim());
|
await viewModel.renameTab(tab.id, trimmed);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(
|
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 {
|
Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
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,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (_) => _DeleteTabDialog(
|
||||||
return AlertDialog(
|
description: tab.items.isEmpty
|
||||||
title: Text(l10n.deleteTab),
|
? l10n.deleteTabDescription
|
||||||
content: Text(l10n.deleteTabDescription),
|
: l10n.deleteTabWithItemsDescription,
|
||||||
actions: [
|
needsAdminPin: needsAdminPin,
|
||||||
TextButton(
|
),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
||||||
child: Text(l10n.cancel),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
||||||
child: Text(l10n.delete),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
await context.read<BarScreenViewModel>().deleteTab(tab.id);
|
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)));
|
).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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../viewmodels/bar_screen_view_model.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.
|
/// Returns the name of the opened tab, or null when nothing was opened.
|
||||||
Future<String?> showNewTabDialog(BuildContext context) async {
|
Future<String?> showNewTabDialog(BuildContext context) async {
|
||||||
@@ -42,8 +43,14 @@ Future<String?> showNewTabDialog(BuildContext context) async {
|
|||||||
final trimmed = name?.trim() ?? '';
|
final trimmed = name?.trim() ?? '';
|
||||||
if (trimmed.isEmpty || !context.mounted) return null;
|
if (trimmed.isEmpty || !context.mounted) return null;
|
||||||
|
|
||||||
|
final viewModel = context.read<BarScreenViewModel>();
|
||||||
|
if (viewModel.hasOpenTabNamed(trimmed) &&
|
||||||
|
!await confirmDuplicateTabName(context, trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context.read<BarScreenViewModel>().createTab(trimmed);
|
await viewModel.createTab(trimmed);
|
||||||
return trimmed;
|
return trimmed;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../l10n/app_localizations_helpers.dart';
|
import '../l10n/app_localizations_helpers.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
|
import '../utils/money.dart';
|
||||||
import '../utils/navigation.dart';
|
import '../utils/navigation.dart';
|
||||||
import '../viewmodels/product_list_view_model.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 {
|
Future<void> _save() async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
if (_imagePath == null || _imagePath!.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(l10n.chooseImage)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_selectedCategory == null) {
|
if (_selectedCategory == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
@@ -179,7 +166,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
|
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
final category = _selectedCategory!;
|
final category = _selectedCategory!;
|
||||||
final priceInCents = _parsePriceToCents(_priceController.text);
|
final priceInCents = parseEuroToCents(_priceController.text)!;
|
||||||
final stockQuantity = int.tryParse(_stockController.text) ?? 0;
|
final stockQuantity = int.tryParse(_stockController.text) ?? 0;
|
||||||
final lowStockThreshold = int.tryParse(_lowStockController.text) ?? 0;
|
final lowStockThreshold = int.tryParse(_lowStockController.text) ?? 0;
|
||||||
|
|
||||||
@@ -422,10 +409,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
return l10n.enterPrice;
|
return l10n.enterPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
final normalized = value.replaceAll(',', '.');
|
if (parseEuroToCents(value) == null) {
|
||||||
final price = double.tryParse(normalized);
|
|
||||||
|
|
||||||
if (price == null || price < 0) {
|
|
||||||
return l10n.enterValidPrice;
|
return l10n.enterValidPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/navigation.dart';
|
||||||
import '../viewmodels/registration_view_model.dart';
|
import '../viewmodels/registration_view_model.dart';
|
||||||
import '../viewmodels/remote_sync_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_deviceNameController.dispose();
|
_deviceNameController.dispose();
|
||||||
@@ -39,6 +42,14 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
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(
|
body: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -87,7 +98,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
const CircularProgressIndicator(strokeWidth: 3),
|
const CircularProgressIndicator(strokeWidth: 3),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Connecting to server…',
|
_l10n.connectingToServer,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -106,12 +117,12 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Failed to connect',
|
_l10n.failedToConnect,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
vm.errorMessage ?? 'Unknown error',
|
vm.errorMessage ?? _l10n.unknownError,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -123,7 +134,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => vm.retry(),
|
onPressed: () => vm.retry(),
|
||||||
icon: const Icon(Icons.refresh),
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Connect to remote server',
|
_l10n.connectToRemoteServer,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Select your venue to link this device',
|
_l10n.selectVenueToLink,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -158,7 +169,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Select venue',
|
_l10n.selectVenue,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -217,7 +228,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => vm.retry(),
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Enter device details to complete pairing',
|
_l10n.enterDeviceDetails,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -255,20 +266,19 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _deviceNameController,
|
controller: _deviceNameController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Device name',
|
labelText: _l10n.deviceName,
|
||||||
hintText: 'e.g. Bar terminal 1',
|
hintText: 'e.g. Bar terminal 1',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: (v) => v == null || v.trim().isEmpty
|
validator: (v) =>
|
||||||
? 'Device name is required'
|
v == null || v.trim().isEmpty ? _l10n.deviceNameRequired : null,
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _regCodeController,
|
controller: _regCodeController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Registration code',
|
labelText: _l10n.registrationCode,
|
||||||
hintText: '6-character code from dashboard',
|
hintText: '6-character code from dashboard',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
counterText: '',
|
counterText: '',
|
||||||
@@ -276,7 +286,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
maxLength: 6,
|
maxLength: 6,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
validator: (v) => v == null || v.trim().length < 6
|
validator: (v) => v == null || v.trim().length < 6
|
||||||
? 'Enter the 6-character code'
|
? _l10n.enterRegistrationCode
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -298,7 +308,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
vm.reset();
|
vm.reset();
|
||||||
},
|
},
|
||||||
child: const Text('Back'),
|
child: Text(_l10n.back),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -312,7 +322,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
.toUpperCase(),
|
.toUpperCase(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Register'),
|
child: Text(_l10n.register),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -330,7 +340,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
const CircularProgressIndicator(strokeWidth: 3),
|
const CircularProgressIndicator(strokeWidth: 3),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Registering device…',
|
_l10n.registeringDevice,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -349,7 +359,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Device registered!',
|
_l10n.deviceRegistered,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
@@ -365,7 +375,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Open tabs will sync to the dashboard.',
|
_l10n.openTabsWillSync,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -378,7 +388,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
|||||||
// Once configured, the router redirects to /bar by itself.
|
// Once configured, the router redirects to /bar by itself.
|
||||||
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
|
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
|
||||||
icon: const Icon(Icons.arrow_forward),
|
icon: const Icon(Icons.arrow_forward),
|
||||||
label: const Text('Start using KoolTap'),
|
label: Text(_l10n.done),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
+176
-169
@@ -53,6 +53,12 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _leave() {
|
||||||
|
final adminPinViewModel = context.read<AdminPinViewModel>();
|
||||||
|
context.popOrGo('/bar');
|
||||||
|
adminPinViewModel.lock();
|
||||||
|
}
|
||||||
|
|
||||||
void _cancelVersionHold() {
|
void _cancelVersionHold() {
|
||||||
_versionHoldTimer?.cancel();
|
_versionHoldTimer?.cancel();
|
||||||
_versionHoldTimer = null;
|
_versionHoldTimer = null;
|
||||||
@@ -225,7 +231,6 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPinViewModel.lock();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +255,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
_showError(l10n.errorText(adminPinViewModel.error));
|
_showError(l10n.errorText(adminPinViewModel.error));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPinViewModel.lock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -263,184 +265,189 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
final pinLockViewModel = context.watch<PinLockViewModel>();
|
final pinLockViewModel = context.watch<PinLockViewModel>();
|
||||||
final adminPinViewModel = context.watch<AdminPinViewModel>();
|
final adminPinViewModel = context.watch<AdminPinViewModel>();
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
leading: IconButton(
|
if (didPop) context.read<AdminPinViewModel>().lock();
|
||||||
onPressed: () => context.popOrGo('/bar'),
|
},
|
||||||
icon: const Icon(Icons.arrow_back),
|
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) {
|
||||||
body: Builder(
|
final isLoading =
|
||||||
builder: (context) {
|
settingsViewModel.isLoading ||
|
||||||
final isLoading =
|
pinLockViewModel.isLoading ||
|
||||||
settingsViewModel.isLoading ||
|
adminPinViewModel.isLoading;
|
||||||
pinLockViewModel.isLoading ||
|
final hasLoaded =
|
||||||
adminPinViewModel.isLoading;
|
settingsViewModel.hasLoaded &&
|
||||||
final hasLoaded =
|
pinLockViewModel.hasLoaded &&
|
||||||
settingsViewModel.hasLoaded &&
|
adminPinViewModel.hasLoaded;
|
||||||
pinLockViewModel.hasLoaded &&
|
|
||||||
adminPinViewModel.hasLoaded;
|
|
||||||
|
|
||||||
if (isLoading && !hasLoaded) {
|
if (isLoading && !hasLoaded) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final settings = settingsViewModel.settings;
|
final settings = settingsViewModel.settings;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
children: [
|
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)
|
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: l10n.updates,
|
title: l10n.security,
|
||||||
children: [
|
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(
|
_SettingsTile(
|
||||||
icon: Icons.system_update_alt_rounded,
|
icon: Icons.admin_panel_settings_outlined,
|
||||||
title: settingsViewModel.checkingForUpdates
|
title: l10n.adminPin,
|
||||||
? l10n.checkingForUpdates
|
subtitle: adminPinViewModel.isPinSet
|
||||||
: l10n.checkForUpdates,
|
? l10n.adminPinConfigured
|
||||||
onTap: settingsViewModel.checkingForUpdates
|
: l10n.adminPinNotSet,
|
||||||
? null
|
onTap: _configureAdminPin,
|
||||||
: _checkForUpdates,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
_SettingsSection(
|
||||||
GestureDetector(
|
title: l10n.appearance,
|
||||||
onTapDown: (_) => _startVersionHold(),
|
children: [
|
||||||
onTapUp: (_) => _cancelVersionHold(),
|
_SettingsTile(
|
||||||
onTapCancel: _cancelVersionHold,
|
icon: Icons.language_rounded,
|
||||||
child: Center(
|
title: l10n.language,
|
||||||
child: Text(
|
subtitle: switch (settings.language) {
|
||||||
l10n.version(_appVersion),
|
AppLanguage.system => l10n.languageSystem,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
AppLanguage.english => l10n.languageEnglish,
|
||||||
color: Theme.of(
|
AppLanguage.dutch => l10n.languageDutch,
|
||||||
context,
|
},
|
||||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
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
@@ -106,7 +106,7 @@ Write-Host "Uploading $version to $updateServer..."
|
|||||||
|
|
||||||
Add-Type -AssemblyName System.Net.Http
|
Add-Type -AssemblyName System.Net.Http
|
||||||
$client = [Net.Http.HttpClient]::new()
|
$client = [Net.Http.HttpClient]::new()
|
||||||
$client.Timeout = [TimeSpan]::FromMinutes(5)
|
$client.Timeout = [TimeSpan]::FromMinutes(10)
|
||||||
$client.DefaultRequestHeaders.Add('X-Admin-Token', $token)
|
$client.DefaultRequestHeaders.Add('X-Admin-Token', $token)
|
||||||
|
|
||||||
$apk = [IO.File]::OpenRead($apkPath)
|
$apk = [IO.File]::OpenRead($apkPath)
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: kooltap
|
|||||||
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.2.10+0
|
version: 1.2.13+0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@@ -3,46 +3,60 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:kooltap/app/app_lifecycle_lock.dart';
|
import 'package:kooltap/app/app_lifecycle_lock.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('locks when the app is no longer active', () {
|
late DateTime now;
|
||||||
var lockCount = 0;
|
late int lockCount;
|
||||||
final observer = AppLifecycleLockObserver(onLock: () => lockCount++);
|
|
||||||
|
|
||||||
for (final state in [
|
AppLifecycleLockObserver observer({bool Function()? shouldLock}) {
|
||||||
AppLifecycleState.inactive,
|
return AppLifecycleLockObserver(
|
||||||
AppLifecycleState.hidden,
|
onLock: () => lockCount++,
|
||||||
AppLifecycleState.paused,
|
shouldLock: shouldLock,
|
||||||
AppLifecycleState.detached,
|
clock: () => now,
|
||||||
]) {
|
);
|
||||||
observer.didChangeAppLifecycleState(state);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
expect(lockCount, 4);
|
setUp(() {
|
||||||
|
now = DateTime(2026, 1, 1, 20);
|
||||||
|
lockCount = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not lock while the app is active', () {
|
test('locks when the app returns after the grace period', () {
|
||||||
var lockCount = 0;
|
final lock = observer();
|
||||||
final observer = AppLifecycleLockObserver(onLock: () => lockCount++);
|
|
||||||
|
|
||||||
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);
|
expect(lockCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not lock when auto-lock is disabled', () {
|
test('does not lock when auto-lock is disabled', () {
|
||||||
var lockCount = 0;
|
final lock = observer(shouldLock: () => false);
|
||||||
final observer = AppLifecycleLockObserver(
|
|
||||||
onLock: () => lockCount++,
|
|
||||||
shouldLock: () => false,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final state in [
|
lock.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||||
AppLifecycleState.inactive,
|
now = now.add(const Duration(minutes: 5));
|
||||||
AppLifecycleState.hidden,
|
lock.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
AppLifecycleState.paused,
|
|
||||||
AppLifecycleState.detached,
|
|
||||||
]) {
|
|
||||||
observer.didChangeAppLifecycleState(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(lockCount, 0);
|
expect(lockCount, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,4 +131,14 @@ void main() {
|
|||||||
expect(viewModel.loadError, isNull);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -565,5 +565,22 @@ void main() {
|
|||||||
expect(closedTabs.last.customerName, 'First');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,4 +19,49 @@ void main() {
|
|||||||
expect(123456.asEuro, '€ 1.234,56');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('deleteProduct', () {
|
group('deleteProduct', () {
|
||||||
test('removes product from database', () async {
|
test('hides the product but keeps its row', () async {
|
||||||
await database
|
await database
|
||||||
.into(database.products)
|
.into(database.products)
|
||||||
.insert(
|
.insert(
|
||||||
@@ -187,8 +187,11 @@ void main() {
|
|||||||
|
|
||||||
await service.deleteProduct('delete-test');
|
await service.deleteProduct('delete-test');
|
||||||
|
|
||||||
final products = await database.select(database.products).get();
|
expect(await service.getProducts(), isEmpty);
|
||||||
expect(products, isEmpty);
|
final row = await service.getProductById('delete-test');
|
||||||
|
expect(row!.active, isFalse);
|
||||||
|
|
||||||
|
await service.increaseStock('delete-test', 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,21 @@ void main() {
|
|||||||
expect(server.syncRequests.last['cursor'], 'cursor-1');
|
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 {
|
test('reports a server error as a failure, not a success', () async {
|
||||||
server.syncResponse = http.Response('oops', 500);
|
server.syncResponse = http.Response('oops', 500);
|
||||||
|
|
||||||
@@ -222,6 +237,31 @@ void main() {
|
|||||||
expect(eventTypes(), contains(RemoteSyncEventType.tabClosedRemotely));
|
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 {
|
test('refuses to close a tab that changed since the request', () async {
|
||||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user