feat: reformat some pages
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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…';
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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…';
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user