From 08f4847800343803799d845e88a2c9138934f1ce Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Mon, 26 Oct 2026 23:15:05 +0100 Subject: [PATCH] feat: reformat some pages --- lib/l10n/app_en.arb | 23 ++ lib/l10n/app_localizations.dart | 102 ++++++ lib/l10n/app_localizations_en.dart | 73 ++++ lib/l10n/app_localizations_helpers.dart | 29 ++ lib/l10n/app_localizations_nl.dart | 76 +++- lib/l10n/app_nl.arb | 18 + lib/services/remote_sync_service.dart | 7 + lib/viewmodels/inventory_view_model.dart | 10 + lib/viewmodels/product_list_view_model.dart | 2 + lib/viewmodels/registration_view_model.dart | 1 + lib/viewmodels/remote_sync_view_model.dart | 5 + lib/views/admin_screen_view.dart | 67 ++-- lib/views/pin_lock_view.dart | 59 +++- lib/views/product_list_view.dart | 365 +++++++++++++++++--- lib/views/registration_view.dart | 4 +- lib/views/settings_view.dart | 167 +++------ pubspec.yaml | 2 +- 17 files changed, 812 insertions(+), 198 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 262f715..6b4250e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -301,6 +301,29 @@ "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.", + "pinSaved": "PIN saved", + "deviceLinkedToVenue": "Linked to {venue}", + "@deviceLinkedToVenue": {"placeholders": {"venue": {"type": "String"}}}, + "justNow": "just now", + "minutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", + "@minutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "@hoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "remoteCommandCloseTab": "Close tab from dashboard", + "remoteCommandUnknown": "Unknown command ({type})", + "@remoteCommandUnknown": {"placeholders": {"type": {"type": "String"}}}, + "commandCompleted": "Done", + "commandConflict": "Conflict", + "commandFailed": "Failed", + "commandExpired": "Expired", + "searchProducts": "Search products…", + "allCategories": "All", + "noProductsMatch": "No products match your search.", + "lowStock": "Low stock", + "productHidden": "Hidden", + "addFirstProduct": "Add your first product to start selling.", + "productStockCount": "Stock: {count}", + "@productStockCount": {"placeholders": {"count": {"type": "int"}}}, "connectingToServer": "Connecting to server…", "failedToConnect": "Failed to connect", "unknownError": "Unknown error", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bd6ad75..e7d9bf9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1772,6 +1772,108 @@ abstract class AppLocalizations { /// **'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 @pinSaved. + /// + /// In en, this message translates to: + /// **'PIN saved'** + String get pinSaved; + + /// No description provided for @deviceLinkedToVenue. + /// + /// In en, this message translates to: + /// **'Linked to {venue}'** + String deviceLinkedToVenue(String venue); + + /// No description provided for @justNow. + /// + /// In en, this message translates to: + /// **'just now'** + String get justNow; + + /// No description provided for @minutesAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 minute ago} other{{count} minutes ago}}'** + String minutesAgo(int count); + + /// No description provided for @hoursAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'** + String hoursAgo(int count); + + /// No description provided for @remoteCommandCloseTab. + /// + /// In en, this message translates to: + /// **'Close tab from dashboard'** + String get remoteCommandCloseTab; + + /// No description provided for @remoteCommandUnknown. + /// + /// In en, this message translates to: + /// **'Unknown command ({type})'** + String remoteCommandUnknown(String type); + + /// No description provided for @commandCompleted. + /// + /// In en, this message translates to: + /// **'Done'** + String get commandCompleted; + + /// No description provided for @commandConflict. + /// + /// In en, this message translates to: + /// **'Conflict'** + String get commandConflict; + + /// No description provided for @commandFailed. + /// + /// In en, this message translates to: + /// **'Failed'** + String get commandFailed; + + /// No description provided for @commandExpired. + /// + /// In en, this message translates to: + /// **'Expired'** + String get commandExpired; + + /// No description provided for @searchProducts. + /// + /// In en, this message translates to: + /// **'Search products…'** + String get searchProducts; + + /// No description provided for @allCategories. + /// + /// In en, this message translates to: + /// **'All'** + String get allCategories; + + /// No description provided for @noProductsMatch. + /// + /// In en, this message translates to: + /// **'No products match your search.'** + String get noProductsMatch; + + /// No description provided for @lowStock. + /// + /// In en, this message translates to: + /// **'Low stock'** + String get lowStock; + + /// No description provided for @productHidden. + /// + /// In en, this message translates to: + /// **'Hidden'** + String get productHidden; + + /// No description provided for @productStockCount. + /// + /// In en, this message translates to: + /// **'Stock: {count}'** + String productStockCount(int count); + /// No description provided for @connectingToServer. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1150cdd..209b5bf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -923,6 +923,79 @@ class AppLocalizationsEn extends AppLocalizations { 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 pinSaved => 'PIN saved'; + + @override + String deviceLinkedToVenue(String venue) { + return 'Linked to $venue'; + } + + @override + String get justNow => 'just now'; + + @override + String minutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count minutes ago', + one: '1 minute ago', + ); + return '$_temp0'; + } + + @override + String hoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hours ago', + one: '1 hour ago', + ); + return '$_temp0'; + } + + @override + String get remoteCommandCloseTab => 'Close tab from dashboard'; + + @override + String remoteCommandUnknown(String type) { + return 'Unknown command ($type)'; + } + + @override + String get commandCompleted => 'Done'; + + @override + String get commandConflict => 'Conflict'; + + @override + String get commandFailed => 'Failed'; + + @override + String get commandExpired => 'Expired'; + + @override + String get searchProducts => 'Search products…'; + + @override + String get allCategories => 'All'; + + @override + String get noProductsMatch => 'No products match your search.'; + + @override + String get lowStock => 'Low stock'; + + @override + String get productHidden => 'Hidden'; + + @override + String productStockCount(int count) { + return 'Stock: $count'; + } + @override String get connectingToServer => 'Connecting to server…'; diff --git a/lib/l10n/app_localizations_helpers.dart b/lib/l10n/app_localizations_helpers.dart index 89a68b7..5481120 100644 --- a/lib/l10n/app_localizations_helpers.dart +++ b/lib/l10n/app_localizations_helpers.dart @@ -1,4 +1,7 @@ +import 'package:intl/intl.dart'; + import '../models/app_error.dart'; +import '../models/remote_command.dart'; import '../models/update_failure.dart'; import 'app_localizations.dart'; @@ -17,6 +20,32 @@ extension AppLocalizationsHelpers on AppLocalizations { }; } + /// "just now", "5 minutes ago"…, and the full date once a day has passed. + String relativeTime(DateTime time, String locale, {DateTime? now}) { + final elapsed = (now ?? DateTime.now()).difference(time); + + if (elapsed.inMinutes < 1) return justNow; + if (elapsed.inHours < 1) return minutesAgo(elapsed.inMinutes); + if (elapsed.inDays < 1) return hoursAgo(elapsed.inHours); + return DateFormat.yMMMd(locale).add_jm().format(time.toLocal()); + } + + String remoteCommandLabel(String rawType) { + return switch (CommandType.fromValue(rawType)) { + CommandType.closeTab => remoteCommandCloseTab, + null => remoteCommandUnknown(rawType), + }; + } + + String commandResultLabel(CommandResult result) { + return switch (result) { + CommandResult.completed => commandCompleted, + CommandResult.conflict => commandConflict, + CommandResult.failed => commandFailed, + CommandResult.expired => commandExpired, + }; + } + String errorText(AppError? error) { return switch (error) { null => genericError, diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 393d437..5486f1e 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -24,7 +24,8 @@ class AppLocalizationsNl extends AppLocalizations { String get noProductsYet => 'Nog geen producten.'; @override - String get addFirstProduct => 'Voeg je eerste product toe om te beginnen.'; + String get addFirstProduct => + 'Voeg je eerste product toe om te beginnen verkopen.'; @override String get manageProducts => 'Producten beheren'; @@ -935,6 +936,79 @@ class AppLocalizationsNl extends AppLocalizations { 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 pinSaved => 'PIN opgeslagen'; + + @override + String deviceLinkedToVenue(String venue) { + return 'Gekoppeld aan $venue'; + } + + @override + String get justNow => 'zonet'; + + @override + String minutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count minuten geleden', + one: '1 minuut geleden', + ); + return '$_temp0'; + } + + @override + String hoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count uur geleden', + one: '1 uur geleden', + ); + return '$_temp0'; + } + + @override + String get remoteCommandCloseTab => 'Poef sluiten via dashboard'; + + @override + String remoteCommandUnknown(String type) { + return 'Onbekende opdracht ($type)'; + } + + @override + String get commandCompleted => 'Uitgevoerd'; + + @override + String get commandConflict => 'Conflict'; + + @override + String get commandFailed => 'Mislukt'; + + @override + String get commandExpired => 'Verlopen'; + + @override + String get searchProducts => 'Producten zoeken…'; + + @override + String get allCategories => 'Alle'; + + @override + String get noProductsMatch => 'Geen producten gevonden.'; + + @override + String get lowStock => 'Bijna op'; + + @override + String get productHidden => 'Verborgen'; + + @override + String productStockCount(int count) { + return 'Voorraad: $count'; + } + @override String get connectingToServer => 'Verbinden met server…'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a0d6b16..27d120d 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -300,6 +300,24 @@ "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.", + "pinSaved": "PIN opgeslagen", + "deviceLinkedToVenue": "Gekoppeld aan {venue}", + "justNow": "zonet", + "minutesAgo": "{count, plural, =1{1 minuut geleden} other{{count} minuten geleden}}", + "hoursAgo": "{count, plural, =1{1 uur geleden} other{{count} uur geleden}}", + "remoteCommandCloseTab": "Poef sluiten via dashboard", + "remoteCommandUnknown": "Onbekende opdracht ({type})", + "commandCompleted": "Uitgevoerd", + "commandConflict": "Conflict", + "commandFailed": "Mislukt", + "commandExpired": "Verlopen", + "searchProducts": "Producten zoeken…", + "allCategories": "Alle", + "noProductsMatch": "Geen producten gevonden.", + "lowStock": "Bijna op", + "productHidden": "Verborgen", + "addFirstProduct": "Voeg je eerste product toe om te beginnen verkopen.", + "productStockCount": "Voorraad: {count}", "connectingToServer": "Verbinden met server…", "failedToConnect": "Verbinden mislukt", "unknownError": "Onbekende fout", diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 05707f8..4da2312 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -62,6 +62,7 @@ class RemoteSyncService { static const _deviceIdKey = 'remote_device_id'; static const _authTokenKey = 'remote_auth_token'; static const _venueIdKey = 'remote_venue_id'; + static const _venueNameKey = 'remote_venue_name'; static const _syncStateId = 'default'; /// Most outbox events sent per sync; a larger backlog drains over syncs. @@ -100,6 +101,9 @@ class RemoteSyncService { Future get venueId => secureStorage.read(key: _venueIdKey); + /// Null for devices linked before the name was kept. + Future get venueName => secureStorage.read(key: _venueNameKey); + Future get isConfigured async => await _credentials() != null; Future get authToken async => (await _credentials())?.token; @@ -758,10 +762,12 @@ class RemoteSyncService { required String deviceId, required String token, required String venueId, + String? venueName, }) async { await secureStorage.write(key: _deviceIdKey, value: deviceId); await secureStorage.write(key: _authTokenKey, value: token); await secureStorage.write(key: _venueIdKey, value: venueId); + await secureStorage.write(key: _venueNameKey, value: venueName); _cachedCredentials = (deviceId: deviceId, token: token); } @@ -770,6 +776,7 @@ class RemoteSyncService { await secureStorage.delete(key: _deviceIdKey); await secureStorage.delete(key: _authTokenKey); await secureStorage.delete(key: _venueIdKey); + await secureStorage.delete(key: _venueNameKey); } Future get lastSuccessfulSyncAt async { diff --git a/lib/viewmodels/inventory_view_model.dart b/lib/viewmodels/inventory_view_model.dart index 5228160..20ea8c4 100644 --- a/lib/viewmodels/inventory_view_model.dart +++ b/lib/viewmodels/inventory_view_model.dart @@ -36,6 +36,16 @@ class InventoryViewModel extends ChangeNotifier { ); } + /// Starts watching again after a load error. + void retry() { + unawaited(_subscription?.cancel()); + _subscription = null; + _hasLoaded = false; + _loadError = null; + notifyListeners(); + ensureLoaded(); + } + void _onProducts(List products) { _products = products; _hasLoaded = true; diff --git a/lib/viewmodels/product_list_view_model.dart b/lib/viewmodels/product_list_view_model.dart index ae22193..fee9396 100644 --- a/lib/viewmodels/product_list_view_model.dart +++ b/lib/viewmodels/product_list_view_model.dart @@ -40,6 +40,8 @@ class ProductListViewModel extends ChangeNotifier { void ensureLoaded() => inventory.ensureLoaded(); + void retry() => inventory.retry(); + Future addProduct({ required String name, required String category, diff --git a/lib/viewmodels/registration_view_model.dart b/lib/viewmodels/registration_view_model.dart index 2715ef1..ff2f7bb 100644 --- a/lib/viewmodels/registration_view_model.dart +++ b/lib/viewmodels/registration_view_model.dart @@ -84,6 +84,7 @@ class RegistrationViewModel extends ChangeNotifier { deviceId: credentials.deviceId, token: credentials.token, venueId: venue.id, + venueName: venue.name, ); _step = RegistrationStep.registered; diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart index a3f73d6..7e9d2a5 100644 --- a/lib/viewmodels/remote_sync_view_model.dart +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -43,6 +43,9 @@ class RemoteSyncViewModel extends ChangeNotifier { String? _venueId; String? get venueId => _venueId; + String? _venueName; + String? get venueName => _venueName; + List _activity = []; List get activity => _activity; @@ -76,6 +79,7 @@ class RemoteSyncViewModel extends ChangeNotifier { Future _refreshStatus() async { _isConfigured = await syncService.isConfigured; _venueId = await syncService.venueId; + _venueName = await syncService.venueName; } Future syncNow() async { @@ -103,6 +107,7 @@ class RemoteSyncViewModel extends ChangeNotifier { _isConfigured = false; _wasRevoked = false; _venueId = null; + _venueName = null; _lastSyncAt = null; _lastError = null; _activity = []; diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index fba4764..a4b2b55 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/app_localizations_helpers.dart'; import '../models/remote_command.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; @@ -42,7 +43,10 @@ class AdminScreenView extends StatelessWidget { ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom(backgroundColor: Colors.red), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), child: Text(l10n.disconnect), ), ], @@ -58,6 +62,8 @@ class AdminScreenView extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final remoteSync = context.watch(); + final scheme = Theme.of(context).colorScheme; + final locale = Localizations.localeOf(context).toLanguageTag(); return PopScope( onPopInvokedWithResult: (didPop, result) { @@ -115,11 +121,16 @@ class AdminScreenView extends StatelessWidget { if (remoteSync.isConfigured) ...[ _AdminTile( icon: Icons.cloud_done_outlined, + iconColor: scheme.primary, title: l10n.connected, - subtitle: l10n.deviceLinked, + subtitle: remoteSync.venueName != null + ? l10n.deviceLinkedToVenue(remoteSync.venueName!) + : l10n.deviceLinked, trailing: TextButton( onPressed: () => _confirmUnlink(context), - style: TextButton.styleFrom(foregroundColor: Colors.red), + style: TextButton.styleFrom( + foregroundColor: scheme.error, + ), child: Text(l10n.disconnect), ), onTap: null, @@ -129,6 +140,7 @@ class AdminScreenView extends StatelessWidget { icon: remoteSync.wasRevoked ? Icons.cloud_off_rounded : Icons.cloud_outlined, + iconColor: remoteSync.wasRevoked ? scheme.error : null, title: l10n.notConnected, subtitle: remoteSync.wasRevoked ? l10n.deviceAccessRevoked @@ -149,10 +161,7 @@ class AdminScreenView extends StatelessWidget { ? l10n.syncing : remoteSync.lastSyncAt != null ? l10n.lastSync( - remoteSync.lastSyncAt! - .toLocal() - .toString() - .substring(0, 19), + l10n.relativeTime(remoteSync.lastSyncAt!, locale), ) : l10n.notSyncedYet, trailing: remoteSync.isSyncing @@ -169,25 +178,29 @@ class AdminScreenView extends StatelessWidget { if (remoteSync.activity.isNotEmpty) _AdminSection( title: l10n.remoteActivityHistory, - children: remoteSync.activity.map((entry) { - return ListTile( - dense: true, - leading: Icon(switch (entry.result) { - CommandResult.completed => Icons.check_circle_outline, - CommandResult.conflict => Icons.warning_amber_outlined, - CommandResult.failed || - CommandResult.expired => Icons.error_outline, - }, size: 20), - title: Text( - '${entry.commandType} (${entry.commandId.substring(0, 8)}...)', - style: const TextStyle(fontSize: 13), + children: [ + for (final entry in remoteSync.activity) + _AdminTile( + icon: switch (entry.result) { + CommandResult.completed => Icons.check_circle_outline, + CommandResult.conflict => + Icons.warning_amber_outlined, + CommandResult.failed || + CommandResult.expired => Icons.error_outline, + }, + iconColor: switch (entry.result) { + CommandResult.completed => scheme.primary, + CommandResult.conflict => scheme.tertiary, + CommandResult.failed || + CommandResult.expired => scheme.error, + }, + title: l10n.remoteCommandLabel(entry.commandType), + subtitle: + '${l10n.commandResultLabel(entry.result)} · ' + '${l10n.relativeTime(entry.processedAt, locale)}', + onTap: null, ), - subtitle: Text( - '${entry.processedAt.toLocal()} - ${entry.result.name}', - style: const TextStyle(fontSize: 11), - ), - ); - }).toList(), + ], ), ], ], @@ -241,6 +254,7 @@ class _AdminSection extends StatelessWidget { class _AdminTile extends StatelessWidget { final IconData icon; + final Color? iconColor; final String title; final String? subtitle; final Widget? trailing; @@ -248,6 +262,7 @@ class _AdminTile extends StatelessWidget { const _AdminTile({ required this.icon, + this.iconColor, required this.title, this.subtitle, this.trailing, @@ -269,7 +284,7 @@ class _AdminTile extends StatelessWidget { Icon( icon, size: 22, - color: scheme.onSurface.withValues(alpha: 0.7), + color: iconColor ?? scheme.onSurface.withValues(alpha: 0.7), ), const SizedBox(width: 14), Expanded( diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index 92182c9..e7aeafe 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -6,23 +6,29 @@ import '../l10n/app_localizations_helpers.dart'; import '../models/app_error.dart'; import '../viewmodels/pin_lock_view_model.dart'; -enum PinEntryMode { unlock, create } +enum PinEntryMode { unlock, create, change } const _pinLength = 4; /// Full-screen PIN entry. In [PinEntryMode.unlock] it verifies a PIN /// against the one already saved. In [PinEntryMode.create] it walks the /// person through entering a new PIN twice before saving it. +/// [PinEntryMode.change] first checks the current PIN, then does the same. class PinEntryView extends StatefulWidget { final PinEntryMode mode; final VoidCallback? onSuccess; final bool isAdmin; final Future Function(String)? onVerify; final Future Function(String)? onSet; + final Future Function(String currentPin, String newPin)? onChange; final AppError? Function()? error; + /// Shows a close button that leaves the screen without entering a PIN. final VoidCallback? onClose; + /// Replaces the title of the step that checks an existing PIN. + final String? title; + const PinEntryView({ super.key, required this.mode, @@ -30,8 +36,10 @@ class PinEntryView extends StatefulWidget { this.isAdmin = false, this.onVerify, this.onSet, + this.onChange, this.error, this.onClose, + this.title, }); @override @@ -41,21 +49,33 @@ class PinEntryView extends StatefulWidget { class _PinEntryViewState extends State { String _digits = ''; String? _firstEntry; + String? _currentPin; bool _isConfirmStep = false; bool _isSubmitting = false; String? _localError; bool _shake = false; - bool get _isCreateFlow => widget.mode == PinEntryMode.create; + bool get _isChangeFlow => widget.mode == PinEntryMode.change; + + bool get _isCreateFlow => + widget.mode == PinEntryMode.create || + (_isChangeFlow && _currentPin != null); String _title(AppLocalizations l10n) { - if (widget.isAdmin) { - if (!_isCreateFlow) return l10n.enterAdminPin; - return _isConfirmStep ? l10n.confirmAdminPin : l10n.createAdminPin; + if (_isChangeFlow && _currentPin == null) { + return widget.title ?? + (widget.isAdmin ? l10n.enterCurrentAdminPin : l10n.enterCurrentPin); } - if (!_isCreateFlow) return l10n.enterPin; - return _isConfirmStep ? l10n.confirmPin : l10n.createPin; + if (widget.isAdmin) { + if (!_isCreateFlow) return widget.title ?? l10n.enterAdminPin; + if (_isConfirmStep) return l10n.confirmAdminPin; + return _isChangeFlow ? l10n.enterNewAdminPin : l10n.createAdminPin; + } + + if (!_isCreateFlow) return widget.title ?? l10n.enterPin; + if (_isConfirmStep) return l10n.confirmPin; + return _isChangeFlow ? l10n.enterNewPin : l10n.createPin; } String? _subtitle(AppLocalizations l10n) { @@ -114,7 +134,9 @@ class _PinEntryViewState extends State { } setState(() => _isSubmitting = true); - final ok = await _setPin(_digits); + final ok = _isChangeFlow + ? await _changePin(_currentPin!, _digits) + : await _setPin(_digits); if (!mounted) return; setState(() => _isSubmitting = false); @@ -135,10 +157,15 @@ class _PinEntryViewState extends State { if (!mounted) return; setState(() => _isSubmitting = false); - if (ok) { - widget.onSuccess?.call(); - } else { + if (!ok) { _fail(l10n.errorText(_error())); + } else if (_isChangeFlow) { + setState(() { + _currentPin = _digits; + _digits = ''; + }); + } else { + widget.onSuccess?.call(); } } @@ -149,6 +176,16 @@ class _PinEntryViewState extends State { return context.read().setPin(pin); } + Future _changePin(String currentPin, String newPin) { + final callback = widget.onChange; + if (callback != null) return callback(currentPin, newPin); + + return context.read().changePin( + currentPin: currentPin, + newPin: newPin, + ); + } + Future _verify(String pin) { final callback = widget.onVerify; if (callback != null) return callback(pin); diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 0e61dfc..0920efd 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -6,6 +6,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'; @@ -18,6 +19,11 @@ class ProductListView extends StatefulWidget { } class _ProductListViewState extends State { + String _query = ''; + + /// Null shows every category. + String? _category; + @override void initState() { super.initState(); @@ -27,6 +33,31 @@ class _ProductListViewState extends State { }); } + /// Products per category label, in label order, after search and filter. + Map> _groupedProducts( + List products, + String? selectedCategory, + AppLocalizations l10n, + ) { + final query = _query.trim().toLowerCase(); + final groups = >{}; + + for (final product in products) { + final category = l10n.categoryLabel(product.category.trim()); + if (selectedCategory != null && category != selectedCategory) { + continue; + } + if (query.isNotEmpty && !product.name.toLowerCase().contains(query)) { + continue; + } + + groups.putIfAbsent(category, () => []).add(product); + } + + final sortedKeys = groups.keys.toList()..sort(); + return {for (final key in sortedKeys) key: groups[key]!}; + } + @override Widget build(BuildContext context) { final viewModel = context.watch(); @@ -53,62 +84,306 @@ class _ProductListViewState extends State { } if (viewModel.loadError != null) { - return Center(child: Text(l10n.errorText(viewModel.loadError))); + return _MessageState( + icon: Icons.error_outline_rounded, + isError: true, + message: l10n.errorText(viewModel.loadError), + action: FilledButton.icon( + onPressed: viewModel.retry, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ); } if (viewModel.products.isEmpty) { - return Center(child: Text(l10n.noProductsYet)); + return _MessageState( + icon: Icons.inventory_2_outlined, + message: l10n.noProductsYet, + detail: l10n.addFirstProduct, + ); } - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: viewModel.products.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final product = viewModel.products[index]; + final categories = { + for (final product in viewModel.products) + l10n.categoryLabel(product.category.trim()), + }.toList()..sort(); + // A category disappears when its last product moves out of it. + final selectedCategory = categories.contains(_category) + ? _category + : null; + final groups = _groupedProducts( + viewModel.products, + selectedCategory, + l10n, + ); - return Card( - child: ListTile( - leading: SizedBox( - width: 56, - height: 56, - child: Center( - child: - product.imagePath != null && - product.imagePath!.isNotEmpty - ? Image.file( - File(product.imagePath!), - fit: BoxFit.contain, - cacheWidth: 112, - errorBuilder: (context, error, stackTrace) => - const Icon( - Icons.image_not_supported_outlined, - ), - ) - : const Icon(Icons.image_not_supported_outlined), - ), + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: TextField( + onChanged: (value) => setState(() => _query = value), + decoration: InputDecoration( + hintText: l10n.searchProducts, + prefixIcon: const Icon(Icons.search_rounded, size: 20), + isDense: true, ), - title: Text(product.name), - subtitle: Text( - product.infiniteStock - ? l10n.productInfiniteStockSummary( - l10n.categoryLabel(product.category), - product.priceInCents.asEuro, - ) - : l10n.productStockSummary( - l10n.categoryLabel(product.category), - product.priceInCents.asEuro, - product.stockQuantity, - ), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('/products/${product.id}/edit'), ), - ); - }, + ), + if (categories.length > 1) + SizedBox( + height: 52, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + children: [ + for (final category in [null, ...categories]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(category ?? l10n.allCategories), + selected: selectedCategory == category, + onSelected: (_) => + setState(() => _category = category), + ), + ), + ], + ), + ), + Expanded( + child: groups.isEmpty + ? _MessageState( + icon: Icons.search_off_rounded, + message: l10n.noProductsMatch, + ) + : ListView( + // Leaves room to scroll the last item past the button. + padding: const EdgeInsets.fromLTRB(16, 4, 16, 96), + children: [ + for (final MapEntry(key: category, value: products) + in groups.entries) ...[ + _CategoryHeader( + title: category, + count: products.length, + ), + for (final product in products) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ProductRow(product: product), + ), + ], + ], + ), + ), + ], ); }, ), ); } } + +class _CategoryHeader extends StatelessWidget { + final String title; + final int count; + + const _CategoryHeader({required this.title, required this.count}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 8), + child: Text( + '${title.toUpperCase()} · $count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: scheme.onSurface.withValues(alpha: 0.5), + ), + ), + ); + } +} + +class _ProductRow extends StatelessWidget { + final Product product; + + const _ProductRow({required this.product}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + final imagePath = product.imagePath; + + final (String, Color, Color)? badge = !product.active + ? (l10n.productHidden, scheme.surfaceContainerHighest, scheme.onSurface) + : product.isOutOfStock + ? (l10n.outOfStock, scheme.errorContainer, scheme.onErrorContainer) + : product.isLowStock + ? (l10n.lowStock, scheme.tertiaryContainer, scheme.onTertiaryContainer) + : null; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.push('/products/${product.id}/edit'), + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.antiAlias, + child: imagePath != null && imagePath.isNotEmpty + ? Image.file( + File(imagePath), + fit: BoxFit.contain, + cacheWidth: 112, + errorBuilder: (context, error, stackTrace) => + const _ImagePlaceholder(), + ) + : const _ImagePlaceholder(), + ), + const SizedBox(width: 14), + Expanded( + child: Opacity( + opacity: product.active ? 1 : 0.5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + product.infiniteStock + ? '∞' + : l10n.productStockCount(product.stockQuantity), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ), + if (badge case (final label, final background, final foreground)) + Container( + margin: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + product.priceInCents.asEuro, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + color: scheme.onSurface.withValues(alpha: 0.3), + ), + ], + ), + ), + ), + ); + } +} + +class _ImagePlaceholder extends StatelessWidget { + const _ImagePlaceholder(); + + @override + Widget build(BuildContext context) { + return Icon( + Icons.local_bar_outlined, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ); + } +} + +class _MessageState extends StatelessWidget { + final IconData icon; + final String message; + final String? detail; + final Widget? action; + final bool isError; + + const _MessageState({ + required this.icon, + required this.message, + this.detail, + this.action, + this.isError = false, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 48, + color: isError + ? scheme.error + : scheme.onSurface.withValues(alpha: 0.35), + ), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + if (detail != null) ...[ + const SizedBox(height: 6), + Text( + detail!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + if (action != null) ...[const SizedBox(height: 20), action!], + ], + ), + ), + ); + } +} diff --git a/lib/views/registration_view.dart b/lib/views/registration_view.dart index 3be1d99..b16eb9a 100644 --- a/lib/views/registration_view.dart +++ b/lib/views/registration_view.dart @@ -434,9 +434,7 @@ class _RegistrationViewState extends State { ), const SizedBox(height: 32), FilledButton.icon( - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - ), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)), onPressed: _finish, icon: const Icon(Icons.arrow_forward), label: Text(l10n.done), diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 363998d..21e06cd 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -8,13 +8,14 @@ import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../l10n/app_localizations.dart'; -import '../l10n/app_localizations_helpers.dart'; +import '../models/app_error.dart'; import '../models/settings.dart'; import '../utils/app_update_util.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/settings_view_model.dart'; +import 'pin_lock_view.dart'; class SettingsScreenView extends StatefulWidget { const SettingsScreenView({super.key}); @@ -80,40 +81,34 @@ class _SettingsScreenViewState extends State { }); } - Future _promptPin(String title, {String? hint}) { - final controller = TextEditingController(); - final l10n = AppLocalizations.of(context); - - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), - content: SizedBox( - width: 320, - child: TextField( - controller: controller, - autofocus: true, - obscureText: true, - keyboardType: TextInputType.number, - maxLength: 4, - decoration: InputDecoration(hintText: hint), - ), + /// Shows the PIN keypad full screen; true once the PIN step succeeded. + Future _showPinEntry({ + required PinEntryMode mode, + bool isAdmin = false, + String? title, + Future Function(String)? onVerify, + Future Function(String)? onSet, + Future Function(String, String)? onChange, + AppError? Function()? error, + }) async { + final succeeded = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (routeContext) => PinEntryView( + mode: mode, + isAdmin: isAdmin, + title: title, + onVerify: onVerify, + onSet: onSet, + onChange: onChange, + error: error, + onSuccess: () => Navigator.pop(routeContext, true), + onClose: () => Navigator.pop(routeContext, false), ), - actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancel), - ), - const SizedBox(width: 12), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text), - child: Text(l10n.confirm), - ), - ], ), ); + + return succeeded ?? false; } Future _pickOption( @@ -140,7 +135,7 @@ class _SettingsScreenViewState extends State { ); } - void _showError(String message) { + void _showMessage(String message) { if (!mounted) return; ScaffoldMessenger.of( context, @@ -151,46 +146,19 @@ class _SettingsScreenViewState extends State { final l10n = AppLocalizations.of(context); final pinLockViewModel = context.read(); final settingsViewModel = context.read(); - final pin = await _promptPin(l10n.setPinCode, hint: l10n.fourDigits); - if (pin == null) return; - if (pin.length != 4) { - _showError(l10n.pinExactlyFour); - return; - } - - final success = await pinLockViewModel.setPin(pin); - - if (!success) { - _showError(l10n.errorText(pinLockViewModel.error)); - return; - } + if (!await _showPinEntry(mode: PinEntryMode.create)) return; pinLockViewModel.setPinRequired(true); await settingsViewModel.updatePinRequired(true); + _showMessage(l10n.pinSaved); } Future _changePin() async { final l10n = AppLocalizations.of(context); - final pinLockViewModel = context.read(); - final current = await _promptPin(l10n.enterCurrentPin); - if (current == null) return; - final newPin = await _promptPin(l10n.enterNewPin, hint: l10n.fourDigits); - if (newPin == null) return; - - if (newPin.length != 4) { - _showError(l10n.pinExactlyFour); - return; - } - - final success = await pinLockViewModel.changePin( - currentPin: current, - newPin: newPin, - ); - - if (!success) { - _showError(l10n.errorText(pinLockViewModel.error)); + if (await _showPinEntry(mode: PinEntryMode.change)) { + _showMessage(l10n.pinSaved); } } @@ -198,15 +166,13 @@ class _SettingsScreenViewState extends State { final l10n = AppLocalizations.of(context); final pinLockViewModel = context.read(); final settingsViewModel = context.read(); - final current = await _promptPin(l10n.enterCurrentPin); - if (current == null) return; - final success = await pinLockViewModel.disablePin(current); - - if (!success) { - _showError(l10n.errorText(pinLockViewModel.error)); - return; - } + final disabled = await _showPinEntry( + mode: PinEntryMode.unlock, + title: l10n.enterCurrentPin, + onVerify: pinLockViewModel.disablePin, + ); + if (!disabled) return; pinLockViewModel.setPinRequired(false); await settingsViewModel.updatePinRequired(false); @@ -216,46 +182,25 @@ class _SettingsScreenViewState extends State { final l10n = AppLocalizations.of(context); final adminPinViewModel = context.read(); - if (!adminPinViewModel.isPinSet) { - final pin = await _promptPin(l10n.setAdminPin, hint: l10n.fourDigits); - if (pin == null) return; + final saved = adminPinViewModel.isPinSet + ? await _showPinEntry( + mode: PinEntryMode.change, + isAdmin: true, + onVerify: adminPinViewModel.verify, + onChange: (currentPin, newPin) => adminPinViewModel.changePin( + currentPin: currentPin, + newPin: newPin, + ), + error: () => adminPinViewModel.error, + ) + : await _showPinEntry( + mode: PinEntryMode.create, + isAdmin: true, + onSet: adminPinViewModel.setPin, + error: () => adminPinViewModel.error, + ); - if (pin.length != 4) { - _showError(l10n.pinExactlyFour); - return; - } - - final success = await adminPinViewModel.setPin(pin); - if (!success) { - _showError(l10n.errorText(adminPinViewModel.error)); - return; - } - - return; - } - - final current = await _promptPin(l10n.enterCurrentAdminPin); - if (current == null) return; - - final newPin = await _promptPin( - l10n.enterNewAdminPin, - hint: l10n.fourDigits, - ); - if (newPin == null) return; - - if (newPin.length != 4) { - _showError(l10n.pinExactlyFour); - return; - } - - final success = await adminPinViewModel.changePin( - currentPin: current, - newPin: newPin, - ); - - if (!success) { - _showError(l10n.errorText(adminPinViewModel.error)); - } + if (saved) _showMessage(l10n.pinSaved); } @override diff --git a/pubspec.yaml b/pubspec.yaml index 2fd4b06..dfcd939 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.3.9+0 +version: 1.3.10+0 environment: sdk: ^3.12.2