From 072f5609890e84741edcc3d98ecb6436ae5d0eef Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sun, 11 Oct 2026 20:37:59 +0200 Subject: [PATCH] feat: alphabet list, change Layout etc --- lib/app/router.dart | 10 +- lib/database/app_database.dart | 10 +- lib/database/app_database.g.dart | 77 +++ lib/database/stock_queries.dart | 38 +- lib/l10n/app_en.arb | 33 +- lib/l10n/app_localizations.dart | 168 +++-- lib/l10n/app_localizations_en.dart | 97 +-- lib/l10n/app_localizations_helpers.dart | 48 +- lib/l10n/app_localizations_nl.dart | 111 ++-- lib/l10n/app_nl.arb | 45 +- lib/models/product.dart | 12 +- lib/models/update_failure.dart | 11 + lib/services/product_service.dart | 5 + lib/utils/name_index.dart | 35 + lib/viewmodels/product_list_view_model.dart | 2 + .../update_progress_view_model.dart | 253 ++++---- lib/views/admin_screen_view.dart | 17 + lib/views/bar/bar_screen_view.dart | 14 +- lib/views/bar/product_grid.dart | 45 +- lib/views/bar/tab_panel.dart | 556 +++++++++++----- lib/views/dialogs/new_tab_dialog.dart | 21 +- lib/views/product_form_view.dart | 92 ++- lib/views/product_list_view.dart | 15 +- lib/views/update_progress_view.dart | 614 +++++++++++------- lib/views/widgets/product_tile.dart | 12 +- make_release.ps1 | 46 +- pubspec.yaml | 2 +- test/database_migration_test.dart | 16 +- test/product_grid_test.dart | 72 ++ test/product_service_test.dart | 59 ++ test/tab_panel_index_test.dart | 143 ++++ 31 files changed, 1847 insertions(+), 832 deletions(-) create mode 100644 lib/models/update_failure.dart create mode 100644 lib/utils/name_index.dart create mode 100644 test/product_grid_test.dart create mode 100644 test/tab_panel_index_test.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index 606b3a7..bd3b4f3 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -103,6 +103,10 @@ GoRouter createAppRouter({ required AdminPinViewModel adminPinViewModel, required RemoteSyncViewModel remoteSyncViewModel, }) { + // go_router drops `extra` when it rebuilds a route from its location, e.g. + // when the app returns from Android's installer, so keep the last update + UpdateInfo? lastUpdate; + return GoRouter( initialLocation: '/bar', observers: [routeObserver, SentryNavigatorObserver()], @@ -194,8 +198,12 @@ GoRouter createAppRouter({ GoRoute( path: '/update-progress', + redirect: (context, state) { + if (state.extra case final UpdateInfo update) lastUpdate = update; + return lastUpdate == null ? '/settings' : null; + }, builder: (context, state) { - final update = state.extra as UpdateInfo; + final update = lastUpdate!; final simulate = state.uri.queryParameters['simulate'] == 'true'; final simulateError = state.uri.queryParameters['simulateError'] == 'true'; diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 9a20d0f..553f832 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -30,6 +30,10 @@ class Products extends Table { IntColumn get lowStockThreshold => integer()(); + /// Products like tap water that are never counted; their stock is ignored. + BoolColumn get infiniteStock => + boolean().withDefault(const Constant(false))(); + IntColumn get priceInCents => integer()(); TextColumn get imagePath => text().nullable()(); @@ -200,7 +204,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 13; + int get schemaVersion => 14; @override MigrationStrategy get migration { @@ -295,6 +299,10 @@ class AppDatabase extends _$AppDatabase { await _backfillPurchaseRows(); await _deleteOrphanedPurchaseRows(); } + + if (from < 14 && !await hasTableColumn('products', 'infinite_stock')) { + await migrator.addColumn(products, products.infiniteStock); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index b484eb5..35aac62 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -60,6 +60,21 @@ class $ProductsTable extends Products type: DriftSqlType.int, requiredDuringInsert: true, ); + static const VerificationMeta _infiniteStockMeta = const VerificationMeta( + 'infiniteStock', + ); + @override + late final GeneratedColumn infiniteStock = GeneratedColumn( + 'infinite_stock', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("infinite_stock" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _priceInCentsMeta = const VerificationMeta( 'priceInCents', ); @@ -102,6 +117,7 @@ class $ProductsTable extends Products category, stockQuantity, lowStockThreshold, + infiniteStock, priceInCents, imagePath, active, @@ -161,6 +177,15 @@ class $ProductsTable extends Products } else if (isInserting) { context.missing(_lowStockThresholdMeta); } + if (data.containsKey('infinite_stock')) { + context.handle( + _infiniteStockMeta, + infiniteStock.isAcceptableOrUnknown( + data['infinite_stock']!, + _infiniteStockMeta, + ), + ); + } if (data.containsKey('price_in_cents')) { context.handle( _priceInCentsMeta, @@ -213,6 +238,10 @@ class $ProductsTable extends Products DriftSqlType.int, data['${effectivePrefix}low_stock_threshold'], )!, + infiniteStock: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}infinite_stock'], + )!, priceInCents: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}price_in_cents'], @@ -240,6 +269,9 @@ class ProductRow extends DataClass implements Insertable { final String category; final int stockQuantity; final int lowStockThreshold; + + /// Products like tap water that are never counted; their stock is ignored. + final bool infiniteStock; final int priceInCents; final String? imagePath; final bool active; @@ -249,6 +281,7 @@ class ProductRow extends DataClass implements Insertable { required this.category, required this.stockQuantity, required this.lowStockThreshold, + required this.infiniteStock, required this.priceInCents, this.imagePath, required this.active, @@ -261,6 +294,7 @@ class ProductRow extends DataClass implements Insertable { map['category'] = Variable(category); map['stock_quantity'] = Variable(stockQuantity); map['low_stock_threshold'] = Variable(lowStockThreshold); + map['infinite_stock'] = Variable(infiniteStock); map['price_in_cents'] = Variable(priceInCents); if (!nullToAbsent || imagePath != null) { map['image_path'] = Variable(imagePath); @@ -276,6 +310,7 @@ class ProductRow extends DataClass implements Insertable { category: Value(category), stockQuantity: Value(stockQuantity), lowStockThreshold: Value(lowStockThreshold), + infiniteStock: Value(infiniteStock), priceInCents: Value(priceInCents), imagePath: imagePath == null && nullToAbsent ? const Value.absent() @@ -295,6 +330,7 @@ class ProductRow extends DataClass implements Insertable { category: serializer.fromJson(json['category']), stockQuantity: serializer.fromJson(json['stockQuantity']), lowStockThreshold: serializer.fromJson(json['lowStockThreshold']), + infiniteStock: serializer.fromJson(json['infiniteStock']), priceInCents: serializer.fromJson(json['priceInCents']), imagePath: serializer.fromJson(json['imagePath']), active: serializer.fromJson(json['active']), @@ -309,6 +345,7 @@ class ProductRow extends DataClass implements Insertable { 'category': serializer.toJson(category), 'stockQuantity': serializer.toJson(stockQuantity), 'lowStockThreshold': serializer.toJson(lowStockThreshold), + 'infiniteStock': serializer.toJson(infiniteStock), 'priceInCents': serializer.toJson(priceInCents), 'imagePath': serializer.toJson(imagePath), 'active': serializer.toJson(active), @@ -321,6 +358,7 @@ class ProductRow extends DataClass implements Insertable { String? category, int? stockQuantity, int? lowStockThreshold, + bool? infiniteStock, int? priceInCents, Value imagePath = const Value.absent(), bool? active, @@ -330,6 +368,7 @@ class ProductRow extends DataClass implements Insertable { category: category ?? this.category, stockQuantity: stockQuantity ?? this.stockQuantity, lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, + infiniteStock: infiniteStock ?? this.infiniteStock, priceInCents: priceInCents ?? this.priceInCents, imagePath: imagePath.present ? imagePath.value : this.imagePath, active: active ?? this.active, @@ -345,6 +384,9 @@ class ProductRow extends DataClass implements Insertable { lowStockThreshold: data.lowStockThreshold.present ? data.lowStockThreshold.value : this.lowStockThreshold, + infiniteStock: data.infiniteStock.present + ? data.infiniteStock.value + : this.infiniteStock, priceInCents: data.priceInCents.present ? data.priceInCents.value : this.priceInCents, @@ -361,6 +403,7 @@ class ProductRow extends DataClass implements Insertable { ..write('category: $category, ') ..write('stockQuantity: $stockQuantity, ') ..write('lowStockThreshold: $lowStockThreshold, ') + ..write('infiniteStock: $infiniteStock, ') ..write('priceInCents: $priceInCents, ') ..write('imagePath: $imagePath, ') ..write('active: $active') @@ -375,6 +418,7 @@ class ProductRow extends DataClass implements Insertable { category, stockQuantity, lowStockThreshold, + infiniteStock, priceInCents, imagePath, active, @@ -388,6 +432,7 @@ class ProductRow extends DataClass implements Insertable { other.category == this.category && other.stockQuantity == this.stockQuantity && other.lowStockThreshold == this.lowStockThreshold && + other.infiniteStock == this.infiniteStock && other.priceInCents == this.priceInCents && other.imagePath == this.imagePath && other.active == this.active); @@ -399,6 +444,7 @@ class ProductsCompanion extends UpdateCompanion { final Value category; final Value stockQuantity; final Value lowStockThreshold; + final Value infiniteStock; final Value priceInCents; final Value imagePath; final Value active; @@ -409,6 +455,7 @@ class ProductsCompanion extends UpdateCompanion { this.category = const Value.absent(), this.stockQuantity = const Value.absent(), this.lowStockThreshold = const Value.absent(), + this.infiniteStock = const Value.absent(), this.priceInCents = const Value.absent(), this.imagePath = const Value.absent(), this.active = const Value.absent(), @@ -420,6 +467,7 @@ class ProductsCompanion extends UpdateCompanion { required String category, required int stockQuantity, required int lowStockThreshold, + this.infiniteStock = const Value.absent(), required int priceInCents, this.imagePath = const Value.absent(), this.active = const Value.absent(), @@ -436,6 +484,7 @@ class ProductsCompanion extends UpdateCompanion { Expression? category, Expression? stockQuantity, Expression? lowStockThreshold, + Expression? infiniteStock, Expression? priceInCents, Expression? imagePath, Expression? active, @@ -447,6 +496,7 @@ class ProductsCompanion extends UpdateCompanion { if (category != null) 'category': category, if (stockQuantity != null) 'stock_quantity': stockQuantity, if (lowStockThreshold != null) 'low_stock_threshold': lowStockThreshold, + if (infiniteStock != null) 'infinite_stock': infiniteStock, if (priceInCents != null) 'price_in_cents': priceInCents, if (imagePath != null) 'image_path': imagePath, if (active != null) 'active': active, @@ -460,6 +510,7 @@ class ProductsCompanion extends UpdateCompanion { Value? category, Value? stockQuantity, Value? lowStockThreshold, + Value? infiniteStock, Value? priceInCents, Value? imagePath, Value? active, @@ -471,6 +522,7 @@ class ProductsCompanion extends UpdateCompanion { category: category ?? this.category, stockQuantity: stockQuantity ?? this.stockQuantity, lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, + infiniteStock: infiniteStock ?? this.infiniteStock, priceInCents: priceInCents ?? this.priceInCents, imagePath: imagePath ?? this.imagePath, active: active ?? this.active, @@ -496,6 +548,9 @@ class ProductsCompanion extends UpdateCompanion { if (lowStockThreshold.present) { map['low_stock_threshold'] = Variable(lowStockThreshold.value); } + if (infiniteStock.present) { + map['infinite_stock'] = Variable(infiniteStock.value); + } if (priceInCents.present) { map['price_in_cents'] = Variable(priceInCents.value); } @@ -519,6 +574,7 @@ class ProductsCompanion extends UpdateCompanion { ..write('category: $category, ') ..write('stockQuantity: $stockQuantity, ') ..write('lowStockThreshold: $lowStockThreshold, ') + ..write('infiniteStock: $infiniteStock, ') ..write('priceInCents: $priceInCents, ') ..write('imagePath: $imagePath, ') ..write('active: $active, ') @@ -3800,6 +3856,7 @@ typedef $$ProductsTableCreateCompanionBuilder = required String category, required int stockQuantity, required int lowStockThreshold, + Value infiniteStock, required int priceInCents, Value imagePath, Value active, @@ -3812,6 +3869,7 @@ typedef $$ProductsTableUpdateCompanionBuilder = Value category, Value stockQuantity, Value lowStockThreshold, + Value infiniteStock, Value priceInCents, Value imagePath, Value active, @@ -3875,6 +3933,11 @@ class $$ProductsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get infiniteStock => $composableBuilder( + column: $table.infiniteStock, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get priceInCents => $composableBuilder( column: $table.priceInCents, builder: (column) => ColumnFilters(column), @@ -3950,6 +4013,11 @@ class $$ProductsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get infiniteStock => $composableBuilder( + column: $table.infiniteStock, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get priceInCents => $composableBuilder( column: $table.priceInCents, builder: (column) => ColumnOrderings(column), @@ -3994,6 +4062,11 @@ class $$ProductsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get infiniteStock => $composableBuilder( + column: $table.infiniteStock, + builder: (column) => column, + ); + GeneratedColumn get priceInCents => $composableBuilder( column: $table.priceInCents, builder: (column) => column, @@ -4064,6 +4137,7 @@ class $$ProductsTableTableManager Value category = const Value.absent(), Value stockQuantity = const Value.absent(), Value lowStockThreshold = const Value.absent(), + Value infiniteStock = const Value.absent(), Value priceInCents = const Value.absent(), Value imagePath = const Value.absent(), Value active = const Value.absent(), @@ -4074,6 +4148,7 @@ class $$ProductsTableTableManager category: category, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, + infiniteStock: infiniteStock, priceInCents: priceInCents, imagePath: imagePath, active: active, @@ -4086,6 +4161,7 @@ class $$ProductsTableTableManager required String category, required int stockQuantity, required int lowStockThreshold, + Value infiniteStock = const Value.absent(), required int priceInCents, Value imagePath = const Value.absent(), Value active = const Value.absent(), @@ -4096,6 +4172,7 @@ class $$ProductsTableTableManager category: category, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, + infiniteStock: infiniteStock, priceInCents: priceInCents, imagePath: imagePath, active: active, diff --git a/lib/database/stock_queries.dart b/lib/database/stock_queries.dart index 86d9a3d..079590b 100644 --- a/lib/database/stock_queries.dart +++ b/lib/database/stock_queries.dart @@ -21,6 +21,7 @@ class ProductNotFoundException implements Exception { /// Atomic stock updates shared by the product and tab services, so they can /// run inside either one's transaction. extension StockQueries on AppDatabase { + /// Products with infinite stock are only checked for existence. Future decreaseStock(String productId, int amount) async { _validateStockAmount(amount); @@ -28,6 +29,7 @@ extension StockQueries on AppDatabase { await (update(products)..where( (row) => row.id.equals(productId) & + row.infiniteStock.not() & row.stockQuantity.isBiggerOrEqualValue(amount), )) .write( @@ -38,28 +40,34 @@ extension StockQueries on AppDatabase { if (updatedRows == 1) return; - final product = await (select( - products, - )..where((row) => row.id.equals(productId))).getSingleOrNull(); - - if (product == null) throw ProductNotFoundException(productId); - - throw const InsufficientStockException(); + final product = await _findProduct(productId); + if (!product.infiniteStock) throw const InsufficientStockException(); } Future increaseStock(String productId, int amount) async { _validateStockAmount(amount); final updatedRows = - await (update( - products, - )..where((row) => row.id.equals(productId))).write( - ProductsCompanion.custom( - stockQuantity: products.stockQuantity + Variable(amount), - ), - ); + await (update(products)..where( + (row) => row.id.equals(productId) & row.infiniteStock.not(), + )) + .write( + ProductsCompanion.custom( + stockQuantity: products.stockQuantity + Variable(amount), + ), + ); - if (updatedRows != 1) throw ProductNotFoundException(productId); + if (updatedRows != 1) await _findProduct(productId); + } + + Future _findProduct(String productId) async { + final product = await (select( + products, + )..where((row) => row.id.equals(productId))).getSingleOrNull(); + + if (product == null) throw ProductNotFoundException(productId); + + return product; } void _validateStockAmount(int amount) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 71dfcaa..18b0021 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -27,6 +27,11 @@ "@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}}, "searchByName": "Search by name…", "clearSearch": "Clear search", + "openNewTabDescription": "Add a customer or group to the bar", + "tabOpened": "Tab opened for {name}", + "@tabOpened": {"placeholders": {"name": {"type": "String"}}}, + "searchTabs": "Search tabs", + "closeSearch": "Close search", "allCustomers": "All customers", "customer": "Customer", "noClosedTabs": "No closed tabs yet", @@ -102,6 +107,10 @@ "closeTab": "Close tab", "productStockSummary": "{category} • {price} • Stock: {stock}", "@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, + "infiniteStock": "Infinite stock", + "infiniteStockDescription": "Never runs out, the stock isn't counted", + "productInfiniteStockSummary": "{category} • {price} • Infinite stock", + "@productInfiniteStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}}}, "outOfStock": "OUT OF STOCK", "tabsYouCloseAppearHere": "Tabs you close will show up here.", "enterPinSubtitle": "You’ll use this to unlock the app", @@ -146,13 +155,8 @@ "@versionAvailable": {"placeholders": {"version": {"type": "String"}}}, "later": "Later", "update": "Update", - "updateReady": "Update ready", - "updateReadyDescription": "The update has been downloaded and installed. Restart the app now to apply the changes.", - "restartApp": "Restart app", "updateFailed": "Update failed", "retry": "Retry", - "updating": "Updating", - "installing": "Installing", "doNotCloseDuringUpdate": "Do not close the app during the update", "cancelUpdate": "Cancel update?", "cancelUpdateDescription": "The update is in progress. If you leave now, the app may become unstable.", @@ -160,14 +164,19 @@ "cancelUpdateAction": "Cancel update", "preparingDownload": "Preparing download…", "simulatedDownloadError": "Simulated download error (network timeout)", - "downloadFailed": "Download failed", - "downloading": "Downloading {percent}%", - "@downloading": {"placeholders": {"percent": {"type": "String"}}}, "installingUpdate": "Installing update…", - "updateInstalledRestarting": "Update installed! Restarting…", - "installationFailed": "Installation failed", "updateAlreadyInProgress": "Update already in progress", - "updateAlreadyRunning": "Update already running", + "updatingTo": "Updating to {version}", + "@updatingTo": {"placeholders": {"version": {"type": "String"}}}, + "downloadingUpdate": "Downloading update…", + "confirmInstallation": "Confirm the installation", + "confirmInstallationDescription": "Confirm the update in the Android prompt, then open KoolTap again.", + "openInstallerAgain": "Open installer again", + "whatsNew": "What's new", + "updateDownloadFailed": "Could not download the update. Check the internet connection and try again.", + "updateChecksumFailed": "The downloaded update is damaged. Try again.", + "updatePermissionDenied": "KoolTap is not allowed to install updates. Allow \"Install unknown apps\" for KoolTap in the Android settings.", + "updateInstallFailed": "The update could not be installed.", "genericError": "An unexpected error occurred.", "couldNotLoadBar": "Could not load bar screen.", "couldNotCreateTabMessage": "Could not create tab.", @@ -186,8 +195,6 @@ "currentPinIncorrect": "Current PIN is incorrect.", "couldNotLoadSettings": "Could not load settings.", "couldNotSaveSettings": "Could not save settings.", - "downloadFailedWithDetail": "Download failed.", - "installationFailedWithDetail": "Installation failed.", "devMenu": "Dev Menu", "dataManagement": "Data Management", "clearOpenTabs": "Clear all open tabs", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3669d34..affe4a3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -254,6 +254,30 @@ abstract class AppLocalizations { /// **'Clear search'** String get clearSearch; + /// No description provided for @openNewTabDescription. + /// + /// In en, this message translates to: + /// **'Add a customer or group to the bar'** + String get openNewTabDescription; + + /// No description provided for @tabOpened. + /// + /// In en, this message translates to: + /// **'Tab opened for {name}'** + String tabOpened(String name); + + /// No description provided for @searchTabs. + /// + /// In en, this message translates to: + /// **'Search tabs'** + String get searchTabs; + + /// No description provided for @closeSearch. + /// + /// In en, this message translates to: + /// **'Close search'** + String get closeSearch; + /// No description provided for @allCustomers. /// /// In en, this message translates to: @@ -674,6 +698,24 @@ abstract class AppLocalizations { /// **'{category} • {price} • Stock: {stock}'** String productStockSummary(String category, String price, int stock); + /// No description provided for @infiniteStock. + /// + /// In en, this message translates to: + /// **'Infinite stock'** + String get infiniteStock; + + /// No description provided for @infiniteStockDescription. + /// + /// In en, this message translates to: + /// **'Never runs out, the stock isn\'t counted'** + String get infiniteStockDescription; + + /// No description provided for @productInfiniteStockSummary. + /// + /// In en, this message translates to: + /// **'{category} • {price} • Infinite stock'** + String productInfiniteStockSummary(String category, String price); + /// No description provided for @outOfStock. /// /// In en, this message translates to: @@ -920,24 +962,6 @@ abstract class AppLocalizations { /// **'Update'** String get update; - /// No description provided for @updateReady. - /// - /// In en, this message translates to: - /// **'Update ready'** - String get updateReady; - - /// No description provided for @updateReadyDescription. - /// - /// In en, this message translates to: - /// **'The update has been downloaded and installed. Restart the app now to apply the changes.'** - String get updateReadyDescription; - - /// No description provided for @restartApp. - /// - /// In en, this message translates to: - /// **'Restart app'** - String get restartApp; - /// No description provided for @updateFailed. /// /// In en, this message translates to: @@ -950,18 +974,6 @@ abstract class AppLocalizations { /// **'Retry'** String get retry; - /// No description provided for @updating. - /// - /// In en, this message translates to: - /// **'Updating'** - String get updating; - - /// No description provided for @installing. - /// - /// In en, this message translates to: - /// **'Installing'** - String get installing; - /// No description provided for @doNotCloseDuringUpdate. /// /// In en, this message translates to: @@ -1004,47 +1016,77 @@ abstract class AppLocalizations { /// **'Simulated download error (network timeout)'** String get simulatedDownloadError; - /// No description provided for @downloadFailed. - /// - /// In en, this message translates to: - /// **'Download failed'** - String get downloadFailed; - - /// No description provided for @downloading. - /// - /// In en, this message translates to: - /// **'Downloading {percent}%'** - String downloading(String percent); - /// No description provided for @installingUpdate. /// /// In en, this message translates to: /// **'Installing update…'** String get installingUpdate; - /// No description provided for @updateInstalledRestarting. - /// - /// In en, this message translates to: - /// **'Update installed! Restarting…'** - String get updateInstalledRestarting; - - /// No description provided for @installationFailed. - /// - /// In en, this message translates to: - /// **'Installation failed'** - String get installationFailed; - /// No description provided for @updateAlreadyInProgress. /// /// In en, this message translates to: /// **'Update already in progress'** String get updateAlreadyInProgress; - /// No description provided for @updateAlreadyRunning. + /// No description provided for @updatingTo. /// /// In en, this message translates to: - /// **'Update already running'** - String get updateAlreadyRunning; + /// **'Updating to {version}'** + String updatingTo(String version); + + /// No description provided for @downloadingUpdate. + /// + /// In en, this message translates to: + /// **'Downloading update…'** + String get downloadingUpdate; + + /// No description provided for @confirmInstallation. + /// + /// In en, this message translates to: + /// **'Confirm the installation'** + String get confirmInstallation; + + /// No description provided for @confirmInstallationDescription. + /// + /// In en, this message translates to: + /// **'Confirm the update in the Android prompt, then open KoolTap again.'** + String get confirmInstallationDescription; + + /// No description provided for @openInstallerAgain. + /// + /// In en, this message translates to: + /// **'Open installer again'** + String get openInstallerAgain; + + /// No description provided for @whatsNew. + /// + /// In en, this message translates to: + /// **'What\'s new'** + String get whatsNew; + + /// No description provided for @updateDownloadFailed. + /// + /// In en, this message translates to: + /// **'Could not download the update. Check the internet connection and try again.'** + String get updateDownloadFailed; + + /// No description provided for @updateChecksumFailed. + /// + /// In en, this message translates to: + /// **'The downloaded update is damaged. Try again.'** + String get updateChecksumFailed; + + /// No description provided for @updatePermissionDenied. + /// + /// In en, this message translates to: + /// **'KoolTap is not allowed to install updates. Allow \"Install unknown apps\" for KoolTap in the Android settings.'** + String get updatePermissionDenied; + + /// No description provided for @updateInstallFailed. + /// + /// In en, this message translates to: + /// **'The update could not be installed.'** + String get updateInstallFailed; /// No description provided for @genericError. /// @@ -1154,18 +1196,6 @@ abstract class AppLocalizations { /// **'Could not save settings.'** String get couldNotSaveSettings; - /// No description provided for @downloadFailedWithDetail. - /// - /// In en, this message translates to: - /// **'Download failed.'** - String get downloadFailedWithDetail; - - /// No description provided for @installationFailedWithDetail. - /// - /// In en, this message translates to: - /// **'Installation failed.'** - String get installationFailedWithDetail; - /// No description provided for @devMenu. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c5c8ceb..ff6547f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -91,6 +91,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearSearch => 'Clear search'; + @override + String get openNewTabDescription => 'Add a customer or group to the bar'; + + @override + String tabOpened(String name) { + return 'Tab opened for $name'; + } + + @override + String get searchTabs => 'Search tabs'; + + @override + String get closeSearch => 'Close search'; + @override String get allCustomers => 'All customers'; @@ -325,6 +339,18 @@ class AppLocalizationsEn extends AppLocalizations { return '$category • $price • Stock: $stock'; } + @override + String get infiniteStock => 'Infinite stock'; + + @override + String get infiniteStockDescription => + 'Never runs out, the stock isn\'t counted'; + + @override + String productInfiniteStockSummary(String category, String price) { + return '$category • $price • Infinite stock'; + } + @override String get outOfStock => 'OUT OF STOCK'; @@ -455,28 +481,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get update => 'Update'; - @override - String get updateReady => 'Update ready'; - - @override - String get updateReadyDescription => - 'The update has been downloaded and installed. Restart the app now to apply the changes.'; - - @override - String get restartApp => 'Restart app'; - @override String get updateFailed => 'Update failed'; @override String get retry => 'Retry'; - @override - String get updating => 'Updating'; - - @override - String get installing => 'Installing'; - @override String get doNotCloseDuringUpdate => 'Do not close the app during the update'; @@ -500,28 +510,47 @@ class AppLocalizationsEn extends AppLocalizations { String get simulatedDownloadError => 'Simulated download error (network timeout)'; - @override - String get downloadFailed => 'Download failed'; - - @override - String downloading(String percent) { - return 'Downloading $percent%'; - } - @override String get installingUpdate => 'Installing update…'; - @override - String get updateInstalledRestarting => 'Update installed! Restarting…'; - - @override - String get installationFailed => 'Installation failed'; - @override String get updateAlreadyInProgress => 'Update already in progress'; @override - String get updateAlreadyRunning => 'Update already running'; + String updatingTo(String version) { + return 'Updating to $version'; + } + + @override + String get downloadingUpdate => 'Downloading update…'; + + @override + String get confirmInstallation => 'Confirm the installation'; + + @override + String get confirmInstallationDescription => + 'Confirm the update in the Android prompt, then open KoolTap again.'; + + @override + String get openInstallerAgain => 'Open installer again'; + + @override + String get whatsNew => 'What\'s new'; + + @override + String get updateDownloadFailed => + 'Could not download the update. Check the internet connection and try again.'; + + @override + String get updateChecksumFailed => + 'The downloaded update is damaged. Try again.'; + + @override + String get updatePermissionDenied => + 'KoolTap is not allowed to install updates. Allow \"Install unknown apps\" for KoolTap in the Android settings.'; + + @override + String get updateInstallFailed => 'The update could not be installed.'; @override String get genericError => 'An unexpected error occurred.'; @@ -577,12 +606,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get couldNotSaveSettings => 'Could not save settings.'; - @override - String get downloadFailedWithDetail => 'Download failed.'; - - @override - String get installationFailedWithDetail => 'Installation failed.'; - @override String get devMenu => 'Dev Menu'; diff --git a/lib/l10n/app_localizations_helpers.dart b/lib/l10n/app_localizations_helpers.dart index 0bde941..89a68b7 100644 --- a/lib/l10n/app_localizations_helpers.dart +++ b/lib/l10n/app_localizations_helpers.dart @@ -1,4 +1,5 @@ import '../models/app_error.dart'; +import '../models/update_failure.dart'; import 'app_localizations.dart'; extension AppLocalizationsHelpers on AppLocalizations { @@ -44,47 +45,18 @@ extension AppLocalizationsHelpers on AppLocalizations { }; } - /// Update messages still come from the OTA plugin as plain strings. - String _updateMessage(String? message) { - return switch (message) { - 'Update failed' => updateFailed, - 'Download failed' => downloadFailed, - 'Installation failed' => installationFailed, - 'Update already in progress' => updateAlreadyInProgress, - _ => message ?? genericError, + String updateFailureText(UpdateFailure? failure) { + return switch (failure) { + UpdateFailure.download => updateDownloadFailed, + UpdateFailure.checksum => updateChecksumFailed, + UpdateFailure.permission => updatePermissionDenied, + UpdateFailure.install => updateInstallFailed, + UpdateFailure.alreadyRunning => updateAlreadyInProgress, + UpdateFailure.simulated => simulatedDownloadError, + UpdateFailure.unknown || null => updateFailed, }; } - String updateStatus(String status) { - if (status.startsWith('Downloading ')) { - return downloading(status.substring('Downloading '.length)); - } - - return switch (status) { - 'Preparing download…' => preparingDownload, - 'Download failed' => downloadFailed, - 'Installing update…' => installingUpdate, - 'Update installed! Restarting…' => updateInstalledRestarting, - 'Installation failed' => installationFailed, - 'Update already running' => updateAlreadyRunning, - _ => status, - }; - } - - String updateError(String? message) { - if (message == null) return updateFailed; - if (message.startsWith('Download failed')) return downloadFailedWithDetail; - if (message.startsWith('Installation failed')) { - return installationFailedWithDetail; - } - if (message == 'Simulated download error (network timeout)') { - return simulatedDownloadError; - } - if (message == 'Update already in progress') return updateAlreadyInProgress; - - return _updateMessage(message); - } - String devAction(String? message) { if (message == null) return ''; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a8083b4..8a85153 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -68,7 +68,7 @@ class AppLocalizationsNl extends AppLocalizations { String get openOrSelectTab => 'Open of selecteer eerst een poef.'; @override - String get openNewTab => 'Nieuw poef openen'; + String get openNewTab => 'Nieuwe poef openen'; @override String get customerGroupName => 'Naam klant / groep'; @@ -90,6 +90,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get clearSearch => 'Zoekopdracht wissen'; + @override + String get openNewTabDescription => + 'Een klant of groep toevoegen aan de kassa'; + + @override + String tabOpened(String name) { + return 'Poef geopend voor $name'; + } + + @override + String get searchTabs => 'Poefs zoeken'; + + @override + String get closeSearch => 'Zoeken sluiten'; + @override String get allCustomers => 'Alle klanten'; @@ -325,6 +340,18 @@ class AppLocalizationsNl extends AppLocalizations { return '$category • $price • Voorraad: $stock'; } + @override + String get infiniteStock => 'Oneindige voorraad'; + + @override + String get infiniteStockDescription => + 'Raakt nooit op, de voorraad wordt niet geteld'; + + @override + String productInfiniteStockSummary(String category, String price) { + return '$category • $price • Oneindige voorraad'; + } + @override String get outOfStock => 'UITVERKOCHT'; @@ -457,28 +484,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get update => 'Updaten'; - @override - String get updateReady => 'Update klaar'; - - @override - String get updateReadyDescription => - 'De update is gedownload en geïnstalleerd. Start de app nu opnieuw om de wijzigingen toe te passen.'; - - @override - String get restartApp => 'App opnieuw starten'; - @override String get updateFailed => 'Update mislukt'; @override String get retry => 'Opnieuw proberen'; - @override - String get updating => 'Updaten'; - - @override - String get installing => 'Installeren'; - @override String get doNotCloseDuringUpdate => 'Sluit de app niet tijdens de update'; @@ -502,29 +513,47 @@ class AppLocalizationsNl extends AppLocalizations { String get simulatedDownloadError => 'Gesimuleerde downloadfout (netwerktime-out)'; - @override - String get downloadFailed => 'Download mislukt'; - - @override - String downloading(String percent) { - return 'Downloaden $percent%'; - } - @override String get installingUpdate => 'Update installeren…'; - @override - String get updateInstalledRestarting => - 'Update geïnstalleerd! Opnieuw starten…'; - - @override - String get installationFailed => 'Installatie mislukt'; - @override String get updateAlreadyInProgress => 'Update is al bezig'; @override - String get updateAlreadyRunning => 'Update wordt al uitgevoerd'; + String updatingTo(String version) { + return 'Updaten naar $version'; + } + + @override + String get downloadingUpdate => 'Update downloaden…'; + + @override + String get confirmInstallation => 'Bevestig de installatie'; + + @override + String get confirmInstallationDescription => + 'Bevestig de update in het Android-venster en open KoolTap daarna opnieuw.'; + + @override + String get openInstallerAgain => 'Installatie opnieuw openen'; + + @override + String get whatsNew => 'Wat is er nieuw'; + + @override + String get updateDownloadFailed => + 'Kon de update niet downloaden. Controleer de internetverbinding en probeer opnieuw.'; + + @override + String get updateChecksumFailed => + 'De gedownloade update is beschadigd. Probeer opnieuw.'; + + @override + String get updatePermissionDenied => + 'KoolTap mag geen updates installeren. Sta \"Onbekende apps installeren\" toe voor KoolTap in de Android-instellingen.'; + + @override + String get updateInstallFailed => 'De update kon niet worden geïnstalleerd.'; @override String get genericError => 'Er is een onverwachte fout opgetreden.'; @@ -584,12 +613,6 @@ class AppLocalizationsNl extends AppLocalizations { String get couldNotSaveSettings => 'Instellingen konden niet worden opgeslagen.'; - @override - String get downloadFailedWithDetail => 'Download mislukt.'; - - @override - String get installationFailedWithDetail => 'Installatie mislukt.'; - @override String get devMenu => 'Ontwikkelaarsmenu'; @@ -659,10 +682,10 @@ class AppLocalizationsNl extends AppLocalizations { String get testingHelpers => 'Testhelpers'; @override - String get createTestTab => 'Testtabblad maken'; + String get createTestTab => 'Testpoef maken'; @override - String get addRandomItems => 'Tabblad met willekeurige items toevoegen'; + String get addRandomItems => 'Poef met willekeurige items toevoegen'; @override String get addTestProducts => '100 testproducten toevoegen'; @@ -754,11 +777,11 @@ class AppLocalizationsNl extends AppLocalizations { String get categoryOther => 'Overig'; @override - String get devActionClearedOpenTabs => 'Alle open tabbladen gewist'; + String get devActionClearedOpenTabs => 'Alle open poefs gewist'; @override String get devActionClearedHistory => - 'Geschiedenis van gesloten tabbladen gewist'; + 'Geschiedenis van gesloten poefs gewist'; @override String get devActionClearedProducts => 'Alle producten gewist'; @@ -796,7 +819,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String devActionTestTab(int count) { - return 'Testtabblad gemaakt met $count items'; + return 'Testpoef gemaakt met $count items'; } @override diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index ccd34f4..23d1440 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -19,7 +19,7 @@ "importNamesDescription": "Bereid een lijst met namen voor om later als poefs te gebruiken.", "importPlaceholder": "Namen importeren is hier later beschikbaar.", "openOrSelectTab": "Open of selecteer eerst een poef.", - "openNewTab": "Nieuw poef openen", + "openNewTab": "Nieuwe poef openen", "customerGroupName": "Naam klant / groep", "cancel": "Annuleren", "openTab": "Poef openen", @@ -27,6 +27,11 @@ "@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}}, "searchByName": "Zoeken op naam…", "clearSearch": "Zoekopdracht wissen", + "openNewTabDescription": "Een klant of groep toevoegen aan de kassa", + "tabOpened": "Poef geopend voor {name}", + "@tabOpened": {"placeholders": {"name": {"type": "String"}}}, + "searchTabs": "Poefs zoeken", + "closeSearch": "Zoeken sluiten", "allCustomers": "Alle klanten", "customer": "Klant", "noClosedTabs": "Nog geen gesloten poefs", @@ -102,6 +107,10 @@ "closeTab": "Poef sluiten", "productStockSummary": "{category} • {price} • Voorraad: {stock}", "@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, + "infiniteStock": "Oneindige voorraad", + "infiniteStockDescription": "Raakt nooit op, de voorraad wordt niet geteld", + "productInfiniteStockSummary": "{category} • {price} • Oneindige voorraad", + "@productInfiniteStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}}}, "outOfStock": "UITVERKOCHT", "tabsYouCloseAppearHere": "Gesloten poefs verschijnen hier.", "enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen", @@ -146,13 +155,8 @@ "@versionAvailable": {"placeholders": {"version": {"type": "String"}}}, "later": "Later", "update": "Updaten", - "updateReady": "Update klaar", - "updateReadyDescription": "De update is gedownload en geïnstalleerd. Start de app nu opnieuw om de wijzigingen toe te passen.", - "restartApp": "App opnieuw starten", "updateFailed": "Update mislukt", "retry": "Opnieuw proberen", - "updating": "Updaten", - "installing": "Installeren", "doNotCloseDuringUpdate": "Sluit de app niet tijdens de update", "cancelUpdate": "Update annuleren?", "cancelUpdateDescription": "De update is bezig. Als je nu weggaat, kan de app instabiel worden.", @@ -160,14 +164,19 @@ "cancelUpdateAction": "Update annuleren", "preparingDownload": "Download voorbereiden…", "simulatedDownloadError": "Gesimuleerde downloadfout (netwerktime-out)", - "downloadFailed": "Download mislukt", - "downloading": "Downloaden {percent}%", - "@downloading": {"placeholders": {"percent": {"type": "String"}}}, "installingUpdate": "Update installeren…", - "updateInstalledRestarting": "Update geïnstalleerd! Opnieuw starten…", - "installationFailed": "Installatie mislukt", "updateAlreadyInProgress": "Update is al bezig", - "updateAlreadyRunning": "Update wordt al uitgevoerd", + "updatingTo": "Updaten naar {version}", + "@updatingTo": {"placeholders": {"version": {"type": "String"}}}, + "downloadingUpdate": "Update downloaden…", + "confirmInstallation": "Bevestig de installatie", + "confirmInstallationDescription": "Bevestig de update in het Android-venster en open KoolTap daarna opnieuw.", + "openInstallerAgain": "Installatie opnieuw openen", + "whatsNew": "Wat is er nieuw", + "updateDownloadFailed": "Kon de update niet downloaden. Controleer de internetverbinding en probeer opnieuw.", + "updateChecksumFailed": "De gedownloade update is beschadigd. Probeer opnieuw.", + "updatePermissionDenied": "KoolTap mag geen updates installeren. Sta \"Onbekende apps installeren\" toe voor KoolTap in de Android-instellingen.", + "updateInstallFailed": "De update kon niet worden geïnstalleerd.", "genericError": "Er is een onverwachte fout opgetreden.", "couldNotLoadBar": "Barkassa kon niet worden geladen.", "couldNotCreateTabMessage": "Poef kon niet worden aangemaakt.", @@ -186,8 +195,6 @@ "currentPinIncorrect": "Huidige PIN is onjuist.", "couldNotLoadSettings": "Instellingen konden niet worden geladen.", "couldNotSaveSettings": "Instellingen konden niet worden opgeslagen.", - "downloadFailedWithDetail": "Download mislukt.", - "installationFailedWithDetail": "Installatie mislukt.", "devMenu": "Ontwikkelaarsmenu", "dataManagement": "Databeheer", "clearOpenTabs": "Alle open poefs wissen", @@ -210,8 +217,8 @@ "simulateUpdateDownload": "Updatedownload simuleren", "openDownloadProgress": "Downloadvoortgang openen", "testingHelpers": "Testhelpers", - "createTestTab": "Testtabblad maken", - "addRandomItems": "Tabblad met willekeurige items toevoegen", + "createTestTab": "Testpoef maken", + "addRandomItems": "Poef met willekeurige items toevoegen", "addTestProducts": "100 testproducten toevoegen", "stressTestGrid": "Productraster stresstesten", "simulateLowStock": "Lage voorraad simuleren", @@ -240,8 +247,8 @@ "categoryCoffee": "Koffie", "categoryTea": "Thee", "categoryOther": "Overig", - "devActionClearedOpenTabs": "Alle open tabbladen gewist", - "devActionClearedHistory": "Geschiedenis van gesloten tabbladen gewist", + "devActionClearedOpenTabs": "Alle open poefs gewist", + "devActionClearedHistory": "Geschiedenis van gesloten poefs gewist", "devActionClearedProducts": "Alle producten gewist", "devActionSeededProducts": "{count} producten toegevoegd", "@devActionSeededProducts": {"placeholders": {"count": {"type": "int"}}}, @@ -254,7 +261,7 @@ "devActionCacheCleared": "Afbeeldingencache gewist (geen actie in debugmodus)", "devActionLowStock": "Lage voorraad gesimuleerd voor {count} producten", "@devActionLowStock": {"placeholders": {"count": {"type": "int"}}}, - "devActionTestTab": "Testtabblad gemaakt met {count} items", + "devActionTestTab": "Testpoef gemaakt met {count} items", "@devActionTestTab": {"placeholders": {"count": {"type": "int"}}}, "devActionTestProducts": "{count} testproducten toegevoegd", "@devActionTestProducts": {"placeholders": {"count": {"type": "int"}}}, diff --git a/lib/models/product.dart b/lib/models/product.dart index 0ce3abc..5411f34 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -4,6 +4,9 @@ class Product { final String category; final int stockQuantity; final int lowStockThreshold; + + /// Never runs out; [stockQuantity] is left untouched by sales. + final bool infiniteStock; final int priceInCents; final String? imagePath; final bool active; @@ -14,12 +17,15 @@ class Product { required this.category, required this.stockQuantity, required this.lowStockThreshold, + this.infiniteStock = false, required this.priceInCents, this.imagePath, this.active = true, }); - bool get isLowStock => stockQuantity <= lowStockThreshold; + bool get isOutOfStock => !infiniteStock && stockQuantity <= 0; + + bool get isLowStock => !infiniteStock && stockQuantity <= lowStockThreshold; Product copyWith({ String? id, @@ -27,6 +33,7 @@ class Product { String? category, int? stockQuantity, int? lowStockThreshold, + bool? infiniteStock, int? priceInCents, String? imagePath, bool? active, @@ -37,6 +44,7 @@ class Product { category: category ?? this.category, stockQuantity: stockQuantity ?? this.stockQuantity, lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, + infiniteStock: infiniteStock ?? this.infiniteStock, priceInCents: priceInCents ?? this.priceInCents, imagePath: imagePath ?? this.imagePath, active: active ?? this.active, @@ -52,6 +60,7 @@ class Product { category == other.category && stockQuantity == other.stockQuantity && lowStockThreshold == other.lowStockThreshold && + infiniteStock == other.infiniteStock && priceInCents == other.priceInCents && imagePath == other.imagePath && active == other.active; @@ -63,6 +72,7 @@ class Product { category, stockQuantity, lowStockThreshold, + infiniteStock, priceInCents, imagePath, active, diff --git a/lib/models/update_failure.dart b/lib/models/update_failure.dart new file mode 100644 index 0000000..27cc893 --- /dev/null +++ b/lib/models/update_failure.dart @@ -0,0 +1,11 @@ +/// Why an over-the-air update stopped. Views translate it with +/// `AppLocalizations.updateFailureText`. +enum UpdateFailure { + download, + checksum, + permission, + install, + alreadyRunning, + simulated, + unknown, +} diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 124d9fb..23e128f 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -21,6 +21,7 @@ abstract class ProductService { required String category, required int stockQuantity, required int lowStockThreshold, + bool infiniteStock = false, required int priceInCents, required String? imagePath, }); @@ -47,6 +48,7 @@ class DriftProductService implements ProductService { category: row.category, stockQuantity: row.stockQuantity, lowStockThreshold: row.lowStockThreshold, + infiniteStock: row.infiniteStock, priceInCents: row.priceInCents, imagePath: row.imagePath, active: row.active, @@ -89,6 +91,7 @@ class DriftProductService implements ProductService { required String category, required int stockQuantity, required int lowStockThreshold, + bool infiniteStock = false, required int priceInCents, required String? imagePath, }) async { @@ -101,6 +104,7 @@ class DriftProductService implements ProductService { category: category, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, + infiniteStock: Value(infiniteStock), priceInCents: priceInCents, imagePath: Value(imagePath), ), @@ -118,6 +122,7 @@ class DriftProductService implements ProductService { category: Value(product.category), stockQuantity: Value(product.stockQuantity), lowStockThreshold: Value(product.lowStockThreshold), + infiniteStock: Value(product.infiniteStock), priceInCents: Value(product.priceInCents), imagePath: Value(product.imagePath), active: Value(product.active), diff --git a/lib/utils/name_index.dart b/lib/utils/name_index.dart new file mode 100644 index 0000000..3a397fa --- /dev/null +++ b/lib/utils/name_index.dart @@ -0,0 +1,35 @@ +/// Letters of an A–Z index; names that don't start with a letter go under `#`. +final indexLetters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), '#']; + +const _accented = 'ÀÁÂÃÄÅÇÈÉÊËÌÍÎÏÑÒÓÔÕÖÙÚÛÜÝŸ'; +const _unaccented = 'AAAAAACEEEEIIIINOOOOOUUUUYY'; + +/// Uppercase without accents, so "Émile" sorts and indexes under E. +String _fold(String name) { + return [ + for (final char in name.trim().toUpperCase().split('')) + switch (_accented.indexOf(char)) { + -1 => char, + final index => _unaccented[index], + }, + ].join(); +} + +String indexLetterOf(String name) { + final folded = _fold(name); + if (folded.isEmpty) return '#'; + + final first = folded[0]; + return indexLetters.contains(first) ? first : '#'; +} + +/// A–Z regardless of case and accents, with the `#` names last. +int compareByIndex(String a, String b) { + final byLetter = indexLetters + .indexOf(indexLetterOf(a)) + .compareTo(indexLetters.indexOf(indexLetterOf(b))); + if (byLetter != 0) return byLetter; + + final byName = _fold(a).compareTo(_fold(b)); + return byName != 0 ? byName : a.compareTo(b); +} diff --git a/lib/viewmodels/product_list_view_model.dart b/lib/viewmodels/product_list_view_model.dart index 84e55b8..ae22193 100644 --- a/lib/viewmodels/product_list_view_model.dart +++ b/lib/viewmodels/product_list_view_model.dart @@ -45,6 +45,7 @@ class ProductListViewModel extends ChangeNotifier { required String category, required int stockQuantity, required int lowStockThreshold, + required bool infiniteStock, required int priceInCents, required String? imagePath, }) { @@ -55,6 +56,7 @@ class ProductListViewModel extends ChangeNotifier { category: category, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, + infiniteStock: infiniteStock, priceInCents: priceInCents, imagePath: imagePath, ), diff --git a/lib/viewmodels/update_progress_view_model.dart b/lib/viewmodels/update_progress_view_model.dart index e7a0a84..60d6e4c 100644 --- a/lib/viewmodels/update_progress_view_model.dart +++ b/lib/viewmodels/update_progress_view_model.dart @@ -2,31 +2,45 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:ota_update/ota_update.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../models/update_failure.dart'; import '../utils/app_config.dart'; import '../utils/app_update_util.dart'; -enum OtaPhase { downloading, installing, done, error } +enum UpdatePhase { + downloading, + installing, + + /// Android's installer prompt is open; the app is replaced once the user + /// confirms it, so there is no "done" state to show. + waitingForInstaller, + failed, +} class UpdateProgressViewModel extends ChangeNotifier { final UpdateInfo update; final bool simulate; final bool simulateError; - OtaPhase _phase = OtaPhase.downloading; - double _progress = 0; - String _statusText = 'Preparing download…'; - String? _errorMessage; - bool _done = false; + UpdatePhase _phase = UpdatePhase.downloading; + double? _progress; + UpdateFailure? _failure; + String? _currentVersion; - StreamSubscription? _subscription; + StreamSubscription? _subscription; Timer? _simTimer; - OtaPhase get phase => _phase; - double get progress => _progress; - String get statusText => _statusText; - String? get errorMessage => _errorMessage; + UpdatePhase get phase => _phase; + + /// Download progress from 0 to 1, or null while it is not known yet. + double? get progress => _progress; + UpdateFailure? get failure => _failure; + String? get currentVersion => _currentVersion; + + bool get isBusy => + _phase == UpdatePhase.downloading || _phase == UpdatePhase.installing; UpdateProgressViewModel({ required this.update, @@ -34,7 +48,18 @@ class UpdateProgressViewModel extends ChangeNotifier { this.simulateError = false, }); - Future start() async { + void init() { + unawaited(_loadCurrentVersion()); + start(); + } + + void start() { + cancel(); + _phase = UpdatePhase.downloading; + _progress = null; + _failure = null; + notifyListeners(); + if (simulate) { _runSimulation(); return; @@ -44,8 +69,6 @@ class UpdateProgressViewModel extends ChangeNotifier { ? update.download : '$kUpdateServerUrl${update.download}'; - debugPrint('UpdateProgressViewModel: starting download from $url'); - _subscription = OtaUpdate() .execute( url, @@ -55,118 +78,126 @@ class UpdateProgressViewModel extends ChangeNotifier { .listen(_onEvent, onError: _onError, onDone: _onDone); } + void cancel() { + _simTimer?.cancel(); + _simTimer = null; + unawaited(_subscription?.cancel()); + _subscription = null; + } + + Future _loadCurrentVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + _currentVersion = info.version; + notifyListeners(); + } catch (e) { + debugPrint('UpdateProgressViewModel: could not read version: $e'); + } + } + + void _onEvent(OtaEvent event) { + switch (event.status) { + case OtaStatus.DOWNLOADING: + final percent = double.tryParse(event.value ?? '') ?? 0; + _progress = (percent / 100).clamp(0.0, 1.0); + _phase = UpdatePhase.downloading; + notifyListeners(); + + case OtaStatus.INSTALLING: + _progress = 1; + _phase = UpdatePhase.installing; + notifyListeners(); + + case OtaStatus.INSTALLATION_DONE: + _phase = UpdatePhase.waitingForInstaller; + notifyListeners(); + + case OtaStatus.DOWNLOAD_ERROR: + _fail(UpdateFailure.download, event.value); + case OtaStatus.CHECKSUM_ERROR: + _fail(UpdateFailure.checksum, event.value); + case OtaStatus.PERMISSION_NOT_GRANTED_ERROR: + _fail(UpdateFailure.permission, event.value); + case OtaStatus.INSTALLATION_ERROR: + _fail(UpdateFailure.install, event.value); + case OtaStatus.ALREADY_RUNNING_ERROR: + _fail(UpdateFailure.alreadyRunning, event.value); + case OtaStatus.INTERNAL_ERROR: + _fail(UpdateFailure.unknown, event.value); + case OtaStatus.CANCELED: + break; + } + } + + /// The plugin closes the stream once it has handed the APK to Android's + /// installer; closing earlier means it gave up without telling why. + void _onDone() { + _subscription = null; + + switch (_phase) { + case UpdatePhase.installing: + _phase = UpdatePhase.waitingForInstaller; + notifyListeners(); + case UpdatePhase.downloading: + _fail(UpdateFailure.unknown, 'Update stream closed while downloading'); + case UpdatePhase.waitingForInstaller || UpdatePhase.failed: + break; + } + } + + void _onError(Object error, StackTrace stackTrace) { + debugPrint('UpdateProgressViewModel: stream error: $error'); + unawaited(Sentry.captureException(error, stackTrace: stackTrace)); + _fail(UpdateFailure.unknown, null, report: false); + } + + void _fail(UpdateFailure failure, String? detail, {bool report = true}) { + debugPrint('UpdateProgressViewModel: $failure: $detail'); + if (report) { + unawaited( + Sentry.captureMessage( + 'OTA update failed ($failure, ${update.version}): $detail', + ), + ); + } + + cancel(); + _phase = UpdatePhase.failed; + _failure = failure; + notifyListeners(); + } + void _runSimulation() { - debugPrint('UpdateProgressViewModel: running simulated download'); + const totalSteps = 40; + var step = 0; - int step = 0; - const totalSteps = 20; - const stepDuration = Duration(milliseconds: 500); - - _simTimer = Timer.periodic(stepDuration, (timer) { + _simTimer = Timer.periodic(const Duration(milliseconds: 150), (timer) { step++; - if (simulateError && step == 10) { + if (simulateError && step == totalSteps ~/ 2) { + _phase = UpdatePhase.failed; + _failure = UpdateFailure.simulated; timer.cancel(); - _phase = OtaPhase.error; - _errorMessage = 'Simulated download error (network timeout)'; - _statusText = 'Download failed'; notifyListeners(); return; } if (step < totalSteps) { _progress = step / totalSteps; - _statusText = 'Downloading ${(_progress * 100).toStringAsFixed(0)}%'; - _phase = OtaPhase.downloading; notifyListeners(); - } else { - timer.cancel(); - _startInstallPhase(); - } - }); - } - - void _startInstallPhase() { - _phase = OtaPhase.installing; - _progress = 1.0; - _statusText = 'Installing update…'; - notifyListeners(); - - _simTimer = Timer(const Duration(seconds: 3), () { - _phase = OtaPhase.done; - _progress = 1.0; - _statusText = 'Update installed! Restarting…'; - notifyListeners(); - }); - } - - void _onDone() { - if (_done) return; - _done = true; - _phase = OtaPhase.done; - _progress = 1.0; - _statusText = 'Update installed! Restarting…'; - notifyListeners(); - } - - void _onEvent(OtaEvent event) { - switch (event.status) { - case OtaStatus.DOWNLOADING: - final raw = double.tryParse(event.value ?? '0') ?? 0; - _progress = (raw / 100).clamp(0.0, 1.0); - _statusText = 'Downloading ${_progress.toStringAsFixed(0)}%'; - _phase = OtaPhase.downloading; - notifyListeners(); - - case OtaStatus.INSTALLING: - _phase = OtaPhase.installing; - _progress = 1.0; - _statusText = 'Installing update…'; - notifyListeners(); - - case OtaStatus.INSTALLATION_DONE: - _onDone(); return; + } - case OtaStatus.DOWNLOAD_ERROR: - _phase = OtaPhase.error; - _errorMessage = 'Download failed: ${event.value}'; - _statusText = 'Download failed'; - Sentry.captureMessage('OTA download error: ${event.value}'); + timer.cancel(); + _progress = 1; + _phase = UpdatePhase.installing; + notifyListeners(); + + _simTimer = Timer(const Duration(seconds: 2), () { + _phase = UpdatePhase.waitingForInstaller; notifyListeners(); - - case OtaStatus.INSTALLATION_ERROR: - _phase = OtaPhase.error; - _errorMessage = 'Installation failed: ${event.value}'; - _statusText = 'Installation failed'; - Sentry.captureMessage('OTA installation error: ${event.value}'); - notifyListeners(); - - case OtaStatus.ALREADY_RUNNING_ERROR: - _phase = OtaPhase.error; - _errorMessage = 'Update already in progress'; - _statusText = 'Update already running'; - notifyListeners(); - - default: - break; - } - } - - void _onError(Object error, StackTrace stack) { - debugPrint('UpdateProgressViewModel: stream error: $error'); - _phase = OtaPhase.error; - _errorMessage = error.toString(); - _statusText = 'Update failed'; - Sentry.captureException(error, stackTrace: stack); - notifyListeners(); - } - - void cancel() { - _simTimer?.cancel(); - _simTimer = null; - _subscription?.cancel(); - _subscription = null; + }); + }); } @override diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index c6646a8..ec2a748 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -7,6 +7,7 @@ import '../models/remote_command.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; +import 'dialogs/new_tab_dialog.dart'; class AdminScreenView extends StatelessWidget { const AdminScreenView({super.key}); @@ -17,6 +18,16 @@ class AdminScreenView extends StatelessWidget { adminPinViewModel.lock(); } + Future _openNewTab(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final name = await showNewTabDialog(context); + + if (name == null || !context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name)))); + } + Future _confirmUnlink(BuildContext context) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( @@ -80,6 +91,12 @@ class AdminScreenView extends StatelessWidget { _AdminSection( title: l10n.adminTools, children: [ + _AdminTile( + icon: Icons.person_add_alt_1_outlined, + title: l10n.openNewTab, + subtitle: l10n.openNewTabDescription, + onTap: () => _openNewTab(context), + ), _AdminTile( icon: Icons.inventory_2_outlined, title: l10n.products, diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index ba23729..2989e92 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -19,7 +19,6 @@ import '../../viewmodels/remote_sync_view_model.dart'; import '../../viewmodels/settings_view_model.dart'; import '../dialogs/close_tab_dialog.dart'; import '../dialogs/edit_tab_dialog.dart'; -import '../dialogs/new_tab_dialog.dart'; import 'product_grid.dart'; import 'tab_panel.dart'; @@ -31,6 +30,7 @@ class BarScreenView extends StatefulWidget { } class _BarScreenViewState extends State { + bool _showSearch = false; late final StreamSubscription _remoteCloseSubscription; @override @@ -110,13 +110,21 @@ class _BarScreenViewState extends State { final settings = context.watch().settings; final stockByProductId = { for (final product in productsViewModel.products) - product.id: product.stockQuantity, + if (!product.infiniteStock) product.id: product.stockQuantity, }; return Scaffold( appBar: AppBar( title: Text(l10n.barTabs), actions: [ + IconButton( + tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, + isSelected: _showSearch, + onPressed: () => setState(() => _showSearch = !_showSearch), + icon: const Icon(Icons.search_rounded), + selectedIcon: const Icon(Icons.search_off_rounded), + ), + const SizedBox(width: 6), IconButton( tooltip: l10n.admin, onPressed: () => context.push('/admin'), @@ -190,7 +198,7 @@ class _BarScreenViewState extends State { selectedTab: viewModel.selectedTab, selectedTabId: viewModel.selectedTabId, stockByProductId: stockByProductId, - onNewTabPressed: () => showNewTabDialog(context), + showSearch: _showSearch, onTabSelected: viewModel.selectTab, onItemQuantityChanged: viewModel.changeItemQuantity, onCloseTabPressed: () => confirmCloseTab(context), diff --git a/lib/views/bar/product_grid.dart b/lib/views/bar/product_grid.dart index b0d9668..d0a5568 100644 --- a/lib/views/bar/product_grid.dart +++ b/lib/views/bar/product_grid.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -111,33 +113,36 @@ class ProductGrid extends StatelessWidget { Expanded( child: LayoutBuilder( builder: (context, constraints) { - const horizontalPadding = 32.0; - const verticalPadding = 28.0; + const padding = EdgeInsets.fromLTRB(16, 12, 16, 16); const spacing = 12.0; + const minTileHeight = 72.0; + + // Exactly [rows] rows fill the height; the column count keeps + // the tiles as close to square as the width allows final rowCount = rows.clamp(2, 6).toInt(); - final rowHeight = - ((constraints.maxHeight - - verticalPadding - - (rowCount - 1) * spacing) / - rowCount) - .clamp(96.0, 320.0) - .toDouble(); - final columns = - (((constraints.maxWidth - horizontalPadding + spacing) / - (rowHeight + spacing)) - .ceil()) - .clamp(2, 8) - .toInt(); + final tileHeight = max( + minTileHeight, + (constraints.maxHeight - + padding.vertical - + (rowCount - 1) * spacing) / + rowCount, + ); + final columns = max( + 1, + ((constraints.maxWidth - padding.horizontal + spacing) / + (tileHeight + spacing)) + .round(), + ); return GridView.builder( - key: ValueKey('product-grid-$rows'), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + key: ValueKey('product-grid-$rowCount'), + padding: padding, itemCount: products.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, + mainAxisExtent: tileHeight, ), itemBuilder: (context, index) { final product = products[index]; diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart index b910103..0a0df3c 100644 --- a/lib/views/bar/tab_panel.dart +++ b/lib/views/bar/tab_panel.dart @@ -1,10 +1,15 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item.dart'; import '../../utils/money.dart'; +import '../../utils/name_index.dart'; import 'selected_tab_details.dart'; class TabPanel extends StatefulWidget { @@ -12,7 +17,7 @@ class TabPanel extends StatefulWidget { final BarTab? selectedTab; final String? selectedTabId; final Map stockByProductId; - final VoidCallback onNewTabPressed; + final bool showSearch; final ValueChanged onTabSelected; final Future Function(TabItem item, int delta) onItemQuantityChanged; final VoidCallback onCloseTabPressed; @@ -25,7 +30,7 @@ class TabPanel extends StatefulWidget { required this.selectedTab, required this.selectedTabId, required this.stockByProductId, - required this.onNewTabPressed, + required this.showSearch, required this.onTabSelected, required this.onItemQuantityChanged, required this.onCloseTabPressed, @@ -39,14 +44,76 @@ class TabPanel extends StatefulWidget { class _TabPanelState extends State { final TextEditingController _searchController = TextEditingController(); + final ScrollController _tabsScrollController = ScrollController(); String _searchQuery = ''; + /// The letter under the finger while the index bar is pressed. + String? _touchedLetter; + + @override + void didUpdateWidget(TabPanel oldWidget) { + super.didUpdateWidget(oldWidget); + + // Hiding the search bar shouldn't leave the list silently filtered + if (oldWidget.showSearch && !widget.showSearch) { + _searchController.clear(); + _searchQuery = ''; + } + } + @override void dispose() { _searchController.dispose(); + _tabsScrollController.dispose(); super.dispose(); } + /// Every tab row has the same height, so it follows from the scroll range. + double? _tabExtent(int tabCount) { + if (!_tabsScrollController.hasClients || tabCount == 0) return null; + + final position = _tabsScrollController.position; + return (position.maxScrollExtent + position.viewportDimension) / tabCount; + } + + /// Scrolls to the first tab at or after [letter], so a letter without names + /// lands on the next one. + void _jumpToLetter(String letter, List tabs) { + if (letter != _touchedLetter) { + unawaited(HapticFeedback.selectionClick()); + setState(() => _touchedLetter = letter); + } + + final extent = _tabExtent(tabs.length); + if (extent == null) return; + + final position = _tabsScrollController.position; + final start = indexLetters.indexOf(letter); + final index = tabs.indexWhere( + (tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start, + ); + + _tabsScrollController.jumpTo( + index == -1 + ? position.maxScrollExtent + : min(index * extent, position.maxScrollExtent), + ); + } + + String? _visibleLetter(List tabs) { + if (tabs.isEmpty) return null; + + final extent = _tabExtent(tabs.length); + final index = extent == null + ? 0 + : (_tabsScrollController.offset / extent).round().clamp( + 0, + tabs.length - 1, + ); + + return indexLetterOf(tabs[index].customerName); + } + @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; @@ -56,95 +123,115 @@ class _TabPanelState extends State { return tab.customerName.toLowerCase().contains( _searchQuery.toLowerCase(), ); - }).toList(); + }).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName)); return DecoratedBox( decoration: BoxDecoration(color: scheme.surfaceContainerLow), child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.fromLTRB(4, 16, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - onChanged: (value) { - setState(() { - _searchQuery = value; - }); - }, - decoration: InputDecoration( - hintText: l10n.searchByName, - prefixIcon: Icon(Icons.search_rounded, size: 20), - suffixIcon: _searchQuery.isEmpty - ? null - : IconButton( - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, - icon: const Icon(Icons.clear_rounded), - tooltip: l10n.clearSearch, - ), - isDense: true, - ), - ), - ), - - const SizedBox(width: 10), - - IconButton.filled( - onPressed: widget.onNewTabPressed, - icon: const Icon(Icons.add_rounded), - tooltip: l10n.openNewTab, - ), - ], - ), - - const SizedBox(height: 8), - - SizedBox( - height: 190, - child: OpenTabsList( - tabs: filteredTabs, - selectedTabId: widget.selectedTabId, - onTabSelected: widget.onTabSelected, - onTabEdited: widget.onTabEdited, - onTabDeleted: widget.onTabDeleted, + ListenableBuilder( + listenable: _tabsScrollController, + builder: (context, child) => _LetterIndexBar( + availableLetters: { + for (final tab in filteredTabs) + indexLetterOf(tab.customerName), + }, + activeLetter: _touchedLetter ?? _visibleLetter(filteredTabs), + isTouched: _touchedLetter != null, + onLetterSelected: (letter) => + _jumpToLetter(letter, filteredTabs), + onReleased: () => setState(() => _touchedLetter = null), ), ), - - const Divider(height: 28), - + const SizedBox(width: 8), Expanded( - child: widget.selectedTab == null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.receipt_long_outlined, - size: 36, - color: scheme.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(height: 10), - Text( - l10n.selectOrOpenTab, - style: Theme.of(context).textTheme.bodyMedium, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Fixed height, so hiding the search bar gives its space + // to the names + SizedBox( + height: 246, + child: Column( + children: [ + if (widget.showSearch) ...[ + TextField( + autofocus: true, + controller: _searchController, + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + decoration: InputDecoration( + hintText: l10n.searchByName, + prefixIcon: Icon(Icons.search_rounded, size: 20), + suffixIcon: _searchQuery.isEmpty + ? null + : IconButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + icon: const Icon(Icons.clear_rounded), + tooltip: l10n.clearSearch, + ), + isDense: true, + ), ), + const SizedBox(height: 8), ], - ), - ) - : SelectedTabDetails( - tab: widget.selectedTab!, - stockByProductId: widget.stockByProductId, - onItemQuantityChanged: widget.onItemQuantityChanged, - onCloseTabPressed: widget.onCloseTabPressed, + Expanded( + child: OpenTabsList( + controller: _tabsScrollController, + tabs: filteredTabs, + selectedTabId: widget.selectedTabId, + onTabSelected: widget.onTabSelected, + onTabEdited: widget.onTabEdited, + onTabDeleted: widget.onTabDeleted, + ), + ), + ], ), + ), + + const Divider(height: 28), + + Expanded( + child: widget.selectedTab == null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 36, + color: scheme.onSurface.withValues( + alpha: 0.25, + ), + ), + const SizedBox(height: 10), + Text( + l10n.selectOrOpenTab, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) + : SelectedTabDetails( + tab: widget.selectedTab!, + stockByProductId: widget.stockByProductId, + onItemQuantityChanged: widget.onItemQuantityChanged, + onCloseTabPressed: widget.onCloseTabPressed, + ), + ), + ], + ), ), ], ), @@ -154,6 +241,7 @@ class _TabPanelState extends State { } class OpenTabsList extends StatelessWidget { + final ScrollController? controller; final List tabs; final String? selectedTabId; final ValueChanged onTabSelected; @@ -162,6 +250,7 @@ class OpenTabsList extends StatelessWidget { const OpenTabsList({ super.key, + this.controller, required this.tabs, required this.selectedTabId, required this.onTabSelected, @@ -181,92 +270,44 @@ class OpenTabsList extends StatelessWidget { ); } - return ListView.separated( + // A fixed row height lets the letter index work out where each tab is + return ListView.builder( + controller: controller, itemCount: tabs.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), + // The selected card's border is thickest, so it sets the height + prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)), itemBuilder: (context, index) { final tab = tabs[index]; - final selected = tab.id == selectedTabId; - final scheme = Theme.of(context).colorScheme; - final primary = scheme.primary; - return ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Slidable( - key: ValueKey(tab.id), - endActionPane: ActionPane( - motion: const DrawerMotion(), - extentRatio: 0.6, - children: [ - SlidableAction( - onPressed: (_) => onTabEdited(tab), - icon: Icons.edit_outlined, - label: l10n.edit, - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - ), - SlidableAction( - onPressed: (_) => onTabDeleted(tab), - icon: Icons.delete_outline_rounded, - label: l10n.delete, - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - ], - ), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: selected - ? primary.withValues(alpha: 0.14) - : scheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected - ? primary.withValues(alpha: 0.6) - : scheme.onSurface.withValues(alpha: 0.06), - width: selected ? 1.4 : 1, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => onTabSelected(tab.id), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - tab.customerName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: scheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - l10n.tabItemSummary( - tab.itemCount, - tab.totalInCents.asEuro, - ), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), + return _TabRow( + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Slidable( + key: ValueKey(tab.id), + endActionPane: ActionPane( + motion: const DrawerMotion(), + extentRatio: 0.6, + children: [ + SlidableAction( + onPressed: (_) => onTabEdited(tab), + icon: Icons.edit_outlined, + label: l10n.edit, + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, ), - ), + SlidableAction( + onPressed: (_) => onTabDeleted(tab), + icon: Icons.delete_outline_rounded, + label: l10n.delete, + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + ], + ), + child: _TabCard( + tab: tab, + selected: tab.id == selectedTabId, + onTap: () => onTabSelected(tab.id), ), ), ), @@ -275,3 +316,196 @@ class OpenTabsList extends StatelessWidget { ); } } + +class _TabRow extends StatelessWidget { + final Widget child; + + const _TabRow({required this.child}); + + @override + Widget build(BuildContext context) { + return Padding(padding: const EdgeInsets.only(bottom: 8), child: child); + } +} + +class _TabCard extends StatelessWidget { + final BarTab tab; + final bool selected; + final VoidCallback? onTap; + + const _TabCard({required this.tab, this.selected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + final primary = scheme.primary; + + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: selected + ? primary.withValues(alpha: 0.14) + : scheme.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? primary.withValues(alpha: 0.6) + : scheme.onSurface.withValues(alpha: 0.06), + width: selected ? 1.4 : 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tab.customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + l10n.tabItemSummary(tab.itemCount, tab.totalInCents.asEuro), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _LetterIndexBar extends StatelessWidget { + final Set availableLetters; + final String? activeLetter; + final bool isTouched; + final ValueChanged onLetterSelected; + final VoidCallback onReleased; + + const _LetterIndexBar({ + required this.availableLetters, + required this.activeLetter, + required this.isTouched, + required this.onLetterSelected, + required this.onReleased, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return LayoutBuilder( + builder: (context, constraints) { + final letterHeight = constraints.maxHeight / indexLetters.length; + final fontSize = min(13.0, letterHeight * 0.65); + + String letterAt(Offset position) { + final index = (position.dy / letterHeight).floor(); + return indexLetters[index.clamp(0, indexLetters.length - 1)]; + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => + onLetterSelected(letterAt(details.localPosition)), + onTapUp: (_) => onReleased(), + onTapCancel: onReleased, + onVerticalDragStart: (details) => + onLetterSelected(letterAt(details.localPosition)), + onVerticalDragUpdate: (details) => + onLetterSelected(letterAt(details.localPosition)), + onVerticalDragEnd: (_) => onReleased(), + onVerticalDragCancel: onReleased, + child: SizedBox( + width: 28, + child: Column( + children: [ + for (final letter in indexLetters) + SizedBox( + height: letterHeight, + child: Center( + child: _IndexLetter( + letter: letter, + fontSize: fontSize, + isActive: letter == activeLetter, + isTouched: isTouched, + hasNames: availableLetters.contains(letter), + scheme: scheme, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _IndexLetter extends StatelessWidget { + final String letter; + final double fontSize; + final bool isActive; + final bool isTouched; + final bool hasNames; + final ColorScheme scheme; + + const _IndexLetter({ + required this.letter, + required this.fontSize, + required this.isActive, + required this.isTouched, + required this.hasNames, + required this.scheme, + }); + + @override + Widget build(BuildContext context) { + final color = switch ((isActive, isTouched, hasNames)) { + (true, true, _) => scheme.onPrimary, + (true, false, _) => scheme.primary, + (false, _, true) => scheme.onSurface, + (false, _, false) => scheme.onSurface.withValues(alpha: 0.25), + }; + + return AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: fontSize * 1.8, + height: fontSize * 1.8, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive && isTouched ? scheme.primary : Colors.transparent, + ), + child: Text( + letter, + style: TextStyle( + fontSize: fontSize, + height: 1, + fontWeight: isActive || hasNames ? FontWeight.w800 : FontWeight.w500, + color: color, + ), + ), + ); + } +} diff --git a/lib/views/dialogs/new_tab_dialog.dart b/lib/views/dialogs/new_tab_dialog.dart index 8e5eee4..2f3ff64 100644 --- a/lib/views/dialogs/new_tab_dialog.dart +++ b/lib/views/dialogs/new_tab_dialog.dart @@ -4,7 +4,8 @@ import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../../viewmodels/bar_screen_view_model.dart'; -Future showNewTabDialog(BuildContext context) async { +/// Returns the name of the opened tab, or null when nothing was opened. +Future showNewTabDialog(BuildContext context) async { final l10n = AppLocalizations.of(context); final controller = TextEditingController(); @@ -38,16 +39,18 @@ Future showNewTabDialog(BuildContext context) async { }, ); - if (name == null || name.trim().isEmpty) return; - - if (!context.mounted) return; + final trimmed = name?.trim() ?? ''; + if (trimmed.isEmpty || !context.mounted) return null; try { - await context.read().createTab(name.trim()); + await context.read().createTab(trimmed); + return trimmed; } catch (_) { - if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); + } + return null; } } diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart index d2a3076..a7eb8b4 100644 --- a/lib/views/product_form_view.dart +++ b/lib/views/product_form_view.dart @@ -39,6 +39,7 @@ class _ProductFormViewState extends State { Product? _existingProduct; String? _imagePath; bool _hasImage = false; + bool _infiniteStock = false; bool _isLoading = true; bool _isSaving = false; @@ -79,6 +80,7 @@ class _ProductFormViewState extends State { _priceController.text = (product.priceInCents / 100).toStringAsFixed(2); _stockController.text = product.stockQuantity.toString(); _lowStockController.text = product.lowStockThreshold.toString(); + _infiniteStock = product.infiniteStock; _imagePath = product.imagePath; _hasImage = product.imagePath != null && product.imagePath!.isNotEmpty; @@ -189,6 +191,7 @@ class _ProductFormViewState extends State { priceInCents: priceInCents, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, + infiniteStock: _infiniteStock, imagePath: _imagePath, ); @@ -200,6 +203,7 @@ class _ProductFormViewState extends State { priceInCents: priceInCents, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, + infiniteStock: _infiniteStock, imagePath: _imagePath, ); } @@ -429,46 +433,64 @@ class _ProductFormViewState extends State { }, ), const SizedBox(height: 16), - TextFormField( - controller: _stockController, - decoration: InputDecoration( - labelText: l10n.stock, - border: OutlineInputBorder(), + SwitchListTile( + value: _infiniteStock, + onChanged: (value) { + setState(() => _infiniteStock = value); + }, + title: Text(l10n.infiniteStock), + subtitle: Text(l10n.infiniteStockDescription), + secondary: const Icon(Icons.all_inclusive_rounded), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), ), - keyboardType: TextInputType.number, - textInputAction: TextInputAction.next, - validator: (value) { - final number = int.tryParse(value ?? ''); - - if (number == null || number < 0) { - return l10n.enterValidStock; - } - - return null; - }, ), - const SizedBox(height: 16), - TextFormField( - controller: _lowStockController, - decoration: InputDecoration( - labelText: l10n.lowStockThreshold, - border: OutlineInputBorder(), + if (!_infiniteStock) ...[ + const SizedBox(height: 16), + TextFormField( + controller: _stockController, + decoration: InputDecoration( + labelText: l10n.stock, + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + validator: (value) { + final number = int.tryParse(value ?? ''); + + if (number == null || number < 0) { + return l10n.enterValidStock; + } + + return null; + }, ), - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) { - FocusScope.of(context).unfocus(); - }, - validator: (value) { - final number = int.tryParse(value ?? ''); + const SizedBox(height: 16), + TextFormField( + controller: _lowStockController, + decoration: InputDecoration( + labelText: l10n.lowStockThreshold, + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) { + FocusScope.of(context).unfocus(); + }, + validator: (value) { + final number = int.tryParse(value ?? ''); - if (number == null || number < 0) { - return l10n.enterValidThreshold; - } + if (number == null || number < 0) { + return l10n.enterValidThreshold; + } - return null; - }, - ), + return null; + }, + ), + ], const SizedBox(height: 24), FilledButton.icon( onPressed: _isSaving ? null : _save, diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 38c4ee7..0e61dfc 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -90,11 +90,16 @@ class _ProductListViewState extends State { ), title: Text(product.name), subtitle: Text( - l10n.productStockSummary( - l10n.categoryLabel(product.category), - product.priceInCents.asEuro, - product.stockQuantity, - ), + product.infiniteStock + ? l10n.productInfiniteStockSummary( + l10n.categoryLabel(product.category), + product.priceInCents.asEuro, + ) + : l10n.productStockSummary( + l10n.categoryLabel(product.category), + product.priceInCents.asEuro, + product.stockQuantity, + ), ), trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/products/${product.id}/edit'), diff --git a/lib/views/update_progress_view.dart b/lib/views/update_progress_view.dart index e913079..e64664a 100644 --- a/lib/views/update_progress_view.dart +++ b/lib/views/update_progress_view.dart @@ -32,112 +32,74 @@ class _UpdateProgressViewState extends State { update: widget.update, simulate: widget.simulate, simulateError: widget.simulateError, - ); - _vm.addListener(_onVmChange); - _vm.start(); - } - - void _onVmChange() { - if (!mounted) return; - - if (_vm.phase == OtaPhase.done) { - _showRestartDialog(); - } else if (_vm.phase == OtaPhase.error) { - _showErrorSnackBar(); - } - } - - void _showRestartDialog() { - final l10n = AppLocalizations.of(context); - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(l10n.updateReady), - content: Text(l10n.updateReadyDescription), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context); - this.context.popOrGo('/bar'); - }, - child: Text(l10n.later), - ), - FilledButton( - onPressed: () { - Navigator.pop(context); - }, - child: Text(l10n.restartApp), - ), - ], - ), - ); - } - - void _showErrorSnackBar() { - final l10n = AppLocalizations.of(context); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.updateError(_vm.errorMessage)), - backgroundColor: Theme.of(context).colorScheme.error, - action: SnackBarAction( - label: l10n.retry, - onPressed: () { - _vm.cancel(); - _vm.start(); - }, - ), - ), - ); + )..init(); } @override void dispose() { - _vm.removeListener(_onVmChange); _vm.dispose(); super.dispose(); } + void _close() { + if (_vm.isBusy) { + _showCancelDialog(); + return; + } + + _vm.cancel(); + context.popOrGo('/bar'); + } + @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { - if (!didPop) _showCancelDialog(); + if (!didPop) _close(); }, child: Scaffold( appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => _showCancelDialog(), - ), - title: Text(l10n.updating), - centerTitle: true, + leading: IconButton(icon: const Icon(Icons.close), onPressed: _close), automaticallyImplyLeading: false, ), body: ListenableBuilder( listenable: _vm, builder: (context, child) { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(), - const SizedBox(height: 40), - _buildProgress(_vm), - const SizedBox(height: 24), - _buildStatus(_vm), - const Spacer(), - _buildVersionInfo(_vm), - ], + return SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header(vm: _vm), + const SizedBox(height: 32), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: switch (_vm.phase) { + UpdatePhase.downloading || + UpdatePhase.installing => _Progress(vm: _vm), + UpdatePhase.waitingForInstaller => + _WaitingForInstaller(onRetry: _vm.start), + UpdatePhase.failed => _Failed( + vm: _vm, + onClose: _close, + ), + }, + ), + if (widget.update.notes.trim().isNotEmpty) ...[ + const SizedBox(height: 32), + _ReleaseNotes(notes: widget.update.notes.trim()), + ], + if (_vm.isBusy) ...[ + const SizedBox(height: 24), + const _DoNotCloseHint(), + ], + ], + ), ), ), ), @@ -148,163 +110,27 @@ class _UpdateProgressViewState extends State { ); } - Widget _buildHeader() { - final l10n = AppLocalizations.of(context); - - return Column( - children: [ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.system_update_alt_rounded, - size: 56, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 24), - Text('KoolTap', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 8), - Text( - l10n.version(widget.update.version), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ); - } - - Widget _buildProgress(UpdateProgressViewModel vm) { - final scheme = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - - return Column( - children: [ - SizedBox( - width: 200, - height: 200, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox.expand( - child: CircularProgressIndicator( - value: vm.phase == OtaPhase.installing ? null : vm.progress, - strokeWidth: 10, - backgroundColor: scheme.primary.withValues(alpha: 0.12), - color: vm.phase == OtaPhase.error - ? scheme.error - : scheme.primary, - ), - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (vm.phase == OtaPhase.error) - Icon( - Icons.error_outline_rounded, - size: 48, - color: scheme.error, - ) - else if (vm.phase == OtaPhase.installing) - Icon( - Icons.settings_rounded, - size: 48, - color: scheme.primary, - ) - else - Text( - '${(vm.progress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.headlineLarge, - ), - const SizedBox(height: 4), - Text( - vm.phase == OtaPhase.installing ? l10n.installing : '', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 24), - if (vm.phase == OtaPhase.downloading) - LinearProgressIndicator( - value: vm.progress, - minHeight: 6, - borderRadius: BorderRadius.circular(999), - ), - ], - ); - } - - Widget _buildStatus(UpdateProgressViewModel vm) { - final scheme = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - - final color = switch (vm.phase) { - OtaPhase.error => scheme.error, - OtaPhase.done => Colors.green, - _ => scheme.onSurface, - }; - - return Text( - l10n.updateStatus(vm.statusText), - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color), - textAlign: TextAlign.center, - ); - } - - Widget _buildVersionInfo(UpdateProgressViewModel vm) { - final l10n = AppLocalizations.of(context); - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.info_outline_rounded, - size: 14, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), - ), - const SizedBox(width: 6), - Text( - l10n.doNotCloseDuringUpdate, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - ), - ), - ], - ); - } - void _showCancelDialog() { final l10n = AppLocalizations.of(context); showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: Text(l10n.cancelUpdate), content: Text(l10n.cancelUpdateDescription), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: Text(l10n.continueUpdate), ), FilledButton( style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, + backgroundColor: Theme.of(dialogContext).colorScheme.error, ), onPressed: () { - Navigator.pop(context); + Navigator.pop(dialogContext); _vm.cancel(); - this.context.popOrGo('/bar'); + context.popOrGo('/bar'); }, child: Text(l10n.cancelUpdateAction), ), @@ -313,3 +139,339 @@ class _UpdateProgressViewState extends State { ); } } + +class _Header extends StatelessWidget { + final UpdateProgressViewModel vm; + + const _Header({required this.vm}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final (icon, background, foreground) = switch (vm.phase) { + UpdatePhase.downloading => ( + Icons.download_rounded, + scheme.primaryContainer, + scheme.onPrimaryContainer, + ), + UpdatePhase.installing => ( + Icons.install_mobile_rounded, + scheme.primaryContainer, + scheme.onPrimaryContainer, + ), + UpdatePhase.waitingForInstaller => ( + Icons.touch_app_rounded, + scheme.tertiaryContainer, + scheme.onTertiaryContainer, + ), + UpdatePhase.failed => ( + Icons.error_outline_rounded, + scheme.errorContainer, + scheme.onErrorContainer, + ), + }; + + return Column( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 88, + height: 88, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(28), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: Icon(icon, key: ValueKey(icon), size: 44, color: foreground), + ), + ), + const SizedBox(height: 24), + Text( + l10n.updatingTo(vm.update.version), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (vm.currentVersion case final current?) ...[ + const SizedBox(height: 12), + _VersionChange(from: current, to: vm.update.version), + ], + ], + ); + } +} + +class _VersionChange extends StatelessWidget { + final String from; + final String to; + + const _VersionChange({required this.from, required this.to}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final style = theme.textTheme.labelLarge?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ); + + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(from, style: style?.copyWith(color: scheme.onSurfaceVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.arrow_forward_rounded, + size: 16, + color: scheme.onSurfaceVariant, + ), + ), + Text(to, style: style?.copyWith(color: scheme.primary)), + ], + ), + ), + ); + } +} + +class _Progress extends StatelessWidget { + final UpdateProgressViewModel vm; + + const _Progress({required this.vm}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final installing = vm.phase == UpdatePhase.installing; + final progress = vm.progress; + + final status = switch ((installing, progress)) { + (true, _) => l10n.installingUpdate, + (false, null) => l10n.preparingDownload, + (false, _) => l10n.downloadingUpdate, + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + status, + style: theme.textTheme.titleMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + if (!installing) + Text( + '${((progress ?? 0) * 100).round()}%', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + const SizedBox(height: 12), + TweenAnimationBuilder( + tween: Tween(end: progress ?? 0), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + builder: (context, value, child) => LinearProgressIndicator( + value: installing || progress == null ? null : value, + minHeight: 12, + borderRadius: BorderRadius.circular(999), + backgroundColor: scheme.surfaceContainerHighest, + ), + ), + ], + ); + } +} + +class _WaitingForInstaller extends StatelessWidget { + final VoidCallback onRetry; + + const _WaitingForInstaller({required this.onRetry}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + return _Panel( + color: theme.colorScheme.tertiaryContainer, + foreground: theme.colorScheme.onTertiaryContainer, + title: l10n.confirmInstallation, + message: l10n.confirmInstallationDescription, + actions: [ + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: Text(l10n.openInstallerAgain), + ), + ], + ); + } +} + +class _Failed extends StatelessWidget { + final UpdateProgressViewModel vm; + final VoidCallback onClose; + + const _Failed({required this.vm, required this.onClose}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + + return _Panel( + color: scheme.errorContainer, + foreground: scheme.onErrorContainer, + title: l10n.updateFailed, + message: l10n.updateFailureText(vm.failure), + actions: [ + TextButton(onPressed: onClose, child: Text(l10n.close)), + FilledButton.icon( + onPressed: vm.start, + icon: const Icon(Icons.refresh_rounded), + label: Text(l10n.retry), + ), + ], + ); + } +} + +class _Panel extends StatelessWidget { + final Color color; + final Color foreground; + final String title; + final String message; + final List actions; + + const _Panel({ + required this.color, + required this.foreground, + required this.title, + required this.message, + required this.actions, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith(color: foreground), + ), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: actions, + ), + ], + ), + ); + } +} + +class _ReleaseNotes extends StatelessWidget { + final String notes; + + const _ReleaseNotes({required this.notes}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.whatsNew, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Text(notes, style: theme.textTheme.bodyMedium), + ), + ], + ); + } +} + +class _DoNotCloseHint extends StatelessWidget { + const _DoNotCloseHint(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final color = theme.colorScheme.onSurfaceVariant; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: color), + const SizedBox(width: 6), + Flexible( + child: Text( + l10n.doNotCloseDuringUpdate, + style: theme.textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ); + } +} diff --git a/lib/views/widgets/product_tile.dart b/lib/views/widgets/product_tile.dart index 24a8ccf..65606ac 100644 --- a/lib/views/widgets/product_tile.dart +++ b/lib/views/widgets/product_tile.dart @@ -26,10 +26,8 @@ class ProductTile extends StatelessWidget { final scheme = theme.colorScheme; final l10n = AppLocalizations.of(context); - final outOfStock = product.stockQuantity <= 0; - final lowStock = - product.stockQuantity > 0 && - product.stockQuantity <= product.lowStockThreshold; + final outOfStock = product.isOutOfStock; + final lowStock = !outOfStock && product.isLowStock; final hasImage = product.imagePath != null && product.imagePath!.isNotEmpty; @@ -142,11 +140,11 @@ class _StockBadge extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - if (product.stockQuantity <= 0) { + if (product.isOutOfStock) { return const SizedBox.shrink(); } - final low = product.stockQuantity <= product.lowStockThreshold; + final low = product.isLowStock; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -157,7 +155,7 @@ class _StockBadge extends StatelessWidget { borderRadius: BorderRadius.circular(999), ), child: Text( - '${product.stockQuantity}', + product.infiniteStock ? '∞' : '${product.stockQuantity}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w800, diff --git a/make_release.ps1 b/make_release.ps1 index 3f344d4..c5183b0 100644 --- a/make_release.ps1 +++ b/make_release.ps1 @@ -7,7 +7,8 @@ $ErrorActionPreference = 'Stop' Set-Location $PSScriptRoot $updateServer = 'https://updater.kooltap.com' -$apkPath = 'build\app\outputs\flutter-apk\app-release.apk' +$pubspecPath = Join-Path $PSScriptRoot 'pubspec.yaml' +$apkPath = Join-Path $PSScriptRoot 'build\app\outputs\flutter-apk\app-release.apk' function Fail([string]$message) { Write-Host $message -ForegroundColor Red @@ -16,7 +17,7 @@ function Fail([string]$message) { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$pubspec = [IO.File]::ReadAllText('pubspec.yaml') +$pubspec = [IO.File]::ReadAllText($pubspecPath) $match = [regex]::Match( $pubspec, '(?m)^version:[ \t]*([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)' @@ -29,22 +30,24 @@ $currentName = $match.Groups[1].Value $currentBuild = [int]$match.Groups[2].Value Write-Host "Current version: $currentName+$currentBuild" -# The server picks the latest release by build number, so every release needs -# a higher one than anything already published +# The server picks the latest release by Android version code, so every release +# needs a higher one than anything already published $publishedName = $null -$publishedBuild = 0 +$publishedCode = 0 try { - foreach ($release in Invoke-RestMethod "$updateServer/api/releases") { - if ($release.VersionCode -gt $publishedBuild) { + $releases = Invoke-RestMethod "$updateServer/api/releases" -UseBasicParsing -TimeoutSec 15 + foreach ($release in $releases) { + if ($release.VersionCode -gt $publishedCode) { $publishedName = $release.Version - $publishedBuild = [int]$release.VersionCode + $publishedCode = [int]$release.VersionCode } } if ($publishedName) { - Write-Host "Latest published: $publishedName+$publishedBuild" + Write-Host "Latest published: $publishedName (version code $publishedCode)" } } catch { - Write-Host "Could not reach $updateServer, using pubspec.yaml only." -ForegroundColor Yellow + Write-Host "Could not reach ${updateServer}: $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host 'Using pubspec.yaml only; the upload is refused if the version is too low.' -ForegroundColor Yellow } $name = Read-Host 'Release version (for example 1.2.2)' @@ -55,14 +58,27 @@ if ($publishedName -and [version]$name -le [version]$publishedName) { Fail "$name must be higher than the published $publishedName, or tablets will not see it." } -$build = [Math]::Max($currentBuild, $publishedBuild) + 1 +# The build number only counts builds of the same version and starts at 0 for +# a new one. Android refuses updates with a lower version code, so that code is +# derived from the whole version instead: 1.2.5+3 becomes 1020503 +$build = if ($name -eq $currentName) { $currentBuild + 1 } else { 0 } +$major, $minor, $patch = $name.Split('.') | ForEach-Object { [int]$_ } +if ($minor -ge 100 -or $patch -ge 100 -or $build -ge 100) { + Fail 'Minor, patch and build numbers must stay below 100.' +} + +$versionCode = $major * 1000000 + $minor * 10000 + $patch * 100 + $build +if ($versionCode -le $publishedCode) { + Fail "Version code $versionCode is not higher than the published $publishedCode." +} + $version = "$name+$build" $versionStart = $match.Groups[1].Index $versionEnd = $match.Groups[2].Index + $match.Groups[2].Length $pubspec = $pubspec.Substring(0, $versionStart) + $version + $pubspec.Substring($versionEnd) -[IO.File]::WriteAllText((Resolve-Path 'pubspec.yaml'), $pubspec) -Write-Host "Updated pubspec.yaml to version $version." +[IO.File]::WriteAllText($pubspecPath, $pubspec) +Write-Host "Updated pubspec.yaml to version $version (version code $versionCode)." if (-not $SkipTests) { Write-Host 'Running tests...' @@ -71,7 +87,7 @@ if (-not $SkipTests) { } Write-Host 'Building release APK...' -flutter build apk --release +flutter build apk --release --build-number=$versionCode if ($LASTEXITCODE -ne 0) { Fail 'Release APK build failed.' } Write-Host "Release APK created at $apkPath" @@ -93,7 +109,7 @@ $client = [Net.Http.HttpClient]::new() $client.Timeout = [TimeSpan]::FromMinutes(5) $client.DefaultRequestHeaders.Add('X-Admin-Token', $token) -$apk = [IO.File]::OpenRead((Resolve-Path $apkPath)) +$apk = [IO.File]::OpenRead($apkPath) $form = [Net.Http.MultipartFormDataContent]::new() $form.Add([Net.Http.StreamContent]::new($apk), 'apk', 'app-release.apk') $form.Add([Net.Http.StringContent]::new($notes), 'notes') diff --git a/pubspec.yaml b/pubspec.yaml index 2e4c11c..6121778 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.2.1+1 +version: 1.2.7+0 environment: sdk: ^3.12.2 diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart index 95a588a..1804dc6 100644 --- a/test/database_migration_test.dart +++ b/test/database_migration_test.dart @@ -2,7 +2,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/database/app_database.dart'; -/// The parts of the v12 schema that the v13 migration touches. +/// The parts of the v12 schema that the v13 and v14 migrations touch. const _v12Schema = [ 'CREATE TABLE bar_tabs (id TEXT NOT NULL, customer_name TEXT NOT NULL, ' "status TEXT NOT NULL DEFAULT 'open', opened_at INTEGER NOT NULL, " @@ -15,6 +15,13 @@ const _v12Schema = [ 'CREATE TABLE tab_item_purchases (id TEXT NOT NULL, ' 'tab_item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, ' 'removed_at INTEGER NULL, PRIMARY KEY (id))', + 'CREATE TABLE products (id TEXT NOT NULL, name TEXT NOT NULL, ' + 'category TEXT NOT NULL, stock_quantity INTEGER NOT NULL, ' + 'low_stock_threshold INTEGER NOT NULL, price_in_cents INTEGER NOT NULL, ' + 'image_path TEXT NULL, active INTEGER NOT NULL DEFAULT 1, ' + 'PRIMARY KEY (id))', + "INSERT INTO products VALUES ('prod-1', 'Beer', 'Drinks', 20, 5, 400, " + 'NULL, 1)', "INSERT INTO bar_tabs VALUES ('tab-1', 'Alice', 'open', 1700000000, NULL, 4)", "INSERT INTO tab_items VALUES ('item-1', 'tab-1', 'prod-1', 'Beer', 3, 400, " '1700000100)', @@ -68,6 +75,13 @@ void main() { expect(purchases.where((row) => row.removedAt != null), hasLength(1)); }); + test('gives existing products a counted stock', () async { + final product = await database.select(database.products).getSingle(); + + expect(product.infiniteStock, false); + expect(product.stockQuantity, 20); + }); + test('deletes purchases whose item no longer exists', () async { final orphan = await (database.select( database.tabItemPurchases, diff --git a/test/product_grid_test.dart b/test/product_grid_test.dart new file mode 100644 index 0000000..2f02684 --- /dev/null +++ b/test/product_grid_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/views/bar/product_grid.dart'; +import 'package:kooltap/views/widgets/product_tile.dart'; + +final _products = [ + for (var i = 0; i < 60; i++) + Product( + id: 'prod-$i', + name: 'Product $i', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 300, + ), +]; + +void main() { + Future pumpGrid(WidgetTester tester, int rows, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ProductGrid( + products: _products, + rows: rows, + hasSelectedTab: true, + onProductTap: (_) {}, + ), + ), + ), + ); + } + + for (final size in [const Size(853, 720), const Size(1280, 1000)]) { + for (var rows = 2; rows <= 6; rows++) { + testWidgets('shows exactly $rows rows at $size', (tester) async { + await pumpGrid(tester, rows, size); + + final grid = tester.getRect(find.byType(GridView)); + final tiles = tester + .widgetList(find.byType(ProductTile)) + .map((tile) => tester.getRect(find.byWidget(tile))) + .toList(); + + final fullyVisibleRows = { + for (final tile in tiles) + if (tile.top >= grid.top && tile.bottom <= grid.bottom + 0.5) + tile.top.round(), + }; + expect(fullyVisibleRows, hasLength(rows)); + + final lastRowBottom = tiles + .where((tile) => tile.bottom <= grid.bottom + 0.5) + .map((tile) => tile.bottom) + .reduce((a, b) => a > b ? a : b); + expect( + grid.bottom - lastRowBottom, + closeTo(16, 1), + reason: 'the rows fill the height down to the bottom padding', + ); + }); + } + } +} diff --git a/test/product_service_test.dart b/test/product_service_test.dart index b590711..bcd4fc0 100644 --- a/test/product_service_test.dart +++ b/test/product_service_test.dart @@ -319,5 +319,64 @@ void main() { expect(product!.stockQuantity, 0); }); }); + + group('infinite stock', () { + setUp(() async { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'water', + name: 'Water', + category: 'Drinks', + stockQuantity: 0, + lowStockThreshold: 0, + infiniteStock: const Value(true), + priceInCents: 100, + ), + ); + }); + + test('sells without stock and leaves the count alone', () async { + await service.decreaseStock('water', 5); + await service.increaseStock('water', 2); + + final product = await service.getProductById('water'); + expect(product!.stockQuantity, 0); + expect(product.infiniteStock, true); + expect(product.isOutOfStock, false); + expect(product.isLowStock, false); + }); + + test('still reports a missing product', () async { + await expectLater( + service.increaseStock('nonexistent', 1), + throwsA(isA()), + ); + }); + + test('is saved by create and update', () async { + await service.createProduct( + name: 'Tap water', + category: 'Drinks', + stockQuantity: 0, + lowStockThreshold: 0, + infiniteStock: true, + priceInCents: 0, + imagePath: null, + ); + final created = (await service.getProducts()).firstWhere( + (product) => product.name == 'Tap water', + ); + expect(created.infiniteStock, true); + + await service.updateProduct( + created.copyWith(infiniteStock: false, stockQuantity: 4), + ); + final updated = await service.getProductById(created.id); + expect(updated!.infiniteStock, false); + expect(updated.stockQuantity, 4); + }); + }); }); } diff --git a/test/tab_panel_index_test.dart b/test/tab_panel_index_test.dart new file mode 100644 index 0000000..5033351 --- /dev/null +++ b/test/tab_panel_index_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/utils/name_index.dart'; +import 'package:kooltap/views/bar/tab_panel.dart'; + +const _names = [ + 'Zoë', 'anna', 'Émile', 'Bram', 'Mieke', 'Marc', 'Ward', 'Tom', 'Sofie', // + 'Lotte', 'Koen', 'Jan', 'Inge', 'Hanne', 'Greet', 'Frank', 'Dirk', 'Chris', + 'Nele', 'Olaf', 'Pieter', 'Rik', 'Vera', 'Yves', '2de tafel', 'Eva', +]; + +void main() { + group('name index', () { + test('folds case and accents into the index letter', () { + expect(indexLetterOf('émile'), 'E'); + expect(indexLetterOf('Zoë'), 'Z'); + expect(indexLetterOf(' anna'), 'A'); + expect(indexLetterOf('2de tafel'), '#'); + expect(indexLetterOf(''), '#'); + }); + + test('sorts A–Z regardless of case and accents, # last', () { + final sorted = ['Zoë', 'émile', 'Eva', 'anna', '2de tafel', 'Bram'] + ..sort(compareByIndex); + + expect(sorted, ['anna', 'Bram', 'émile', 'Eva', 'Zoë', '2de tafel']); + }); + }); + + group('TabPanel letter index', () { + final tabs = [ + for (final (index, name) in _names.indexed) + BarTab( + id: 'tab-$index', + customerName: name, + openedAt: DateTime(2026, 10, 11, 12, index), + items: const [], + ), + ]; + + Future pumpPanel( + WidgetTester tester, { + bool showSearch = false, + }) async { + tester.view.physicalSize = const Size(500, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TabPanel( + tabs: tabs, + selectedTab: null, + selectedTabId: null, + stockByProductId: const {}, + showSearch: showSearch, + onTabSelected: (_) {}, + onItemQuantityChanged: (_, _) async {}, + onCloseTabPressed: () {}, + onTabEdited: (_) async {}, + onTabDeleted: (_) async {}, + ), + ), + ), + ); + } + + String firstVisibleName(WidgetTester tester) { + final list = tester.getRect(find.byType(OpenTabsList)); + final visible = _names.where((name) { + final finder = find.text(name); + if (finder.evaluate().isEmpty) return false; + final rect = tester.getRect(finder); + return rect.top >= list.top && rect.bottom <= list.bottom; + }).toList(); + + return visible.reduce( + (a, b) => + tester.getRect(find.text(a)).top < tester.getRect(find.text(b)).top + ? a + : b, + ); + } + + Future tapLetter(WidgetTester tester, String letter) async { + await tester.tap( + find.descendant( + of: find.byWidgetPredicate( + (widget) => widget.runtimeType.toString() == '_LetterIndexBar', + ), + matching: find.text(letter), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('lists the tabs alphabetically', (tester) async { + await pumpPanel(tester); + + expect(firstVisibleName(tester), 'anna'); + }); + + testWidgets('tapping a letter scrolls to its first name', (tester) async { + await pumpPanel(tester); + + await tapLetter(tester, 'M'); + expect(firstVisibleName(tester), 'Marc'); + + await tapLetter(tester, 'E'); + expect(firstVisibleName(tester), 'Émile'); + }); + + testWidgets('shows the search bar only when asked', (tester) async { + await pumpPanel(tester); + expect(find.byType(TextField), findsNothing); + + await pumpPanel(tester, showSearch: true); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('closing the search clears the filter', (tester) async { + await pumpPanel(tester, showSearch: true); + await tester.enterText(find.byType(TextField), 'Marc'); + await tester.pump(); + expect(find.text('anna'), findsNothing); + + await pumpPanel(tester); + expect(find.text('anna'), findsOneWidget); + }); + + testWidgets('a letter without names goes to the next one', (tester) async { + await pumpPanel(tester); + + await tapLetter(tester, 'Q'); + expect(firstVisibleName(tester), 'Rik'); + }); + }); +}