feat: reformat some pages

This commit is contained in:
2026-10-26 23:15:05 +01:00
parent 65e5e286b0
commit 08f4847800
17 changed files with 812 additions and 198 deletions
+23
View File
@@ -301,6 +301,29 @@
"duplicateTabNameDescription": "A tab named {name} is already open. Use this name anyway?", "duplicateTabNameDescription": "A tab named {name} is already open. Use this name anyway?",
"@duplicateTabNameDescription": {"placeholders": {"name": {"type": "String"}}}, "@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.", "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…", "connectingToServer": "Connecting to server…",
"failedToConnect": "Failed to connect", "failedToConnect": "Failed to connect",
"unknownError": "Unknown error", "unknownError": "Unknown error",
+102
View File
@@ -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.'** /// **'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; 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. /// No description provided for @connectingToServer.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+73
View File
@@ -923,6 +923,79 @@ class AppLocalizationsEn extends AppLocalizations {
String get deleteTabWithItemsDescription => 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.'; '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 @override
String get connectingToServer => 'Connecting to server…'; String get connectingToServer => 'Connecting to server…';
+29
View File
@@ -1,4 +1,7 @@
import 'package:intl/intl.dart';
import '../models/app_error.dart'; import '../models/app_error.dart';
import '../models/remote_command.dart';
import '../models/update_failure.dart'; import '../models/update_failure.dart';
import 'app_localizations.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) { String errorText(AppError? error) {
return switch (error) { return switch (error) {
null => genericError, null => genericError,
+75 -1
View File
@@ -24,7 +24,8 @@ class AppLocalizationsNl extends AppLocalizations {
String get noProductsYet => 'Nog geen producten.'; String get noProductsYet => 'Nog geen producten.';
@override @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 @override
String get manageProducts => 'Producten beheren'; String get manageProducts => 'Producten beheren';
@@ -935,6 +936,79 @@ class AppLocalizationsNl extends AppLocalizations {
String get deleteTabWithItemsDescription => 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.'; '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 @override
String get connectingToServer => 'Verbinden met server…'; String get connectingToServer => 'Verbinden met server…';
+18
View File
@@ -300,6 +300,24 @@
"duplicateTabName": "Naam al in gebruik", "duplicateTabName": "Naam al in gebruik",
"duplicateTabNameDescription": "Er is al een poef open met de naam {name}. Toch deze naam gebruiken?", "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.", "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…", "connectingToServer": "Verbinden met server…",
"failedToConnect": "Verbinden mislukt", "failedToConnect": "Verbinden mislukt",
"unknownError": "Onbekende fout", "unknownError": "Onbekende fout",
+7
View File
@@ -62,6 +62,7 @@ class RemoteSyncService {
static const _deviceIdKey = 'remote_device_id'; static const _deviceIdKey = 'remote_device_id';
static const _authTokenKey = 'remote_auth_token'; static const _authTokenKey = 'remote_auth_token';
static const _venueIdKey = 'remote_venue_id'; static const _venueIdKey = 'remote_venue_id';
static const _venueNameKey = 'remote_venue_name';
static const _syncStateId = 'default'; static const _syncStateId = 'default';
/// Most outbox events sent per sync; a larger backlog drains over syncs. /// Most outbox events sent per sync; a larger backlog drains over syncs.
@@ -100,6 +101,9 @@ class RemoteSyncService {
Future<String?> get venueId => secureStorage.read(key: _venueIdKey); Future<String?> get venueId => secureStorage.read(key: _venueIdKey);
/// Null for devices linked before the name was kept.
Future<String?> get venueName => secureStorage.read(key: _venueNameKey);
Future<bool> get isConfigured async => await _credentials() != null; Future<bool> get isConfigured async => await _credentials() != null;
Future<String?> get authToken async => (await _credentials())?.token; Future<String?> get authToken async => (await _credentials())?.token;
@@ -758,10 +762,12 @@ class RemoteSyncService {
required String deviceId, required String deviceId,
required String token, required String token,
required String venueId, required String venueId,
String? venueName,
}) async { }) async {
await secureStorage.write(key: _deviceIdKey, value: deviceId); await secureStorage.write(key: _deviceIdKey, value: deviceId);
await secureStorage.write(key: _authTokenKey, value: token); await secureStorage.write(key: _authTokenKey, value: token);
await secureStorage.write(key: _venueIdKey, value: venueId); await secureStorage.write(key: _venueIdKey, value: venueId);
await secureStorage.write(key: _venueNameKey, value: venueName);
_cachedCredentials = (deviceId: deviceId, token: token); _cachedCredentials = (deviceId: deviceId, token: token);
} }
@@ -770,6 +776,7 @@ class RemoteSyncService {
await secureStorage.delete(key: _deviceIdKey); await secureStorage.delete(key: _deviceIdKey);
await secureStorage.delete(key: _authTokenKey); await secureStorage.delete(key: _authTokenKey);
await secureStorage.delete(key: _venueIdKey); await secureStorage.delete(key: _venueIdKey);
await secureStorage.delete(key: _venueNameKey);
} }
Future<DateTime?> get lastSuccessfulSyncAt async { Future<DateTime?> get lastSuccessfulSyncAt async {
+10
View File
@@ -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<Product> products) { void _onProducts(List<Product> products) {
_products = products; _products = products;
_hasLoaded = true; _hasLoaded = true;
@@ -40,6 +40,8 @@ class ProductListViewModel extends ChangeNotifier {
void ensureLoaded() => inventory.ensureLoaded(); void ensureLoaded() => inventory.ensureLoaded();
void retry() => inventory.retry();
Future<void> addProduct({ Future<void> addProduct({
required String name, required String name,
required String category, required String category,
@@ -84,6 +84,7 @@ class RegistrationViewModel extends ChangeNotifier {
deviceId: credentials.deviceId, deviceId: credentials.deviceId,
token: credentials.token, token: credentials.token,
venueId: venue.id, venueId: venue.id,
venueName: venue.name,
); );
_step = RegistrationStep.registered; _step = RegistrationStep.registered;
@@ -43,6 +43,9 @@ class RemoteSyncViewModel extends ChangeNotifier {
String? _venueId; String? _venueId;
String? get venueId => _venueId; String? get venueId => _venueId;
String? _venueName;
String? get venueName => _venueName;
List<ProcessedRemoteCommandRow> _activity = []; List<ProcessedRemoteCommandRow> _activity = [];
List<ProcessedRemoteCommandRow> get activity => _activity; List<ProcessedRemoteCommandRow> get activity => _activity;
@@ -76,6 +79,7 @@ class RemoteSyncViewModel extends ChangeNotifier {
Future<void> _refreshStatus() async { Future<void> _refreshStatus() async {
_isConfigured = await syncService.isConfigured; _isConfigured = await syncService.isConfigured;
_venueId = await syncService.venueId; _venueId = await syncService.venueId;
_venueName = await syncService.venueName;
} }
Future<void> syncNow() async { Future<void> syncNow() async {
@@ -103,6 +107,7 @@ class RemoteSyncViewModel extends ChangeNotifier {
_isConfigured = false; _isConfigured = false;
_wasRevoked = false; _wasRevoked = false;
_venueId = null; _venueId = null;
_venueName = null;
_lastSyncAt = null; _lastSyncAt = null;
_lastError = null; _lastError = null;
_activity = []; _activity = [];
+38 -23
View File
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../models/remote_command.dart'; import '../models/remote_command.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/admin_pin_view_model.dart';
@@ -42,7 +43,10 @@ class AdminScreenView extends StatelessWidget {
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red), style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: Text(l10n.disconnect), child: Text(l10n.disconnect),
), ),
], ],
@@ -58,6 +62,8 @@ class AdminScreenView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final remoteSync = context.watch<RemoteSyncViewModel>(); final remoteSync = context.watch<RemoteSyncViewModel>();
final scheme = Theme.of(context).colorScheme;
final locale = Localizations.localeOf(context).toLanguageTag();
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
@@ -115,11 +121,16 @@ class AdminScreenView extends StatelessWidget {
if (remoteSync.isConfigured) ...[ if (remoteSync.isConfigured) ...[
_AdminTile( _AdminTile(
icon: Icons.cloud_done_outlined, icon: Icons.cloud_done_outlined,
iconColor: scheme.primary,
title: l10n.connected, title: l10n.connected,
subtitle: l10n.deviceLinked, subtitle: remoteSync.venueName != null
? l10n.deviceLinkedToVenue(remoteSync.venueName!)
: l10n.deviceLinked,
trailing: TextButton( trailing: TextButton(
onPressed: () => _confirmUnlink(context), onPressed: () => _confirmUnlink(context),
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(
foregroundColor: scheme.error,
),
child: Text(l10n.disconnect), child: Text(l10n.disconnect),
), ),
onTap: null, onTap: null,
@@ -129,6 +140,7 @@ class AdminScreenView extends StatelessWidget {
icon: remoteSync.wasRevoked icon: remoteSync.wasRevoked
? Icons.cloud_off_rounded ? Icons.cloud_off_rounded
: Icons.cloud_outlined, : Icons.cloud_outlined,
iconColor: remoteSync.wasRevoked ? scheme.error : null,
title: l10n.notConnected, title: l10n.notConnected,
subtitle: remoteSync.wasRevoked subtitle: remoteSync.wasRevoked
? l10n.deviceAccessRevoked ? l10n.deviceAccessRevoked
@@ -149,10 +161,7 @@ class AdminScreenView extends StatelessWidget {
? l10n.syncing ? l10n.syncing
: remoteSync.lastSyncAt != null : remoteSync.lastSyncAt != null
? l10n.lastSync( ? l10n.lastSync(
remoteSync.lastSyncAt! l10n.relativeTime(remoteSync.lastSyncAt!, locale),
.toLocal()
.toString()
.substring(0, 19),
) )
: l10n.notSyncedYet, : l10n.notSyncedYet,
trailing: remoteSync.isSyncing trailing: remoteSync.isSyncing
@@ -169,25 +178,29 @@ class AdminScreenView extends StatelessWidget {
if (remoteSync.activity.isNotEmpty) if (remoteSync.activity.isNotEmpty)
_AdminSection( _AdminSection(
title: l10n.remoteActivityHistory, title: l10n.remoteActivityHistory,
children: remoteSync.activity.map((entry) { children: [
return ListTile( for (final entry in remoteSync.activity)
dense: true, _AdminTile(
leading: Icon(switch (entry.result) { icon: switch (entry.result) {
CommandResult.completed => Icons.check_circle_outline, CommandResult.completed => Icons.check_circle_outline,
CommandResult.conflict => Icons.warning_amber_outlined, CommandResult.conflict =>
Icons.warning_amber_outlined,
CommandResult.failed || CommandResult.failed ||
CommandResult.expired => Icons.error_outline, CommandResult.expired => Icons.error_outline,
}, size: 20), },
title: Text( iconColor: switch (entry.result) {
'${entry.commandType} (${entry.commandId.substring(0, 8)}...)', CommandResult.completed => scheme.primary,
style: const TextStyle(fontSize: 13), 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 { class _AdminTile extends StatelessWidget {
final IconData icon; final IconData icon;
final Color? iconColor;
final String title; final String title;
final String? subtitle; final String? subtitle;
final Widget? trailing; final Widget? trailing;
@@ -248,6 +262,7 @@ class _AdminTile extends StatelessWidget {
const _AdminTile({ const _AdminTile({
required this.icon, required this.icon,
this.iconColor,
required this.title, required this.title,
this.subtitle, this.subtitle,
this.trailing, this.trailing,
@@ -269,7 +284,7 @@ class _AdminTile extends StatelessWidget {
Icon( Icon(
icon, icon,
size: 22, size: 22,
color: scheme.onSurface.withValues(alpha: 0.7), color: iconColor ?? scheme.onSurface.withValues(alpha: 0.7),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
+48 -11
View File
@@ -6,23 +6,29 @@ import '../l10n/app_localizations_helpers.dart';
import '../models/app_error.dart'; import '../models/app_error.dart';
import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart';
enum PinEntryMode { unlock, create } enum PinEntryMode { unlock, create, change }
const _pinLength = 4; const _pinLength = 4;
/// Full-screen PIN entry. In [PinEntryMode.unlock] it verifies a PIN /// Full-screen PIN entry. In [PinEntryMode.unlock] it verifies a PIN
/// against the one already saved. In [PinEntryMode.create] it walks the /// against the one already saved. In [PinEntryMode.create] it walks the
/// person through entering a new PIN twice before saving it. /// 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 { class PinEntryView extends StatefulWidget {
final PinEntryMode mode; final PinEntryMode mode;
final VoidCallback? onSuccess; final VoidCallback? onSuccess;
final bool isAdmin; final bool isAdmin;
final Future<bool> Function(String)? onVerify; final Future<bool> Function(String)? onVerify;
final Future<bool> Function(String)? onSet; final Future<bool> Function(String)? onSet;
final Future<bool> Function(String currentPin, String newPin)? onChange;
final AppError? Function()? error; final AppError? Function()? error;
/// Shows a close button that leaves the screen without entering a PIN. /// Shows a close button that leaves the screen without entering a PIN.
final VoidCallback? onClose; final VoidCallback? onClose;
/// Replaces the title of the step that checks an existing PIN.
final String? title;
const PinEntryView({ const PinEntryView({
super.key, super.key,
required this.mode, required this.mode,
@@ -30,8 +36,10 @@ class PinEntryView extends StatefulWidget {
this.isAdmin = false, this.isAdmin = false,
this.onVerify, this.onVerify,
this.onSet, this.onSet,
this.onChange,
this.error, this.error,
this.onClose, this.onClose,
this.title,
}); });
@override @override
@@ -41,21 +49,33 @@ class PinEntryView extends StatefulWidget {
class _PinEntryViewState extends State<PinEntryView> { class _PinEntryViewState extends State<PinEntryView> {
String _digits = ''; String _digits = '';
String? _firstEntry; String? _firstEntry;
String? _currentPin;
bool _isConfirmStep = false; bool _isConfirmStep = false;
bool _isSubmitting = false; bool _isSubmitting = false;
String? _localError; String? _localError;
bool _shake = false; 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) { String _title(AppLocalizations l10n) {
if (widget.isAdmin) { if (_isChangeFlow && _currentPin == null) {
if (!_isCreateFlow) return l10n.enterAdminPin; return widget.title ??
return _isConfirmStep ? l10n.confirmAdminPin : l10n.createAdminPin; (widget.isAdmin ? l10n.enterCurrentAdminPin : l10n.enterCurrentPin);
} }
if (!_isCreateFlow) return l10n.enterPin; if (widget.isAdmin) {
return _isConfirmStep ? l10n.confirmPin : l10n.createPin; 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) { String? _subtitle(AppLocalizations l10n) {
@@ -114,7 +134,9 @@ class _PinEntryViewState extends State<PinEntryView> {
} }
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
final ok = await _setPin(_digits); final ok = _isChangeFlow
? await _changePin(_currentPin!, _digits)
: await _setPin(_digits);
if (!mounted) return; if (!mounted) return;
setState(() => _isSubmitting = false); setState(() => _isSubmitting = false);
@@ -135,10 +157,15 @@ class _PinEntryViewState extends State<PinEntryView> {
if (!mounted) return; if (!mounted) return;
setState(() => _isSubmitting = false); setState(() => _isSubmitting = false);
if (ok) { if (!ok) {
widget.onSuccess?.call();
} else {
_fail(l10n.errorText(_error())); _fail(l10n.errorText(_error()));
} else if (_isChangeFlow) {
setState(() {
_currentPin = _digits;
_digits = '';
});
} else {
widget.onSuccess?.call();
} }
} }
@@ -149,6 +176,16 @@ class _PinEntryViewState extends State<PinEntryView> {
return context.read<PinLockViewModel>().setPin(pin); return context.read<PinLockViewModel>().setPin(pin);
} }
Future<bool> _changePin(String currentPin, String newPin) {
final callback = widget.onChange;
if (callback != null) return callback(currentPin, newPin);
return context.read<PinLockViewModel>().changePin(
currentPin: currentPin,
newPin: newPin,
);
}
Future<bool> _verify(String pin) { Future<bool> _verify(String pin) {
final callback = widget.onVerify; final callback = widget.onVerify;
if (callback != null) return callback(pin); if (callback != null) return callback(pin);
+307 -32
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart'; import '../l10n/app_localizations_helpers.dart';
import '../models/product.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.dart'; import '../viewmodels/product_list_view_model.dart';
@@ -18,6 +19,11 @@ class ProductListView extends StatefulWidget {
} }
class _ProductListViewState extends State<ProductListView> { class _ProductListViewState extends State<ProductListView> {
String _query = '';
/// Null shows every category.
String? _category;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -27,6 +33,31 @@ class _ProductListViewState extends State<ProductListView> {
}); });
} }
/// Products per category label, in label order, after search and filter.
Map<String, List<Product>> _groupedProducts(
List<Product> products,
String? selectedCategory,
AppLocalizations l10n,
) {
final query = _query.trim().toLowerCase();
final groups = <String, List<Product>>{};
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final viewModel = context.watch<ProductListViewModel>(); final viewModel = context.watch<ProductListViewModel>();
@@ -53,61 +84,305 @@ class _ProductListViewState extends State<ProductListView> {
} }
if (viewModel.loadError != null) { 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) { 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( final categories = {
padding: const EdgeInsets.all(16), for (final product in viewModel.products)
itemCount: viewModel.products.length, l10n.categoryLabel(product.category.trim()),
separatorBuilder: (_, _) => const SizedBox(height: 8), }.toList()..sort();
itemBuilder: (context, index) { // A category disappears when its last product moves out of it.
final product = viewModel.products[index]; final selectedCategory = categories.contains(_category)
? _category
: null;
final groups = _groupedProducts(
viewModel.products,
selectedCategory,
l10n,
);
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,
),
),
),
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( return Card(
child: ListTile( margin: EdgeInsets.zero,
leading: SizedBox( 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, width: 56,
height: 56, height: 56,
child: Center( decoration: BoxDecoration(
child: color: scheme.onSurface.withValues(alpha: 0.04),
product.imagePath != null && borderRadius: BorderRadius.circular(10),
product.imagePath!.isNotEmpty ),
clipBehavior: Clip.antiAlias,
child: imagePath != null && imagePath.isNotEmpty
? Image.file( ? Image.file(
File(product.imagePath!), File(imagePath),
fit: BoxFit.contain, fit: BoxFit.contain,
cacheWidth: 112, cacheWidth: 112,
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>
const Icon( const _ImagePlaceholder(),
Icons.image_not_supported_outlined,
),
) )
: const Icon(Icons.image_not_supported_outlined), : 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,
), ),
title: Text(product.name), const SizedBox(height: 2),
subtitle: Text( Text(
product.infiniteStock product.infiniteStock
? l10n.productInfiniteStockSummary( ? '∞'
l10n.categoryLabel(product.category), : 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, product.priceInCents.asEuro,
) style: Theme.of(
: l10n.productStockSummary( context,
l10n.categoryLabel(product.category), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
product.priceInCents.asEuro, ),
product.stockQuantity, const SizedBox(width: 4),
Icon(
Icons.chevron_right_rounded,
color: scheme.onSurface.withValues(alpha: 0.3),
),
],
), ),
), ),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/products/${product.id}/edit'),
), ),
); );
}, }
}
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!],
],
),
), ),
); );
} }
+1 -3
View File
@@ -434,9 +434,7 @@ class _RegistrationViewState extends State<RegistrationView> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
FilledButton.icon( FilledButton.icon(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)),
minimumSize: const Size.fromHeight(52),
),
onPressed: _finish, onPressed: _finish,
icon: const Icon(Icons.arrow_forward), icon: const Icon(Icons.arrow_forward),
label: Text(l10n.done), label: Text(l10n.done),
+54 -109
View File
@@ -8,13 +8,14 @@ import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart'; import '../models/app_error.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../utils/app_update_util.dart'; import '../utils/app_update_util.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart';
import '../viewmodels/settings_view_model.dart'; import '../viewmodels/settings_view_model.dart';
import 'pin_lock_view.dart';
class SettingsScreenView extends StatefulWidget { class SettingsScreenView extends StatefulWidget {
const SettingsScreenView({super.key}); const SettingsScreenView({super.key});
@@ -80,40 +81,34 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
}); });
} }
Future<String?> _promptPin(String title, {String? hint}) { /// Shows the PIN keypad full screen; true once the PIN step succeeded.
final controller = TextEditingController(); Future<bool> _showPinEntry({
final l10n = AppLocalizations.of(context); required PinEntryMode mode,
bool isAdmin = false,
return showDialog<String>( String? title,
context: context, Future<bool> Function(String)? onVerify,
builder: (context) => AlertDialog( Future<bool> Function(String)? onSet,
title: Text(title), Future<bool> Function(String, String)? onChange,
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), AppError? Function()? error,
content: SizedBox( }) async {
width: 320, final succeeded = await Navigator.of(context).push<bool>(
child: TextField( MaterialPageRoute(
controller: controller, fullscreenDialog: true,
autofocus: true, builder: (routeContext) => PinEntryView(
obscureText: true, mode: mode,
keyboardType: TextInputType.number, isAdmin: isAdmin,
maxLength: 4, title: title,
decoration: InputDecoration(hintText: hint), 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<T?> _pickOption<T>( Future<T?> _pickOption<T>(
@@ -140,7 +135,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
); );
} }
void _showError(String message) { void _showMessage(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
@@ -151,46 +146,19 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final pinLockViewModel = context.read<PinLockViewModel>(); final pinLockViewModel = context.read<PinLockViewModel>();
final settingsViewModel = context.read<SettingsViewModel>(); final settingsViewModel = context.read<SettingsViewModel>();
final pin = await _promptPin(l10n.setPinCode, hint: l10n.fourDigits);
if (pin == null) return;
if (pin.length != 4) { if (!await _showPinEntry(mode: PinEntryMode.create)) return;
_showError(l10n.pinExactlyFour);
return;
}
final success = await pinLockViewModel.setPin(pin);
if (!success) {
_showError(l10n.errorText(pinLockViewModel.error));
return;
}
pinLockViewModel.setPinRequired(true); pinLockViewModel.setPinRequired(true);
await settingsViewModel.updatePinRequired(true); await settingsViewModel.updatePinRequired(true);
_showMessage(l10n.pinSaved);
} }
Future<void> _changePin() async { Future<void> _changePin() async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final pinLockViewModel = context.read<PinLockViewModel>();
final current = await _promptPin(l10n.enterCurrentPin);
if (current == null) return;
final newPin = await _promptPin(l10n.enterNewPin, hint: l10n.fourDigits); if (await _showPinEntry(mode: PinEntryMode.change)) {
if (newPin == null) return; _showMessage(l10n.pinSaved);
if (newPin.length != 4) {
_showError(l10n.pinExactlyFour);
return;
}
final success = await pinLockViewModel.changePin(
currentPin: current,
newPin: newPin,
);
if (!success) {
_showError(l10n.errorText(pinLockViewModel.error));
} }
} }
@@ -198,15 +166,13 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final pinLockViewModel = context.read<PinLockViewModel>(); final pinLockViewModel = context.read<PinLockViewModel>();
final settingsViewModel = context.read<SettingsViewModel>(); final settingsViewModel = context.read<SettingsViewModel>();
final current = await _promptPin(l10n.enterCurrentPin);
if (current == null) return;
final success = await pinLockViewModel.disablePin(current); final disabled = await _showPinEntry(
mode: PinEntryMode.unlock,
if (!success) { title: l10n.enterCurrentPin,
_showError(l10n.errorText(pinLockViewModel.error)); onVerify: pinLockViewModel.disablePin,
return; );
} if (!disabled) return;
pinLockViewModel.setPinRequired(false); pinLockViewModel.setPinRequired(false);
await settingsViewModel.updatePinRequired(false); await settingsViewModel.updatePinRequired(false);
@@ -216,46 +182,25 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final adminPinViewModel = context.read<AdminPinViewModel>(); final adminPinViewModel = context.read<AdminPinViewModel>();
if (!adminPinViewModel.isPinSet) { final saved = adminPinViewModel.isPinSet
final pin = await _promptPin(l10n.setAdminPin, hint: l10n.fourDigits); ? await _showPinEntry(
if (pin == null) return; mode: PinEntryMode.change,
isAdmin: true,
if (pin.length != 4) { onVerify: adminPinViewModel.verify,
_showError(l10n.pinExactlyFour); onChange: (currentPin, newPin) => adminPinViewModel.changePin(
return; currentPin: currentPin,
}
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, newPin: newPin,
),
error: () => adminPinViewModel.error,
)
: await _showPinEntry(
mode: PinEntryMode.create,
isAdmin: true,
onSet: adminPinViewModel.setPin,
error: () => adminPinViewModel.error,
); );
if (!success) { if (saved) _showMessage(l10n.pinSaved);
_showError(l10n.errorText(adminPinViewModel.error));
}
} }
@override @override
+1 -1
View File
@@ -2,7 +2,7 @@ name: kooltap
description: "KoolTap: a tablet point-of-sale app for running bar tabs." description: "KoolTap: a tablet point-of-sale app for running bar tabs."
publish_to: 'none' publish_to: 'none'
version: 1.3.9+0 version: 1.3.10+0
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2