fix: alot
This commit is contained in:
@@ -8,6 +8,7 @@ import '../database/app_database.dart';
|
||||
import '../services/bar_tab_service.dart';
|
||||
import '../services/default_product_seeder.dart';
|
||||
import '../services/export_service.dart';
|
||||
import '../services/remote_sync_service.dart';
|
||||
import '../viewmodels/bar_screen_view_model.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
|
||||
@@ -38,9 +39,11 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
||||
context.read<ProductListViewModel>().ensureLoaded();
|
||||
context.read<BarScreenViewModel>().ensureLoaded();
|
||||
|
||||
final syncService = context.read<RemoteSyncService>();
|
||||
unawaited(
|
||||
DefaultExportService(
|
||||
barTabService: context.read<BarTabService>(),
|
||||
authToken: () => syncService.authToken,
|
||||
).uploadToServer(),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Locks once the app returns after being in the background for at least
|
||||
/// [gracePeriod], so a quick trip to the image picker or installer doesn't.
|
||||
class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
||||
final VoidCallback onLock;
|
||||
final VoidCallback? onBackground;
|
||||
final VoidCallback? onForeground;
|
||||
final bool Function()? shouldLock;
|
||||
final Duration gracePeriod;
|
||||
final DateTime Function() _clock;
|
||||
|
||||
DateTime? _backgroundedAt;
|
||||
|
||||
AppLifecycleLockObserver({
|
||||
required this.onLock,
|
||||
this.onBackground,
|
||||
this.onForeground,
|
||||
this.shouldLock,
|
||||
});
|
||||
this.gracePeriod = const Duration(seconds: 30),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.inactive:
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
if (shouldLock?.call() ?? true) onLock();
|
||||
if (_backgroundedAt != null) return;
|
||||
_backgroundedAt = _clock();
|
||||
onBackground?.call();
|
||||
case AppLifecycleState.resumed:
|
||||
final backgroundedAt = _backgroundedAt;
|
||||
if (backgroundedAt == null) return;
|
||||
_backgroundedAt = null;
|
||||
|
||||
final away = _clock().difference(backgroundedAt);
|
||||
if (away >= gracePeriod && (shouldLock?.call() ?? true)) onLock();
|
||||
onForeground?.call();
|
||||
}
|
||||
}
|
||||
|
||||
+32
-24
@@ -12,7 +12,6 @@ import '../views/bar/bar_screen_view.dart';
|
||||
import '../views/dev_menu_view.dart';
|
||||
import '../views/error_screen_view.dart';
|
||||
import '../views/history_screen_view.dart';
|
||||
import '../views/import_names_view.dart';
|
||||
import '../views/pin_lock_view.dart';
|
||||
import '../views/product_form_view.dart';
|
||||
import '../views/product_list_view.dart';
|
||||
@@ -35,24 +34,18 @@ String? _redirectApp({
|
||||
}
|
||||
|
||||
final location = state.uri.path;
|
||||
final isConfigured = remoteSyncViewModel.isConfigured;
|
||||
final returnTo = _returnLocation(state);
|
||||
|
||||
if (!isConfigured && location != '/register') {
|
||||
return '/register';
|
||||
if (remoteSyncViewModel.isConfigured && location == '/register') {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
if (isConfigured && location == '/register') {
|
||||
return '/bar';
|
||||
}
|
||||
|
||||
if (!isConfigured) return null;
|
||||
|
||||
final goingToLock = location == '/lock';
|
||||
final goingToAdminLock = location == '/admin/lock';
|
||||
final mustBeOnLock = _mustBeOnLock(pinLockViewModel);
|
||||
|
||||
if (mustBeOnLock && !goingToLock) return '/lock';
|
||||
if (!mustBeOnLock && goingToLock) return '/bar';
|
||||
if (mustBeOnLock && !goingToLock) return _lockLocation('/lock', state);
|
||||
if (!mustBeOnLock && goingToLock) return returnTo ?? '/bar';
|
||||
|
||||
final isAdminArea = _isAdminArea(location);
|
||||
final isDebugShortcutArea = _isDebugShortcutArea(location);
|
||||
@@ -61,14 +54,34 @@ String? _redirectApp({
|
||||
!isDebugShortcutArea &&
|
||||
!goingToAdminLock &&
|
||||
!adminPinViewModel.isUnlocked) {
|
||||
return '/admin/lock';
|
||||
return _lockLocation('/admin/lock', state);
|
||||
}
|
||||
|
||||
if (goingToAdminLock && adminPinViewModel.isUnlocked) return '/admin';
|
||||
if (goingToAdminLock && adminPinViewModel.isUnlocked) {
|
||||
return returnTo ?? '/admin';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Sends the user back where they were headed once the PIN is entered.
|
||||
String _lockLocation(String lockPath, GoRouterState state) {
|
||||
return Uri(
|
||||
path: lockPath,
|
||||
queryParameters: {'from': state.uri.toString()},
|
||||
).toString();
|
||||
}
|
||||
|
||||
String? _returnLocation(GoRouterState state) {
|
||||
final from = state.uri.queryParameters['from'];
|
||||
if (from == null || !from.startsWith('/')) return null;
|
||||
|
||||
final path = Uri.parse(from).path;
|
||||
if (path == '/lock' || path == '/admin/lock') return null;
|
||||
|
||||
return from;
|
||||
}
|
||||
|
||||
bool _mustBeOnLock(PinLockViewModel pinLockViewModel) {
|
||||
final needsPinSetup =
|
||||
!pinLockViewModel.isPinSet && pinLockViewModel.pinRequired;
|
||||
@@ -83,6 +96,9 @@ bool _mustBeOnLock(PinLockViewModel pinLockViewModel) {
|
||||
bool _isAdminArea(String location) {
|
||||
return location == '/admin' ||
|
||||
location.startsWith('/admin/') ||
|
||||
location == '/settings' ||
|
||||
location == '/dev' ||
|
||||
location == '/register' ||
|
||||
location == '/products' ||
|
||||
location.startsWith('/products/') ||
|
||||
location == '/history' ||
|
||||
@@ -135,9 +151,7 @@ GoRouter createAppRouter({
|
||||
mode: pinLockViewModel.isPinSet
|
||||
? PinEntryMode.unlock
|
||||
: PinEntryMode.create,
|
||||
onSuccess: () {
|
||||
context.go('/bar');
|
||||
},
|
||||
onSuccess: () => context.go(_returnLocation(state) ?? '/bar'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -153,19 +167,13 @@ GoRouter createAppRouter({
|
||||
onVerify: adminPinViewModel.verify,
|
||||
onSet: adminPinViewModel.setPin,
|
||||
error: () => adminPinViewModel.error,
|
||||
onSuccess: () => context.go('/admin'),
|
||||
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
|
||||
),
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/admin',
|
||||
builder: (context, state) => const AdminScreenView(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'import',
|
||||
builder: (context, state) => const ImportNamesView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
|
||||
+33
-1
@@ -282,5 +282,37 @@
|
||||
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||
"notSyncedYet": "Not synced yet",
|
||||
"tabClosedRemotely": "Tab {customer} closed remotely by {requester}",
|
||||
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}
|
||||
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}},
|
||||
"actionCannotBeUndone": "This cannot be undone.",
|
||||
"disconnectFromServer": "Disconnect from server",
|
||||
"disconnectFromServerDescription": "This unlinks the device from the remote server. The bar keeps working, but open tabs will no longer be synced. Continue?",
|
||||
"disconnect": "Disconnect",
|
||||
"remoteServer": "Remote server",
|
||||
"connected": "Connected",
|
||||
"deviceLinked": "Device linked to remote server",
|
||||
"notConnected": "Not connected",
|
||||
"linkToDashboard": "Link to remote dashboard",
|
||||
"deviceAccessRevoked": "The server no longer accepts this device. Link it again in Admin.",
|
||||
"duplicateTabName": "Name already in use",
|
||||
"duplicateTabNameDescription": "A tab named {name} is already open. Use this name anyway?",
|
||||
"@duplicateTabNameDescription": {"placeholders": {"name": {"type": "String"}}},
|
||||
"deleteTabWithItemsDescription": "This tab still has drinks on it. Deleting it returns them to stock and leaves nothing in history. Enter the admin PIN to continue.",
|
||||
"connectingToServer": "Connecting to server…",
|
||||
"failedToConnect": "Failed to connect",
|
||||
"unknownError": "Unknown error",
|
||||
"connectToRemoteServer": "Connect to remote server",
|
||||
"selectVenueToLink": "Select your venue to link this device",
|
||||
"selectVenue": "Select venue",
|
||||
"refreshVenues": "Refresh venues",
|
||||
"enterDeviceDetails": "Enter device details to complete pairing",
|
||||
"deviceName": "Device name",
|
||||
"deviceNameRequired": "Device name is required",
|
||||
"registrationCode": "Registration code",
|
||||
"enterRegistrationCode": "Enter the 6-character code",
|
||||
"back": "Back",
|
||||
"register": "Register",
|
||||
"registeringDevice": "Registering device…",
|
||||
"deviceRegistered": "Device registered!",
|
||||
"openTabsWillSync": "Open tabs will sync to the dashboard.",
|
||||
"done": "Done"
|
||||
}
|
||||
|
||||
@@ -1669,6 +1669,192 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Tab {customer} closed remotely by {requester}'**
|
||||
String tabClosedRemotely(String customer, String requester);
|
||||
|
||||
/// No description provided for @actionCannotBeUndone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This cannot be undone.'**
|
||||
String get actionCannotBeUndone;
|
||||
|
||||
/// No description provided for @disconnectFromServer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disconnect from server'**
|
||||
String get disconnectFromServer;
|
||||
|
||||
/// No description provided for @disconnectFromServerDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This unlinks the device from the remote server. The bar keeps working, but open tabs will no longer be synced. Continue?'**
|
||||
String get disconnectFromServerDescription;
|
||||
|
||||
/// No description provided for @disconnect.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disconnect'**
|
||||
String get disconnect;
|
||||
|
||||
/// No description provided for @remoteServer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote server'**
|
||||
String get remoteServer;
|
||||
|
||||
/// No description provided for @connected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connected'**
|
||||
String get connected;
|
||||
|
||||
/// No description provided for @deviceLinked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device linked to remote server'**
|
||||
String get deviceLinked;
|
||||
|
||||
/// No description provided for @notConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not connected'**
|
||||
String get notConnected;
|
||||
|
||||
/// No description provided for @linkToDashboard.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link to remote dashboard'**
|
||||
String get linkToDashboard;
|
||||
|
||||
/// No description provided for @deviceAccessRevoked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The server no longer accepts this device. Link it again in Admin.'**
|
||||
String get deviceAccessRevoked;
|
||||
|
||||
/// No description provided for @duplicateTabName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name already in use'**
|
||||
String get duplicateTabName;
|
||||
|
||||
/// No description provided for @duplicateTabNameDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A tab named {name} is already open. Use this name anyway?'**
|
||||
String duplicateTabNameDescription(String name);
|
||||
|
||||
/// No description provided for @deleteTabWithItemsDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This tab still has drinks on it. Deleting it returns them to stock and leaves nothing in history. Enter the admin PIN to continue.'**
|
||||
String get deleteTabWithItemsDescription;
|
||||
|
||||
/// No description provided for @connectingToServer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connecting to server…'**
|
||||
String get connectingToServer;
|
||||
|
||||
/// No description provided for @failedToConnect.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to connect'**
|
||||
String get failedToConnect;
|
||||
|
||||
/// No description provided for @unknownError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown error'**
|
||||
String get unknownError;
|
||||
|
||||
/// No description provided for @connectToRemoteServer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connect to remote server'**
|
||||
String get connectToRemoteServer;
|
||||
|
||||
/// No description provided for @selectVenueToLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select your venue to link this device'**
|
||||
String get selectVenueToLink;
|
||||
|
||||
/// No description provided for @selectVenue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select venue'**
|
||||
String get selectVenue;
|
||||
|
||||
/// No description provided for @refreshVenues.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh venues'**
|
||||
String get refreshVenues;
|
||||
|
||||
/// No description provided for @enterDeviceDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter device details to complete pairing'**
|
||||
String get enterDeviceDetails;
|
||||
|
||||
/// No description provided for @deviceName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device name'**
|
||||
String get deviceName;
|
||||
|
||||
/// No description provided for @deviceNameRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device name is required'**
|
||||
String get deviceNameRequired;
|
||||
|
||||
/// No description provided for @registrationCode.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Registration code'**
|
||||
String get registrationCode;
|
||||
|
||||
/// No description provided for @enterRegistrationCode.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter the 6-character code'**
|
||||
String get enterRegistrationCode;
|
||||
|
||||
/// No description provided for @back.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Back'**
|
||||
String get back;
|
||||
|
||||
/// No description provided for @register.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Register'**
|
||||
String get register;
|
||||
|
||||
/// No description provided for @registeringDevice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Registering device…'**
|
||||
String get registeringDevice;
|
||||
|
||||
/// No description provided for @deviceRegistered.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device registered!'**
|
||||
String get deviceRegistered;
|
||||
|
||||
/// No description provided for @openTabsWillSync.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open tabs will sync to the dashboard.'**
|
||||
String get openTabsWillSync;
|
||||
|
||||
/// No description provided for @done.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get done;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -864,4 +864,102 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String tabClosedRemotely(String customer, String requester) {
|
||||
return 'Tab $customer closed remotely by $requester';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionCannotBeUndone => 'This cannot be undone.';
|
||||
|
||||
@override
|
||||
String get disconnectFromServer => 'Disconnect from server';
|
||||
|
||||
@override
|
||||
String get disconnectFromServerDescription =>
|
||||
'This unlinks the device from the remote server. The bar keeps working, but open tabs will no longer be synced. Continue?';
|
||||
|
||||
@override
|
||||
String get disconnect => 'Disconnect';
|
||||
|
||||
@override
|
||||
String get remoteServer => 'Remote server';
|
||||
|
||||
@override
|
||||
String get connected => 'Connected';
|
||||
|
||||
@override
|
||||
String get deviceLinked => 'Device linked to remote server';
|
||||
|
||||
@override
|
||||
String get notConnected => 'Not connected';
|
||||
|
||||
@override
|
||||
String get linkToDashboard => 'Link to remote dashboard';
|
||||
|
||||
@override
|
||||
String get deviceAccessRevoked =>
|
||||
'The server no longer accepts this device. Link it again in Admin.';
|
||||
|
||||
@override
|
||||
String get duplicateTabName => 'Name already in use';
|
||||
|
||||
@override
|
||||
String duplicateTabNameDescription(String name) {
|
||||
return 'A tab named $name is already open. Use this name anyway?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deleteTabWithItemsDescription =>
|
||||
'This tab still has drinks on it. Deleting it returns them to stock and leaves nothing in history. Enter the admin PIN to continue.';
|
||||
|
||||
@override
|
||||
String get connectingToServer => 'Connecting to server…';
|
||||
|
||||
@override
|
||||
String get failedToConnect => 'Failed to connect';
|
||||
|
||||
@override
|
||||
String get unknownError => 'Unknown error';
|
||||
|
||||
@override
|
||||
String get connectToRemoteServer => 'Connect to remote server';
|
||||
|
||||
@override
|
||||
String get selectVenueToLink => 'Select your venue to link this device';
|
||||
|
||||
@override
|
||||
String get selectVenue => 'Select venue';
|
||||
|
||||
@override
|
||||
String get refreshVenues => 'Refresh venues';
|
||||
|
||||
@override
|
||||
String get enterDeviceDetails => 'Enter device details to complete pairing';
|
||||
|
||||
@override
|
||||
String get deviceName => 'Device name';
|
||||
|
||||
@override
|
||||
String get deviceNameRequired => 'Device name is required';
|
||||
|
||||
@override
|
||||
String get registrationCode => 'Registration code';
|
||||
|
||||
@override
|
||||
String get enterRegistrationCode => 'Enter the 6-character code';
|
||||
|
||||
@override
|
||||
String get back => 'Back';
|
||||
|
||||
@override
|
||||
String get register => 'Register';
|
||||
|
||||
@override
|
||||
String get registeringDevice => 'Registering device…';
|
||||
|
||||
@override
|
||||
String get deviceRegistered => 'Device registered!';
|
||||
|
||||
@override
|
||||
String get openTabsWillSync => 'Open tabs will sync to the dashboard.';
|
||||
|
||||
@override
|
||||
String get done => 'Done';
|
||||
}
|
||||
|
||||
@@ -876,4 +876,103 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String tabClosedRemotely(String customer, String requester) {
|
||||
return 'Poef $customer extern gesloten door $requester';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionCannotBeUndone => 'Dit kan niet ongedaan gemaakt worden.';
|
||||
|
||||
@override
|
||||
String get disconnectFromServer => 'Loskoppelen van server';
|
||||
|
||||
@override
|
||||
String get disconnectFromServerDescription =>
|
||||
'Dit koppelt het toestel los van de server. De bar blijft werken, maar open poefs worden niet meer gesynchroniseerd. Doorgaan?';
|
||||
|
||||
@override
|
||||
String get disconnect => 'Loskoppelen';
|
||||
|
||||
@override
|
||||
String get remoteServer => 'Server';
|
||||
|
||||
@override
|
||||
String get connected => 'Verbonden';
|
||||
|
||||
@override
|
||||
String get deviceLinked => 'Toestel gekoppeld aan de server';
|
||||
|
||||
@override
|
||||
String get notConnected => 'Niet verbonden';
|
||||
|
||||
@override
|
||||
String get linkToDashboard => 'Koppelen aan het dashboard';
|
||||
|
||||
@override
|
||||
String get deviceAccessRevoked =>
|
||||
'De server aanvaardt dit toestel niet meer. Koppel het opnieuw via Beheer.';
|
||||
|
||||
@override
|
||||
String get duplicateTabName => 'Naam al in gebruik';
|
||||
|
||||
@override
|
||||
String duplicateTabNameDescription(String name) {
|
||||
return 'Er is al een poef open met de naam $name. Toch deze naam gebruiken?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deleteTabWithItemsDescription =>
|
||||
'Op deze poef staan nog drankjes. Verwijderen zet ze terug in de voorraad en laat niets na in de geschiedenis. Geef de beheer-PIN in om door te gaan.';
|
||||
|
||||
@override
|
||||
String get connectingToServer => 'Verbinden met server…';
|
||||
|
||||
@override
|
||||
String get failedToConnect => 'Verbinden mislukt';
|
||||
|
||||
@override
|
||||
String get unknownError => 'Onbekende fout';
|
||||
|
||||
@override
|
||||
String get connectToRemoteServer => 'Verbinden met server';
|
||||
|
||||
@override
|
||||
String get selectVenueToLink => 'Kies je locatie om dit toestel te koppelen';
|
||||
|
||||
@override
|
||||
String get selectVenue => 'Kies locatie';
|
||||
|
||||
@override
|
||||
String get refreshVenues => 'Locaties vernieuwen';
|
||||
|
||||
@override
|
||||
String get enterDeviceDetails => 'Vul de toestelgegevens in om te koppelen';
|
||||
|
||||
@override
|
||||
String get deviceName => 'Toestelnaam';
|
||||
|
||||
@override
|
||||
String get deviceNameRequired => 'Toestelnaam is verplicht';
|
||||
|
||||
@override
|
||||
String get registrationCode => 'Registratiecode';
|
||||
|
||||
@override
|
||||
String get enterRegistrationCode => 'Geef de code van 6 tekens in';
|
||||
|
||||
@override
|
||||
String get back => 'Terug';
|
||||
|
||||
@override
|
||||
String get register => 'Registreren';
|
||||
|
||||
@override
|
||||
String get registeringDevice => 'Toestel registreren…';
|
||||
|
||||
@override
|
||||
String get deviceRegistered => 'Toestel geregistreerd!';
|
||||
|
||||
@override
|
||||
String get openTabsWillSync =>
|
||||
'Open poefs worden gesynchroniseerd met het dashboard.';
|
||||
|
||||
@override
|
||||
String get done => 'Klaar';
|
||||
}
|
||||
|
||||
+32
-1
@@ -282,5 +282,36 @@
|
||||
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||
"notSyncedYet": "Nog niet gesynchroniseerd",
|
||||
"tabClosedRemotely": "Poef {customer} extern gesloten door {requester}",
|
||||
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}
|
||||
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}},
|
||||
"actionCannotBeUndone": "Dit kan niet ongedaan gemaakt worden.",
|
||||
"disconnectFromServer": "Loskoppelen van server",
|
||||
"disconnectFromServerDescription": "Dit koppelt het toestel los van de server. De bar blijft werken, maar open poefs worden niet meer gesynchroniseerd. Doorgaan?",
|
||||
"disconnect": "Loskoppelen",
|
||||
"remoteServer": "Server",
|
||||
"connected": "Verbonden",
|
||||
"deviceLinked": "Toestel gekoppeld aan de server",
|
||||
"notConnected": "Niet verbonden",
|
||||
"linkToDashboard": "Koppelen aan het dashboard",
|
||||
"deviceAccessRevoked": "De server aanvaardt dit toestel niet meer. Koppel het opnieuw via Beheer.",
|
||||
"duplicateTabName": "Naam al in gebruik",
|
||||
"duplicateTabNameDescription": "Er is al een poef open met de naam {name}. Toch deze naam gebruiken?",
|
||||
"deleteTabWithItemsDescription": "Op deze poef staan nog drankjes. Verwijderen zet ze terug in de voorraad en laat niets na in de geschiedenis. Geef de beheer-PIN in om door te gaan.",
|
||||
"connectingToServer": "Verbinden met server…",
|
||||
"failedToConnect": "Verbinden mislukt",
|
||||
"unknownError": "Onbekende fout",
|
||||
"connectToRemoteServer": "Verbinden met server",
|
||||
"selectVenueToLink": "Kies je locatie om dit toestel te koppelen",
|
||||
"selectVenue": "Kies locatie",
|
||||
"refreshVenues": "Locaties vernieuwen",
|
||||
"enterDeviceDetails": "Vul de toestelgegevens in om te koppelen",
|
||||
"deviceName": "Toestelnaam",
|
||||
"deviceNameRequired": "Toestelnaam is verplicht",
|
||||
"registrationCode": "Registratiecode",
|
||||
"enterRegistrationCode": "Geef de code van 6 tekens in",
|
||||
"back": "Terug",
|
||||
"register": "Registreren",
|
||||
"registeringDevice": "Toestel registreren…",
|
||||
"deviceRegistered": "Toestel geregistreerd!",
|
||||
"openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.",
|
||||
"done": "Klaar"
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Future<void> _runApp() async {
|
||||
final productService = DriftProductService(database: database);
|
||||
final barTabService = DriftBarTabService(database: database);
|
||||
final settingsService = DriftSettingsService(database: database);
|
||||
final pinLockService = PinLockService();
|
||||
final pinLockService = PinLockService(maxLockout: const Duration(minutes: 1));
|
||||
final apiClient = KoolTapApiClient();
|
||||
|
||||
final remoteSyncService = RemoteSyncService(
|
||||
|
||||
@@ -19,22 +19,34 @@ abstract class ExportService {
|
||||
/// failed backup is logged but never interrupts the bar.
|
||||
class DefaultExportService implements ExportService {
|
||||
final BarTabService barTabService;
|
||||
final Future<String?> Function()? authToken;
|
||||
final http.Client _client;
|
||||
|
||||
DefaultExportService({required this.barTabService, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
DefaultExportService({
|
||||
required this.barTabService,
|
||||
this.authToken,
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
static const _timeout = Duration(seconds: 30);
|
||||
|
||||
@override
|
||||
Future<void> uploadToServer() async {
|
||||
try {
|
||||
final closedTabs = await barTabService.getClosedTabs();
|
||||
final appVersion = (await PackageInfo.fromPlatform()).version;
|
||||
final token = await authToken?.call();
|
||||
|
||||
final response = await _client.post(
|
||||
Uri.parse(kExportServerUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)),
|
||||
);
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse(kExportServerUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
unawaited(
|
||||
|
||||
@@ -20,6 +20,7 @@ class PinLockService {
|
||||
this._saltKey = _defaultSaltKey,
|
||||
this._hashKey = _defaultHashKey,
|
||||
DateTime Function()? clock,
|
||||
this.maxLockout = const Duration(minutes: 15),
|
||||
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||
_clock = clock ?? DateTime.now;
|
||||
|
||||
@@ -31,13 +32,15 @@ class PinLockService {
|
||||
|
||||
static const _freeAttempts = 5;
|
||||
static const _baseLockout = Duration(seconds: 30);
|
||||
static const _maxLockout = Duration(minutes: 15);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
final String _saltKey;
|
||||
final String _hashKey;
|
||||
final DateTime Function() _clock;
|
||||
|
||||
/// Kept short for the bar PIN so a bartender is never locked out for long.
|
||||
final Duration maxLockout;
|
||||
|
||||
String get _failuresKey => '${_hashKey}_failures';
|
||||
String get _lockedUntilKey => '${_hashKey}_locked_until';
|
||||
|
||||
@@ -113,7 +116,7 @@ class PinLockService {
|
||||
|
||||
final exponent = min(failures - _freeAttempts, 10);
|
||||
final lockout = _baseLockout * (1 << exponent);
|
||||
final capped = lockout > _maxLockout ? _maxLockout : lockout;
|
||||
final capped = lockout > maxLockout ? maxLockout : lockout;
|
||||
|
||||
await _storage.write(
|
||||
key: _lockedUntilKey,
|
||||
|
||||
@@ -130,12 +130,13 @@ class DriftProductService implements ProductService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Only hidden, since open tabs and history still refer to it.
|
||||
@override
|
||||
Future<void> deleteProduct(String id) async {
|
||||
final query = database.delete(database.products)
|
||||
final query = database.update(database.products)
|
||||
..where((product) => product.id.equals(id));
|
||||
|
||||
await query.go();
|
||||
await query.write(const ProductsCompanion(active: Value(false)));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
@@ -65,20 +67,29 @@ class RemoteSyncService {
|
||||
DateTime? _firstPendingChange;
|
||||
bool _syncQueued = false;
|
||||
|
||||
_Credentials? _cachedCredentials;
|
||||
|
||||
Future<String?> get venueId => secureStorage.read(key: _venueIdKey);
|
||||
|
||||
Future<bool> get isConfigured async {
|
||||
final token = await secureStorage.read(key: _authTokenKey);
|
||||
final venueId = await this.venueId;
|
||||
return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false);
|
||||
}
|
||||
Future<bool> get isConfigured async => await _credentials() != null;
|
||||
|
||||
Future<String?> get authToken async => (await _credentials())?.token;
|
||||
|
||||
Future<_Credentials?> _credentials() async {
|
||||
if (_cachedCredentials != null) return _cachedCredentials;
|
||||
|
||||
final deviceId = await secureStorage.read(key: _deviceIdKey);
|
||||
final token = await secureStorage.read(key: _authTokenKey);
|
||||
if (deviceId == null || token == null) return null;
|
||||
final venueId = await this.venueId;
|
||||
if (deviceId == null ||
|
||||
token == null ||
|
||||
token.isEmpty ||
|
||||
venueId == null ||
|
||||
venueId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (deviceId: deviceId, token: token);
|
||||
return _cachedCredentials = (deviceId: deviceId, token: token);
|
||||
}
|
||||
|
||||
void startPeriodicSync() {
|
||||
@@ -109,8 +120,6 @@ class RemoteSyncService {
|
||||
|
||||
void resume() {
|
||||
_paused = false;
|
||||
// Catch up right away instead of waiting for the next poll
|
||||
_onTabsChanged();
|
||||
}
|
||||
|
||||
/// Pushes tab changes to the server soon after they happen instead of
|
||||
@@ -164,7 +173,7 @@ class RemoteSyncService {
|
||||
}
|
||||
|
||||
final credentials = await _credentials();
|
||||
if (credentials == null || !await isConfigured) return;
|
||||
if (credentials == null) return;
|
||||
|
||||
_isSyncing = true;
|
||||
Duration nextDelay;
|
||||
@@ -176,6 +185,7 @@ class RemoteSyncService {
|
||||
_emit(RemoteSyncEventType.syncCompleted);
|
||||
} on UnauthorizedException {
|
||||
debugPrint('RemoteSyncService: token revoked');
|
||||
_cachedCredentials = null;
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
stopPeriodicSync();
|
||||
_emit(RemoteSyncEventType.unauthorized);
|
||||
@@ -183,7 +193,9 @@ class RemoteSyncService {
|
||||
} on RateLimitedException catch (e) {
|
||||
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
|
||||
} catch (e, stack) {
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
if (!_isConnectivityError(e)) {
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
}
|
||||
nextDelay = await _recordFailure(e);
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
@@ -197,6 +209,13 @@ class RemoteSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline tablets are expected, not worth an error report.
|
||||
bool _isConnectivityError(Object error) {
|
||||
return error is IOException ||
|
||||
error is TimeoutException ||
|
||||
error is http.ClientException;
|
||||
}
|
||||
|
||||
Future<Duration> _recordFailure(Object error, {Duration? retryAfter}) async {
|
||||
debugPrint('RemoteSyncService: sync failed: $error');
|
||||
_consecutiveFailures++;
|
||||
@@ -391,9 +410,11 @@ class RemoteSyncService {
|
||||
await secureStorage.write(key: _deviceIdKey, value: deviceId);
|
||||
await secureStorage.write(key: _authTokenKey, value: token);
|
||||
await secureStorage.write(key: _venueIdKey, value: venueId);
|
||||
_cachedCredentials = (deviceId: deviceId, token: token);
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
_cachedCredentials = null;
|
||||
await secureStorage.delete(key: _deviceIdKey);
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
await secureStorage.delete(key: _venueIdKey);
|
||||
|
||||
@@ -65,9 +65,7 @@ class AppUpdateUtil {
|
||||
|
||||
final url = Uri.parse('$serverUrl/api/update?version=$version');
|
||||
|
||||
final response = await http.get(url);
|
||||
|
||||
debugPrint('Response from update server: ${response.body}');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Update server unavailable');
|
||||
|
||||
@@ -2,6 +2,19 @@ import 'package:intl/intl.dart';
|
||||
|
||||
final _euro = NumberFormat.currency(locale: 'nl_BE', symbol: '€');
|
||||
|
||||
final _euroInput = RegExp(r'^(\d{1,6})(?:[.,](\d{1,2}))?$');
|
||||
|
||||
extension Cents on int {
|
||||
String get asEuro => _euro.format(this / 100);
|
||||
}
|
||||
|
||||
/// Parses a typed price like `2,50` or `3` without going through doubles.
|
||||
/// Returns null for anything else, including more than two decimals.
|
||||
int? parseEuroToCents(String input) {
|
||||
final match = _euroInput.firstMatch(input.trim());
|
||||
if (match == null) return null;
|
||||
|
||||
final euros = int.parse(match.group(1)!);
|
||||
final cents = int.parse((match.group(2) ?? '0').padRight(2, '0'));
|
||||
return euros * 100 + cents;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,15 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
BarTab? get selectedTab =>
|
||||
_tabs.firstWhereOrNull((tab) => tab.id == _selectedTabId);
|
||||
|
||||
bool hasOpenTabNamed(String name, {String? exceptTabId}) {
|
||||
final normalized = name.trim().toLowerCase();
|
||||
return _tabs.any(
|
||||
(tab) =>
|
||||
tab.id != exceptTabId &&
|
||||
tab.customerName.trim().toLowerCase() == normalized,
|
||||
);
|
||||
}
|
||||
|
||||
void ensureLoaded() {
|
||||
if (_subscription != null) return;
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
bool _isConfigured = false;
|
||||
bool get isConfigured => _isConfigured;
|
||||
|
||||
bool _wasRevoked = false;
|
||||
|
||||
/// The server rejected this device's token; it needs to be linked again.
|
||||
bool get wasRevoked => _wasRevoked;
|
||||
|
||||
DateTime? _lastSyncAt;
|
||||
DateTime? get lastSyncAt => _lastSyncAt;
|
||||
|
||||
@@ -54,6 +59,7 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
/// Called once registration stored new credentials.
|
||||
Future<void> activate() async {
|
||||
await _refreshStatus();
|
||||
if (_isConfigured) _wasRevoked = false;
|
||||
notifyListeners();
|
||||
|
||||
if (!_isConfigured) return;
|
||||
@@ -90,6 +96,7 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
syncService.stopPeriodicSync();
|
||||
await syncService.clearCredentials();
|
||||
_isConfigured = false;
|
||||
_wasRevoked = false;
|
||||
_venueId = null;
|
||||
_lastSyncAt = null;
|
||||
_lastError = null;
|
||||
@@ -105,8 +112,8 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
case RemoteSyncEventType.syncFailed:
|
||||
_lastError = event.message;
|
||||
case RemoteSyncEventType.unauthorized:
|
||||
// The router sends the device back to registration.
|
||||
_isConfigured = false;
|
||||
_wasRevoked = true;
|
||||
case RemoteSyncEventType.tabClosedRemotely:
|
||||
case RemoteSyncEventType.conflict:
|
||||
break;
|
||||
|
||||
@@ -33,11 +33,8 @@ class AdminScreenView extends StatelessWidget {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Disconnect from server'),
|
||||
content: const Text(
|
||||
'This will unlink the device from the remote server. '
|
||||
'Open tabs will no longer be synced. Continue?',
|
||||
),
|
||||
title: Text(l10n.disconnectFromServer),
|
||||
content: Text(l10n.disconnectFromServerDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
@@ -46,7 +43,7 @@ class AdminScreenView extends StatelessWidget {
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('Disconnect'),
|
||||
child: Text(l10n.disconnect),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -109,35 +106,33 @@ class AdminScreenView extends StatelessWidget {
|
||||
subtitle: l10n.closedTabs,
|
||||
onTap: () => context.push('/history'),
|
||||
),
|
||||
_AdminTile(
|
||||
icon: Icons.file_upload_outlined,
|
||||
title: l10n.importData,
|
||||
subtitle: l10n.importNamesDescription,
|
||||
onTap: () => context.push('/admin/import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminSection(
|
||||
title: 'Remote Server',
|
||||
title: l10n.remoteServer,
|
||||
children: [
|
||||
if (remoteSync.isConfigured) ...[
|
||||
_AdminTile(
|
||||
icon: Icons.cloud_done_outlined,
|
||||
title: 'Connected',
|
||||
subtitle: 'Device linked to remote server',
|
||||
title: l10n.connected,
|
||||
subtitle: l10n.deviceLinked,
|
||||
trailing: TextButton(
|
||||
onPressed: () => _confirmUnlink(context),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Disconnect'),
|
||||
child: Text(l10n.disconnect),
|
||||
),
|
||||
onTap: null,
|
||||
),
|
||||
] else
|
||||
_AdminTile(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'Not connected',
|
||||
subtitle: 'Link to remote dashboard',
|
||||
icon: remoteSync.wasRevoked
|
||||
? Icons.cloud_off_rounded
|
||||
: Icons.cloud_outlined,
|
||||
title: l10n.notConnected,
|
||||
subtitle: remoteSync.wasRevoked
|
||||
? l10n.deviceAccessRevoked
|
||||
: l10n.linkToDashboard,
|
||||
onTap: () => context.push('/register'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -144,6 +144,15 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
location: '/history',
|
||||
),
|
||||
],
|
||||
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.deviceAccessRevoked,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.cloud_off_rounded),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.refresh,
|
||||
|
||||
@@ -45,6 +45,36 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
).showSnackBar(SnackBar(content: Text(l10n.devAction(message))));
|
||||
}
|
||||
|
||||
Future<void> _confirmThen(
|
||||
String title,
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(l10n.actionCannotBeUndone),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||
),
|
||||
child: Text(l10n.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) await action();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_vm?.removeListener(_onVmChange);
|
||||
@@ -78,12 +108,19 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
_Tile(
|
||||
icon: Icons.delete_sweep_rounded,
|
||||
title: l10n.clearOpenTabs,
|
||||
onTap: vm.isLoading ? null : () => vm.clearOpenTabs(),
|
||||
onTap: vm.isLoading
|
||||
? null
|
||||
: () => _confirmThen(l10n.clearOpenTabs, vm.clearOpenTabs),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.history_rounded,
|
||||
title: l10n.clearClosedHistory,
|
||||
onTap: vm.isLoading ? null : () => vm.clearClosedTabHistory(),
|
||||
onTap: vm.isLoading
|
||||
? null
|
||||
: () => _confirmThen(
|
||||
l10n.clearClosedHistory,
|
||||
vm.clearClosedTabHistory,
|
||||
),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.add_box_rounded,
|
||||
@@ -95,18 +132,33 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
icon: Icons.restart_alt_rounded,
|
||||
title: l10n.clearAndReseedProducts,
|
||||
subtitle: l10n.wipesAndReseeds,
|
||||
onTap: vm.isLoading ? null : () => vm.clearAndReseedProducts(),
|
||||
onTap: vm.isLoading
|
||||
? null
|
||||
: () => _confirmThen(
|
||||
l10n.clearAndReseedProducts,
|
||||
vm.clearAndReseedProducts,
|
||||
),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.delete_forever_rounded,
|
||||
title: l10n.clearAllProducts,
|
||||
onTap: vm.isLoading ? null : () => vm.clearAllProducts(),
|
||||
onTap: vm.isLoading
|
||||
? null
|
||||
: () => _confirmThen(
|
||||
l10n.clearAllProducts,
|
||||
vm.clearAllProducts,
|
||||
),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.image_rounded,
|
||||
title: l10n.clearProductImages,
|
||||
subtitle: l10n.deletesProductImages,
|
||||
onTap: vm.isLoading ? null : () => vm.clearProductImages(),
|
||||
onTap: vm.isLoading
|
||||
? null
|
||||
: () => _confirmThen(
|
||||
l10n.clearProductImages,
|
||||
vm.clearProductImages,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -117,7 +169,9 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
icon: Icons.lock_reset_rounded,
|
||||
title: l10n.resetPin,
|
||||
subtitle: l10n.removePinLock,
|
||||
onTap: vm.isLoading ? null : () => vm.resetPin(),
|
||||
onTap: vm.isLoading
|
||||
? null
|
||||
: () => _confirmThen(l10n.resetPin, vm.resetPin),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.bug_report_rounded,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
Future<bool> confirmDuplicateTabName(BuildContext context, String name) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.duplicateTabName),
|
||||
content: Text(l10n.duplicateTabNameDescription(name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(l10n.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return confirmed == true;
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../l10n/app_localizations_helpers.dart';
|
||||
import '../../models/app_error.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../services/pin_lock_service.dart';
|
||||
import '../../viewmodels/admin_pin_view_model.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import 'duplicate_tab_name_dialog.dart';
|
||||
|
||||
Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
@@ -13,10 +18,19 @@ Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
|
||||
builder: (_) => _EditTabDialog(initialName: tab.customerName),
|
||||
);
|
||||
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) return;
|
||||
final trimmed = name?.trim() ?? '';
|
||||
if (trimmed.isEmpty || trimmed == tab.customerName || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
if (viewModel.hasOpenTabNamed(trimmed, exceptTabId: tab.id) &&
|
||||
!await confirmDuplicateTabName(context, trimmed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().renameTab(tab.id, name.trim());
|
||||
await viewModel.renameTab(tab.id, trimmed);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -78,30 +92,37 @@ class _EditTabDialogState extends State<_EditTabDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deleting a tab with drinks on it leaves no trace in history, so it needs
|
||||
/// the admin PIN when one is set.
|
||||
Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final adminPinViewModel = context.read<AdminPinViewModel>();
|
||||
final needsAdminPin = tab.items.isNotEmpty && adminPinViewModel.isPinSet;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
final result = await showDialog<_DeleteTabResult>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.deleteTab),
|
||||
content: Text(l10n.deleteTabDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
builder: (_) => _DeleteTabDialog(
|
||||
description: tab.items.isEmpty
|
||||
? l10n.deleteTabDescription
|
||||
: l10n.deleteTabWithItemsDescription,
|
||||
needsAdminPin: needsAdminPin,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
if (needsAdminPin) {
|
||||
final error = await _verifyAdminPin(adminPinViewModel, result.pin);
|
||||
if (error != null) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.errorText(error))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().deleteTab(tab.id);
|
||||
@@ -112,3 +133,94 @@ Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
|
||||
).showSnackBar(SnackBar(content: Text(l10n.couldNotDeleteTab)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppError?> _verifyAdminPin(
|
||||
AdminPinViewModel adminPinViewModel,
|
||||
String pin,
|
||||
) async {
|
||||
try {
|
||||
final matches = await adminPinViewModel.adminPinService.verifyPin(pin);
|
||||
return matches ? null : AppError.incorrectPin;
|
||||
} on PinLockedOutException {
|
||||
return AppError.tooManyPinAttempts;
|
||||
} catch (_) {
|
||||
return AppError.verifyPin;
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteTabResult {
|
||||
final String pin;
|
||||
|
||||
const _DeleteTabResult(this.pin);
|
||||
}
|
||||
|
||||
class _DeleteTabDialog extends StatefulWidget {
|
||||
final String description;
|
||||
final bool needsAdminPin;
|
||||
|
||||
const _DeleteTabDialog({
|
||||
required this.description,
|
||||
required this.needsAdminPin,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DeleteTabDialog> createState() => _DeleteTabDialogState();
|
||||
}
|
||||
|
||||
class _DeleteTabDialogState extends State<_DeleteTabDialog> {
|
||||
final _pinController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
Navigator.of(context).pop(_DeleteTabResult(_pinController.text));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(l10n.deleteTab),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.description),
|
||||
if (widget.needsAdminPin) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _pinController,
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
decoration: InputDecoration(labelText: l10n.adminPin),
|
||||
onSubmitted: (_) => _confirm(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _confirm,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import 'duplicate_tab_name_dialog.dart';
|
||||
|
||||
/// Returns the name of the opened tab, or null when nothing was opened.
|
||||
Future<String?> showNewTabDialog(BuildContext context) async {
|
||||
@@ -42,8 +43,14 @@ Future<String?> showNewTabDialog(BuildContext context) async {
|
||||
final trimmed = name?.trim() ?? '';
|
||||
if (trimmed.isEmpty || !context.mounted) return null;
|
||||
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
if (viewModel.hasOpenTabNamed(trimmed) &&
|
||||
!await confirmDuplicateTabName(context, trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().createTab(trimmed);
|
||||
await viewModel.createTab(trimmed);
|
||||
return trimmed;
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -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_helpers.dart';
|
||||
import '../models/product.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
|
||||
@@ -148,24 +149,10 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
});
|
||||
}
|
||||
|
||||
int _parsePriceToCents(String value) {
|
||||
final normalized = value.replaceAll(',', '.');
|
||||
final euros = double.tryParse(normalized) ?? 0;
|
||||
|
||||
return (euros * 100).round();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (_imagePath == null || _imagePath!.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.chooseImage)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedCategory == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -179,7 +166,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
|
||||
final name = _nameController.text.trim();
|
||||
final category = _selectedCategory!;
|
||||
final priceInCents = _parsePriceToCents(_priceController.text);
|
||||
final priceInCents = parseEuroToCents(_priceController.text)!;
|
||||
final stockQuantity = int.tryParse(_stockController.text) ?? 0;
|
||||
final lowStockThreshold = int.tryParse(_lowStockController.text) ?? 0;
|
||||
|
||||
@@ -422,10 +409,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
return l10n.enterPrice;
|
||||
}
|
||||
|
||||
final normalized = value.replaceAll(',', '.');
|
||||
final price = double.tryParse(normalized);
|
||||
|
||||
if (price == null || price < 0) {
|
||||
if (parseEuroToCents(value) == null) {
|
||||
return l10n.enterValidPrice;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/registration_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
|
||||
@@ -25,6 +26,8 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
});
|
||||
}
|
||||
|
||||
AppLocalizations get _l10n => AppLocalizations.of(context);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_deviceNameController.dispose();
|
||||
@@ -39,6 +42,14 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
tooltip: l10n.cancel,
|
||||
onPressed: () => context.popOrGo('/admin'),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
@@ -87,7 +98,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
const CircularProgressIndicator(strokeWidth: 3),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Connecting to server…',
|
||||
_l10n.connectingToServer,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
@@ -106,12 +117,12 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Failed to connect',
|
||||
_l10n.failedToConnect,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
vm.errorMessage ?? 'Unknown error',
|
||||
vm.errorMessage ?? _l10n.unknownError,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
@@ -123,7 +134,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
FilledButton.icon(
|
||||
onPressed: () => vm.retry(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
label: Text(_l10n.retry),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -142,13 +153,13 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Connect to remote server',
|
||||
_l10n.connectToRemoteServer,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select your venue to link this device',
|
||||
_l10n.selectVenueToLink,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
@@ -158,7 +169,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Select venue',
|
||||
_l10n.selectVenue,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
@@ -217,7 +228,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => vm.retry(),
|
||||
child: const Text('Refresh venues'),
|
||||
child: Text(_l10n.refreshVenues),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -244,7 +255,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter device details to complete pairing',
|
||||
_l10n.enterDeviceDetails,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
@@ -255,20 +266,19 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _deviceNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Device name',
|
||||
decoration: InputDecoration(
|
||||
labelText: _l10n.deviceName,
|
||||
hintText: 'e.g. Bar terminal 1',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) => v == null || v.trim().isEmpty
|
||||
? 'Device name is required'
|
||||
: null,
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? _l10n.deviceNameRequired : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _regCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Registration code',
|
||||
decoration: InputDecoration(
|
||||
labelText: _l10n.registrationCode,
|
||||
hintText: '6-character code from dashboard',
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
@@ -276,7 +286,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
maxLength: 6,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
validator: (v) => v == null || v.trim().length < 6
|
||||
? 'Enter the 6-character code'
|
||||
? _l10n.enterRegistrationCode
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -298,7 +308,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
onPressed: () {
|
||||
vm.reset();
|
||||
},
|
||||
child: const Text('Back'),
|
||||
child: Text(_l10n.back),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -312,7 +322,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
.toUpperCase(),
|
||||
);
|
||||
},
|
||||
child: const Text('Register'),
|
||||
child: Text(_l10n.register),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -330,7 +340,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
const CircularProgressIndicator(strokeWidth: 3),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Registering device…',
|
||||
_l10n.registeringDevice,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
@@ -349,7 +359,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Device registered!',
|
||||
_l10n.deviceRegistered,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
@@ -365,7 +375,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Open tabs will sync to the dashboard.',
|
||||
_l10n.openTabsWillSync,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
@@ -378,7 +388,7 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
// Once configured, the router redirects to /bar by itself.
|
||||
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('Start using KoolTap'),
|
||||
label: Text(_l10n.done),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+176
-169
@@ -53,6 +53,12 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
});
|
||||
}
|
||||
|
||||
void _leave() {
|
||||
final adminPinViewModel = context.read<AdminPinViewModel>();
|
||||
context.popOrGo('/bar');
|
||||
adminPinViewModel.lock();
|
||||
}
|
||||
|
||||
void _cancelVersionHold() {
|
||||
_versionHoldTimer?.cancel();
|
||||
_versionHoldTimer = null;
|
||||
@@ -225,7 +231,6 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
return;
|
||||
}
|
||||
|
||||
adminPinViewModel.lock();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,10 +255,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
|
||||
if (!success) {
|
||||
_showError(l10n.errorText(adminPinViewModel.error));
|
||||
return;
|
||||
}
|
||||
|
||||
adminPinViewModel.lock();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -263,184 +265,189 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
final pinLockViewModel = context.watch<PinLockViewModel>();
|
||||
final adminPinViewModel = context.watch<AdminPinViewModel>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => context.popOrGo('/bar'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) context.read<AdminPinViewModel>().lock();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: _leave,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
title: Text(l10n.settingsTitle),
|
||||
),
|
||||
title: Text(l10n.settingsTitle),
|
||||
),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final isLoading =
|
||||
settingsViewModel.isLoading ||
|
||||
pinLockViewModel.isLoading ||
|
||||
adminPinViewModel.isLoading;
|
||||
final hasLoaded =
|
||||
settingsViewModel.hasLoaded &&
|
||||
pinLockViewModel.hasLoaded &&
|
||||
adminPinViewModel.hasLoaded;
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final isLoading =
|
||||
settingsViewModel.isLoading ||
|
||||
pinLockViewModel.isLoading ||
|
||||
adminPinViewModel.isLoading;
|
||||
final hasLoaded =
|
||||
settingsViewModel.hasLoaded &&
|
||||
pinLockViewModel.hasLoaded &&
|
||||
adminPinViewModel.hasLoaded;
|
||||
|
||||
if (isLoading && !hasLoaded) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
);
|
||||
}
|
||||
if (isLoading && !hasLoaded) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
);
|
||||
}
|
||||
|
||||
final settings = settingsViewModel.settings;
|
||||
final settings = settingsViewModel.settings;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: l10n.security,
|
||||
children: [
|
||||
_SettingsSwitchTile(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
title: l10n.pinRequired,
|
||||
value: settings.pinRequired,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
_enablePin();
|
||||
} else {
|
||||
_disablePin();
|
||||
}
|
||||
},
|
||||
),
|
||||
_SettingsSwitchTile(
|
||||
icon: Icons.lock_clock_rounded,
|
||||
title: l10n.autoLockOnExit,
|
||||
value: settings.autoLockEnabled,
|
||||
onChanged: (value) {
|
||||
settingsViewModel.updateAutoLockEnabled(value);
|
||||
},
|
||||
),
|
||||
if (settings.pinRequired)
|
||||
_SettingsTile(
|
||||
icon: Icons.pin_rounded,
|
||||
title: l10n.changePin,
|
||||
onTap: _changePin,
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
title: l10n.adminPin,
|
||||
subtitle: adminPinViewModel.isPinSet
|
||||
? l10n.adminPinConfigured
|
||||
: l10n.adminPinNotSet,
|
||||
onTap: _configureAdminPin,
|
||||
),
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: l10n.appearance,
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.language_rounded,
|
||||
title: l10n.language,
|
||||
subtitle: switch (settings.language) {
|
||||
AppLanguage.system => l10n.languageSystem,
|
||||
AppLanguage.english => l10n.languageEnglish,
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
},
|
||||
onTap: () async {
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: AppLanguage.values,
|
||||
selected: settings.language,
|
||||
label: (language) => switch (language) {
|
||||
AppLanguage.system => l10n.languageSystem,
|
||||
AppLanguage.english => l10n.languageEnglish,
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
},
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
await settingsViewModel.updateLanguage(selected);
|
||||
}
|
||||
},
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.grid_view_rounded,
|
||||
title: l10n.productGridRows,
|
||||
subtitle: l10n.rowsCount(settings.barGridRows),
|
||||
onTap: () async {
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: const [2, 3, 4, 5, 6],
|
||||
selected: settings.barGridRows,
|
||||
label: l10n.rowsCount,
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
await settingsViewModel.updateBarGridRows(selected);
|
||||
}
|
||||
},
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.brightness_6_rounded,
|
||||
title: l10n.theme,
|
||||
subtitle: switch (settings.themeMode) {
|
||||
AppThemeMode.system => l10n.system,
|
||||
AppThemeMode.light => l10n.light,
|
||||
AppThemeMode.dark => l10n.dark,
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
},
|
||||
onTap: () async {
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: AppThemeMode.values,
|
||||
selected: settings.themeMode,
|
||||
label: (mode) => switch (mode) {
|
||||
AppThemeMode.system => l10n.system,
|
||||
AppThemeMode.light => l10n.light,
|
||||
AppThemeMode.dark => l10n.dark,
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
},
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
await settingsViewModel.updateThemeMode(selected);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: l10n.updates,
|
||||
title: l10n.security,
|
||||
children: [
|
||||
_SettingsSwitchTile(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
title: l10n.pinRequired,
|
||||
value: settings.pinRequired,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
_enablePin();
|
||||
} else {
|
||||
_disablePin();
|
||||
}
|
||||
},
|
||||
),
|
||||
_SettingsSwitchTile(
|
||||
icon: Icons.lock_clock_rounded,
|
||||
title: l10n.autoLockOnExit,
|
||||
value: settings.autoLockEnabled,
|
||||
onChanged: (value) {
|
||||
settingsViewModel.updateAutoLockEnabled(value);
|
||||
},
|
||||
),
|
||||
if (settings.pinRequired)
|
||||
_SettingsTile(
|
||||
icon: Icons.pin_rounded,
|
||||
title: l10n.changePin,
|
||||
onTap: _changePin,
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.system_update_alt_rounded,
|
||||
title: settingsViewModel.checkingForUpdates
|
||||
? l10n.checkingForUpdates
|
||||
: l10n.checkForUpdates,
|
||||
onTap: settingsViewModel.checkingForUpdates
|
||||
? null
|
||||
: _checkForUpdates,
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
title: l10n.adminPin,
|
||||
subtitle: adminPinViewModel.isPinSet
|
||||
? l10n.adminPinConfigured
|
||||
: l10n.adminPinNotSet,
|
||||
onTap: _configureAdminPin,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
GestureDetector(
|
||||
onTapDown: (_) => _startVersionHold(),
|
||||
onTapUp: (_) => _cancelVersionHold(),
|
||||
onTapCancel: _cancelVersionHold,
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.version(_appVersion),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
_SettingsSection(
|
||||
title: l10n.appearance,
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.language_rounded,
|
||||
title: l10n.language,
|
||||
subtitle: switch (settings.language) {
|
||||
AppLanguage.system => l10n.languageSystem,
|
||||
AppLanguage.english => l10n.languageEnglish,
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
},
|
||||
onTap: () async {
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: AppLanguage.values,
|
||||
selected: settings.language,
|
||||
label: (language) => switch (language) {
|
||||
AppLanguage.system => l10n.languageSystem,
|
||||
AppLanguage.english => l10n.languageEnglish,
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
},
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
await settingsViewModel.updateLanguage(selected);
|
||||
}
|
||||
},
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.grid_view_rounded,
|
||||
title: l10n.productGridRows,
|
||||
subtitle: l10n.rowsCount(settings.barGridRows),
|
||||
onTap: () async {
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: const [2, 3, 4, 5, 6],
|
||||
selected: settings.barGridRows,
|
||||
label: l10n.rowsCount,
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
await settingsViewModel.updateBarGridRows(selected);
|
||||
}
|
||||
},
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.brightness_6_rounded,
|
||||
title: l10n.theme,
|
||||
subtitle: switch (settings.themeMode) {
|
||||
AppThemeMode.system => l10n.system,
|
||||
AppThemeMode.light => l10n.light,
|
||||
AppThemeMode.dark => l10n.dark,
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
},
|
||||
onTap: () async {
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: AppThemeMode.values,
|
||||
selected: settings.themeMode,
|
||||
label: (mode) => switch (mode) {
|
||||
AppThemeMode.system => l10n.system,
|
||||
AppThemeMode.light => l10n.light,
|
||||
AppThemeMode.dark => l10n.dark,
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
},
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
await settingsViewModel.updateThemeMode(selected);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
_SettingsSection(
|
||||
title: l10n.updates,
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.system_update_alt_rounded,
|
||||
title: settingsViewModel.checkingForUpdates
|
||||
? l10n.checkingForUpdates
|
||||
: l10n.checkForUpdates,
|
||||
onTap: settingsViewModel.checkingForUpdates
|
||||
? null
|
||||
: _checkForUpdates,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
GestureDetector(
|
||||
onTapDown: (_) => _startVersionHold(),
|
||||
onTapUp: (_) => _cancelVersionHold(),
|
||||
onTapCancel: _cancelVersionHold,
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.version(_appVersion),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user