From 71af6f9ffea863ede8f018d2fad52006d12bd335 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Thu, 22 Oct 2026 00:10:26 +0200 Subject: [PATCH] feat: add remote products and qr code for payconic --- assets/payconiq/.gitkeep | 0 lib/app/router.dart | 2 + lib/database/app_database.dart | 31 +- lib/database/app_database.g.dart | 528 ++++++++++++++++++ lib/database/catalog_queries.dart | 213 +++++-- lib/database/stock_queries.dart | 8 +- lib/l10n/app_en.arb | 9 +- lib/l10n/app_localizations.dart | 12 +- lib/l10n/app_localizations_en.dart | 9 +- lib/l10n/app_localizations_nl.dart | 9 +- lib/l10n/app_nl.arb | 4 +- lib/main.dart | 15 + lib/models/remote_command.dart | 5 + lib/models/tab_event.dart | 52 +- lib/services/bar_tab_service.dart | 6 +- lib/services/default_product_seeder.dart | 2 +- lib/services/kooltap_api_client.dart | 37 ++ lib/services/product_image_sync.dart | 180 ++++++ lib/services/product_service.dart | 59 +- lib/services/remote_sync_service.dart | 29 + lib/views/dialogs/close_tab_dialog.dart | 62 +- lib/views/pin_lock_view.dart | 27 +- lib/views/product_form_view.dart | 15 +- lib/views/product_list_view.dart | 25 +- .../widgets/managed_on_website_notice.dart | 24 - pubspec.yaml | 3 +- test/database_migration_test.dart | 7 + test/e2e/website_catalog_e2e_test.dart | 154 ++++- test/product_image_sync_test.dart | 273 +++++++++ test/remote_sync_service_test.dart | 244 +++++++- test/tab_events_test.dart | 188 ++++++- 31 files changed, 2053 insertions(+), 179 deletions(-) create mode 100644 assets/payconiq/.gitkeep create mode 100644 lib/services/product_image_sync.dart delete mode 100644 lib/views/widgets/managed_on_website_notice.dart create mode 100644 test/product_image_sync_test.dart diff --git a/assets/payconiq/.gitkeep b/assets/payconiq/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/app/router.dart b/lib/app/router.dart index 162975d..8385559 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -168,6 +168,8 @@ GoRouter createAppRouter({ onSet: adminPinViewModel.setPin, error: () => adminPinViewModel.error, onSuccess: () => context.go(_returnLocation(state) ?? '/admin'), + onClose: () => + context.canPop() ? context.pop() : context.go('/bar'), ), ), diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index c59642e..8bdb188 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -36,8 +36,13 @@ class Products extends Table { IntColumn get priceInCents => integer()(); + /// The picture shown on the tile: a local file, also offline. TextColumn get imagePath => text().nullable()(); + /// The server's picture of this product (`/images/`); [imagePath] + /// is its downloaded copy once that finished. Null: none on the server. + TextColumn get imageId => text().nullable()(); + BoolColumn get active => boolean().withDefault(const Constant(true))(); @override @@ -227,6 +232,22 @@ class StockAdjustmentResults extends Table { Set get primaryKey => {id}; } +/// Pictures taken on this tablet, waiting to be uploaded. Once uploaded, the +/// product points at the server's copy and the row is removed; offline it +/// waits here, while the tile already shows the local file. +@DataClassName('PendingImageUploadRow') +class PendingImageUploads extends Table { + /// One picture per product: a newer one replaces a waiting one. + TextColumn get productId => text()(); + + TextColumn get localPath => text()(); + + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {productId}; +} + @DriftDatabase( tables: [ Products, @@ -240,13 +261,14 @@ class StockAdjustmentResults extends Table { ProcessedRemoteCommands, TabEvents, StockAdjustmentResults, + PendingImageUploads, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 17; + int get schemaVersion => 18; @override MigrationStrategy get migration { @@ -362,6 +384,13 @@ class AppDatabase extends _$AppDatabase { remoteSyncState.settlementsCursor, ); } + + if (from < 18) { + if (!await hasTableColumn('products', 'image_id')) { + await migrator.addColumn(products, products.imageId); + } + await migrator.createTable(pendingImageUploads); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index c9d0d08..10d76d9 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -97,6 +97,17 @@ class $ProductsTable extends Products type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _imageIdMeta = const VerificationMeta( + 'imageId', + ); + @override + late final GeneratedColumn imageId = GeneratedColumn( + 'image_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _activeMeta = const VerificationMeta('active'); @override late final GeneratedColumn active = GeneratedColumn( @@ -120,6 +131,7 @@ class $ProductsTable extends Products infiniteStock, priceInCents, imagePath, + imageId, active, ]; @override @@ -203,6 +215,12 @@ class $ProductsTable extends Products imagePath.isAcceptableOrUnknown(data['image_path']!, _imagePathMeta), ); } + if (data.containsKey('image_id')) { + context.handle( + _imageIdMeta, + imageId.isAcceptableOrUnknown(data['image_id']!, _imageIdMeta), + ); + } if (data.containsKey('active')) { context.handle( _activeMeta, @@ -250,6 +268,10 @@ class $ProductsTable extends Products DriftSqlType.string, data['${effectivePrefix}image_path'], ), + imageId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}image_id'], + ), active: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}active'], @@ -273,7 +295,13 @@ class ProductRow extends DataClass implements Insertable { /// Products like tap water that are never counted; their stock is ignored. final bool infiniteStock; final int priceInCents; + + /// The picture shown on the tile: a local file, also offline. final String? imagePath; + + /// The server's picture of this product (`/images/`); [imagePath] + /// is its downloaded copy once that finished. Null: none on the server. + final String? imageId; final bool active; const ProductRow({ required this.id, @@ -284,6 +312,7 @@ class ProductRow extends DataClass implements Insertable { required this.infiniteStock, required this.priceInCents, this.imagePath, + this.imageId, required this.active, }); @override @@ -299,6 +328,9 @@ class ProductRow extends DataClass implements Insertable { if (!nullToAbsent || imagePath != null) { map['image_path'] = Variable(imagePath); } + if (!nullToAbsent || imageId != null) { + map['image_id'] = Variable(imageId); + } map['active'] = Variable(active); return map; } @@ -315,6 +347,9 @@ class ProductRow extends DataClass implements Insertable { imagePath: imagePath == null && nullToAbsent ? const Value.absent() : Value(imagePath), + imageId: imageId == null && nullToAbsent + ? const Value.absent() + : Value(imageId), active: Value(active), ); } @@ -333,6 +368,7 @@ class ProductRow extends DataClass implements Insertable { infiniteStock: serializer.fromJson(json['infiniteStock']), priceInCents: serializer.fromJson(json['priceInCents']), imagePath: serializer.fromJson(json['imagePath']), + imageId: serializer.fromJson(json['imageId']), active: serializer.fromJson(json['active']), ); } @@ -348,6 +384,7 @@ class ProductRow extends DataClass implements Insertable { 'infiniteStock': serializer.toJson(infiniteStock), 'priceInCents': serializer.toJson(priceInCents), 'imagePath': serializer.toJson(imagePath), + 'imageId': serializer.toJson(imageId), 'active': serializer.toJson(active), }; } @@ -361,6 +398,7 @@ class ProductRow extends DataClass implements Insertable { bool? infiniteStock, int? priceInCents, Value imagePath = const Value.absent(), + Value imageId = const Value.absent(), bool? active, }) => ProductRow( id: id ?? this.id, @@ -371,6 +409,7 @@ class ProductRow extends DataClass implements Insertable { infiniteStock: infiniteStock ?? this.infiniteStock, priceInCents: priceInCents ?? this.priceInCents, imagePath: imagePath.present ? imagePath.value : this.imagePath, + imageId: imageId.present ? imageId.value : this.imageId, active: active ?? this.active, ); ProductRow copyWithCompanion(ProductsCompanion data) { @@ -391,6 +430,7 @@ class ProductRow extends DataClass implements Insertable { ? data.priceInCents.value : this.priceInCents, imagePath: data.imagePath.present ? data.imagePath.value : this.imagePath, + imageId: data.imageId.present ? data.imageId.value : this.imageId, active: data.active.present ? data.active.value : this.active, ); } @@ -406,6 +446,7 @@ class ProductRow extends DataClass implements Insertable { ..write('infiniteStock: $infiniteStock, ') ..write('priceInCents: $priceInCents, ') ..write('imagePath: $imagePath, ') + ..write('imageId: $imageId, ') ..write('active: $active') ..write(')')) .toString(); @@ -421,6 +462,7 @@ class ProductRow extends DataClass implements Insertable { infiniteStock, priceInCents, imagePath, + imageId, active, ); @override @@ -435,6 +477,7 @@ class ProductRow extends DataClass implements Insertable { other.infiniteStock == this.infiniteStock && other.priceInCents == this.priceInCents && other.imagePath == this.imagePath && + other.imageId == this.imageId && other.active == this.active); } @@ -447,6 +490,7 @@ class ProductsCompanion extends UpdateCompanion { final Value infiniteStock; final Value priceInCents; final Value imagePath; + final Value imageId; final Value active; final Value rowid; const ProductsCompanion({ @@ -458,6 +502,7 @@ class ProductsCompanion extends UpdateCompanion { this.infiniteStock = const Value.absent(), this.priceInCents = const Value.absent(), this.imagePath = const Value.absent(), + this.imageId = const Value.absent(), this.active = const Value.absent(), this.rowid = const Value.absent(), }); @@ -470,6 +515,7 @@ class ProductsCompanion extends UpdateCompanion { this.infiniteStock = const Value.absent(), required int priceInCents, this.imagePath = const Value.absent(), + this.imageId = const Value.absent(), this.active = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), @@ -487,6 +533,7 @@ class ProductsCompanion extends UpdateCompanion { Expression? infiniteStock, Expression? priceInCents, Expression? imagePath, + Expression? imageId, Expression? active, Expression? rowid, }) { @@ -499,6 +546,7 @@ class ProductsCompanion extends UpdateCompanion { if (infiniteStock != null) 'infinite_stock': infiniteStock, if (priceInCents != null) 'price_in_cents': priceInCents, if (imagePath != null) 'image_path': imagePath, + if (imageId != null) 'image_id': imageId, if (active != null) 'active': active, if (rowid != null) 'rowid': rowid, }); @@ -513,6 +561,7 @@ class ProductsCompanion extends UpdateCompanion { Value? infiniteStock, Value? priceInCents, Value? imagePath, + Value? imageId, Value? active, Value? rowid, }) { @@ -525,6 +574,7 @@ class ProductsCompanion extends UpdateCompanion { infiniteStock: infiniteStock ?? this.infiniteStock, priceInCents: priceInCents ?? this.priceInCents, imagePath: imagePath ?? this.imagePath, + imageId: imageId ?? this.imageId, active: active ?? this.active, rowid: rowid ?? this.rowid, ); @@ -557,6 +607,9 @@ class ProductsCompanion extends UpdateCompanion { if (imagePath.present) { map['image_path'] = Variable(imagePath.value); } + if (imageId.present) { + map['image_id'] = Variable(imageId.value); + } if (active.present) { map['active'] = Variable(active.value); } @@ -577,6 +630,7 @@ class ProductsCompanion extends UpdateCompanion { ..write('infiniteStock: $infiniteStock, ') ..write('priceInCents: $priceInCents, ') ..write('imagePath: $imagePath, ') + ..write('imageId: $imageId, ') ..write('active: $active, ') ..write('rowid: $rowid') ..write(')')) @@ -4447,6 +4501,278 @@ class StockAdjustmentResultsCompanion } } +class $PendingImageUploadsTable extends PendingImageUploads + with TableInfo<$PendingImageUploadsTable, PendingImageUploadRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PendingImageUploadsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _productIdMeta = const VerificationMeta( + 'productId', + ); + @override + late final GeneratedColumn productId = GeneratedColumn( + 'product_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _localPathMeta = const VerificationMeta( + 'localPath', + ); + @override + late final GeneratedColumn localPath = GeneratedColumn( + 'local_path', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [productId, localPath, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'pending_image_uploads'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('product_id')) { + context.handle( + _productIdMeta, + productId.isAcceptableOrUnknown(data['product_id']!, _productIdMeta), + ); + } else if (isInserting) { + context.missing(_productIdMeta); + } + if (data.containsKey('local_path')) { + context.handle( + _localPathMeta, + localPath.isAcceptableOrUnknown(data['local_path']!, _localPathMeta), + ); + } else if (isInserting) { + context.missing(_localPathMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {productId}; + @override + PendingImageUploadRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PendingImageUploadRow( + productId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}product_id'], + )!, + localPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}local_path'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $PendingImageUploadsTable createAlias(String alias) { + return $PendingImageUploadsTable(attachedDatabase, alias); + } +} + +class PendingImageUploadRow extends DataClass + implements Insertable { + /// One picture per product: a newer one replaces a waiting one. + final String productId; + final String localPath; + final DateTime createdAt; + const PendingImageUploadRow({ + required this.productId, + required this.localPath, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['product_id'] = Variable(productId); + map['local_path'] = Variable(localPath); + map['created_at'] = Variable(createdAt); + return map; + } + + PendingImageUploadsCompanion toCompanion(bool nullToAbsent) { + return PendingImageUploadsCompanion( + productId: Value(productId), + localPath: Value(localPath), + createdAt: Value(createdAt), + ); + } + + factory PendingImageUploadRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PendingImageUploadRow( + productId: serializer.fromJson(json['productId']), + localPath: serializer.fromJson(json['localPath']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'productId': serializer.toJson(productId), + 'localPath': serializer.toJson(localPath), + 'createdAt': serializer.toJson(createdAt), + }; + } + + PendingImageUploadRow copyWith({ + String? productId, + String? localPath, + DateTime? createdAt, + }) => PendingImageUploadRow( + productId: productId ?? this.productId, + localPath: localPath ?? this.localPath, + createdAt: createdAt ?? this.createdAt, + ); + PendingImageUploadRow copyWithCompanion(PendingImageUploadsCompanion data) { + return PendingImageUploadRow( + productId: data.productId.present ? data.productId.value : this.productId, + localPath: data.localPath.present ? data.localPath.value : this.localPath, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('PendingImageUploadRow(') + ..write('productId: $productId, ') + ..write('localPath: $localPath, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(productId, localPath, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PendingImageUploadRow && + other.productId == this.productId && + other.localPath == this.localPath && + other.createdAt == this.createdAt); +} + +class PendingImageUploadsCompanion + extends UpdateCompanion { + final Value productId; + final Value localPath; + final Value createdAt; + final Value rowid; + const PendingImageUploadsCompanion({ + this.productId = const Value.absent(), + this.localPath = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + PendingImageUploadsCompanion.insert({ + required String productId, + required String localPath, + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : productId = Value(productId), + localPath = Value(localPath), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? productId, + Expression? localPath, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (productId != null) 'product_id': productId, + if (localPath != null) 'local_path': localPath, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + PendingImageUploadsCompanion copyWith({ + Value? productId, + Value? localPath, + Value? createdAt, + Value? rowid, + }) { + return PendingImageUploadsCompanion( + productId: productId ?? this.productId, + localPath: localPath ?? this.localPath, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (productId.present) { + map['product_id'] = Variable(productId.value); + } + if (localPath.present) { + map['local_path'] = Variable(localPath.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PendingImageUploadsCompanion(') + ..write('productId: $productId, ') + ..write('localPath: $localPath, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -4469,6 +4795,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $TabEventsTable tabEvents = $TabEventsTable(this); late final $StockAdjustmentResultsTable stockAdjustmentResults = $StockAdjustmentResultsTable(this); + late final $PendingImageUploadsTable pendingImageUploads = + $PendingImageUploadsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -4485,6 +4813,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { processedRemoteCommands, tabEvents, stockAdjustmentResults, + pendingImageUploads, ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ @@ -4515,6 +4844,7 @@ typedef $$ProductsTableCreateCompanionBuilder = Value infiniteStock, required int priceInCents, Value imagePath, + Value imageId, Value active, Value rowid, }); @@ -4528,6 +4858,7 @@ typedef $$ProductsTableUpdateCompanionBuilder = Value infiniteStock, Value priceInCents, Value imagePath, + Value imageId, Value active, Value rowid, }); @@ -4604,6 +4935,11 @@ class $$ProductsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get imageId => $composableBuilder( + column: $table.imageId, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get active => $composableBuilder( column: $table.active, builder: (column) => ColumnFilters(column), @@ -4684,6 +5020,11 @@ class $$ProductsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get imageId => $composableBuilder( + column: $table.imageId, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get active => $composableBuilder( column: $table.active, builder: (column) => ColumnOrderings(column), @@ -4731,6 +5072,9 @@ class $$ProductsTableAnnotationComposer GeneratedColumn get imagePath => $composableBuilder(column: $table.imagePath, builder: (column) => column); + GeneratedColumn get imageId => + $composableBuilder(column: $table.imageId, builder: (column) => column); + GeneratedColumn get active => $composableBuilder(column: $table.active, builder: (column) => column); @@ -4796,6 +5140,7 @@ class $$ProductsTableTableManager Value infiniteStock = const Value.absent(), Value priceInCents = const Value.absent(), Value imagePath = const Value.absent(), + Value imageId = const Value.absent(), Value active = const Value.absent(), Value rowid = const Value.absent(), }) => ProductsCompanion( @@ -4807,6 +5152,7 @@ class $$ProductsTableTableManager infiniteStock: infiniteStock, priceInCents: priceInCents, imagePath: imagePath, + imageId: imageId, active: active, rowid: rowid, ), @@ -4820,6 +5166,7 @@ class $$ProductsTableTableManager Value infiniteStock = const Value.absent(), required int priceInCents, Value imagePath = const Value.absent(), + Value imageId = const Value.absent(), Value active = const Value.absent(), Value rowid = const Value.absent(), }) => ProductsCompanion.insert( @@ -4831,6 +5178,7 @@ class $$ProductsTableTableManager infiniteStock: infiniteStock, priceInCents: priceInCents, imagePath: imagePath, + imageId: imageId, active: active, rowid: rowid, ), @@ -7549,6 +7897,184 @@ typedef $$StockAdjustmentResultsTableProcessedTableManager = StockAdjustmentResultRow, PrefetchHooks Function() >; +typedef $$PendingImageUploadsTableCreateCompanionBuilder = + PendingImageUploadsCompanion Function({ + required String productId, + required String localPath, + required DateTime createdAt, + Value rowid, + }); +typedef $$PendingImageUploadsTableUpdateCompanionBuilder = + PendingImageUploadsCompanion Function({ + Value productId, + Value localPath, + Value createdAt, + Value rowid, + }); + +class $$PendingImageUploadsTableFilterComposer + extends Composer<_$AppDatabase, $PendingImageUploadsTable> { + $$PendingImageUploadsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get productId => $composableBuilder( + column: $table.productId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localPath => $composableBuilder( + column: $table.localPath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$PendingImageUploadsTableOrderingComposer + extends Composer<_$AppDatabase, $PendingImageUploadsTable> { + $$PendingImageUploadsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get productId => $composableBuilder( + column: $table.productId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localPath => $composableBuilder( + column: $table.localPath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$PendingImageUploadsTableAnnotationComposer + extends Composer<_$AppDatabase, $PendingImageUploadsTable> { + $$PendingImageUploadsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get productId => + $composableBuilder(column: $table.productId, builder: (column) => column); + + GeneratedColumn get localPath => + $composableBuilder(column: $table.localPath, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$PendingImageUploadsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $PendingImageUploadsTable, + PendingImageUploadRow, + $$PendingImageUploadsTableFilterComposer, + $$PendingImageUploadsTableOrderingComposer, + $$PendingImageUploadsTableAnnotationComposer, + $$PendingImageUploadsTableCreateCompanionBuilder, + $$PendingImageUploadsTableUpdateCompanionBuilder, + ( + PendingImageUploadRow, + BaseReferences< + _$AppDatabase, + $PendingImageUploadsTable, + PendingImageUploadRow + >, + ), + PendingImageUploadRow, + PrefetchHooks Function() + > { + $$PendingImageUploadsTableTableManager( + _$AppDatabase db, + $PendingImageUploadsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PendingImageUploadsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PendingImageUploadsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$PendingImageUploadsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value productId = const Value.absent(), + Value localPath = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => PendingImageUploadsCompanion( + productId: productId, + localPath: localPath, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String productId, + required String localPath, + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => PendingImageUploadsCompanion.insert( + productId: productId, + localPath: localPath, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$PendingImageUploadsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $PendingImageUploadsTable, + PendingImageUploadRow, + $$PendingImageUploadsTableFilterComposer, + $$PendingImageUploadsTableOrderingComposer, + $$PendingImageUploadsTableAnnotationComposer, + $$PendingImageUploadsTableCreateCompanionBuilder, + $$PendingImageUploadsTableUpdateCompanionBuilder, + ( + PendingImageUploadRow, + BaseReferences< + _$AppDatabase, + $PendingImageUploadsTable, + PendingImageUploadRow + >, + ), + PendingImageUploadRow, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -7581,4 +8107,6 @@ class $AppDatabaseManager { _db, _db.stockAdjustmentResults, ); + $$PendingImageUploadsTableTableManager get pendingImageUploads => + $$PendingImageUploadsTableTableManager(_db, _db.pendingImageUploads); } diff --git a/lib/database/catalog_queries.dart b/lib/database/catalog_queries.dart index c339f43..8a4ad26 100644 --- a/lib/database/catalog_queries.dart +++ b/lib/database/catalog_queries.dart @@ -1,73 +1,184 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import '../models/remote_command.dart'; +import '../models/tab_event.dart'; import 'app_database.dart'; -/// The product catalog as the website manages it. +/// A product as the catalog and this tablet's unsent changes make it. +class _Product { + String name; + String category; + int priceInCents; + int lowStockThreshold; + bool active; + String? imageId; + String? imagePath; + + _Product(ProductRow row) + : name = row.name, + category = row.category, + priceInCents = row.priceInCents, + lowStockThreshold = row.lowStockThreshold, + active = row.active, + imageId = row.imageId, + imagePath = row.imagePath; + + _Product.fromServer(ServerProduct product) + : name = product.name, + category = product.category ?? '', + priceInCents = product.priceInCents, + lowStockThreshold = product.lowStockThreshold ?? 0, + active = !product.archived, + imageId = product.imageId, + imagePath = null; + + bool sameAs(ProductRow row) => + name == row.name && + category == row.category && + priceInCents == row.priceInCents && + lowStockThreshold == row.lowStockThreshold && + active == row.active && + imageId == row.imageId && + imagePath == row.imagePath; + + ProductsCompanion toCompanion() => ProductsCompanion( + name: Value(name), + category: Value(category), + priceInCents: Value(priceInCents), + lowStockThreshold: Value(lowStockThreshold), + active: Value(active), + imageId: Value(imageId), + imagePath: Value(imagePath), + ); + + /// Applies the fields of a `product_created` / `product_updated` event. + void apply(Map fields) { + if (fields['name'] case final String value) name = value; + if (fields.containsKey('category')) { + category = fields['category'] as String? ?? ''; + } + if (fields['priceInCents'] case final int value) priceInCents = value; + if (fields.containsKey('lowStockThreshold')) { + lowStockThreshold = fields['lowStockThreshold'] as int? ?? 0; + } + if (fields.containsKey('imageId')) imageId = fields['imageId'] as String?; + } +} + +/// The product catalog: the website's, with this tablet's changes on top. extension CatalogQueries on AppDatabase { - /// Makes the local products match [catalog]: new products are added, - /// changed ones updated, archived ones and products the server doesn't - /// know (such as the default products of a new tablet) are hidden. Hidden, - /// not deleted: open tabs and the history refer to them. + /// Makes the local products match [catalog], then replays this tablet's + /// product changes the server hasn't applied yet on top, so an offline + /// edit stays visible until the server has it (the same idea as the tabs). /// - /// The stock is not touched here: the stock rebase that follows sets it, a - /// new product starts as untracked until then. Local images are kept. + /// New products are added; archived ones, and products the server doesn't + /// know and this tablet didn't create (such as the default products of a + /// new tablet), are hidden: open tabs and the history refer to them. + /// + /// Pictures: a changed one keeps showing the old file until + /// [ProductImageSync] downloaded the new one; a picture waiting to be + /// uploaded from this tablet is left alone. The stock is not touched here: + /// the stock rebase that follows sets it. Future applyServerCatalog(List catalog) { return transaction(() async { - final local = { + final rows = { for (final row in await select(products).get()) row.id: row, }; + final uploading = { + for (final row in await select(pendingImageUploads).get()) + row.productId, + }; + final desired = {for (final row in rows.values) row.id: _Product(row)}; + final listed = {}; for (final product in catalog) { - final category = product.category ?? ''; - final lowStockThreshold = product.lowStockThreshold ?? 0; - final existing = local.remove(product.id); - - if (existing == null) { - await into(products).insert( - ProductsCompanion.insert( - id: product.id, - name: product.name, - category: category, - stockQuantity: 0, - lowStockThreshold: lowStockThreshold, - infiniteStock: const Value(true), - priceInCents: product.priceInCents, - active: Value(!product.archived), - ), - ); + listed.add(product.id); + final local = desired[product.id]; + final next = _Product.fromServer(product); + if (local == null) { + desired[product.id] = next; continue; } - - if (existing.name == product.name && - existing.category == category && - existing.priceInCents == product.priceInCents && - existing.lowStockThreshold == lowStockThreshold && - existing.active == !product.archived) { - continue; + if (uploading.contains(product.id)) { + next + ..imageId = local.imageId + ..imagePath = local.imagePath; + } else if (next.imageId != null && next.imageId == local.imageId) { + next.imagePath = local.imagePath; + } else if (next.imageId != null) { + // Until the new picture is downloaded, the old one is better than none + next.imagePath = local.imagePath; } - await (update( - products, - )..where((row) => row.id.equals(product.id))).write( - ProductsCompanion( - name: Value(product.name), - category: Value(category), - priceInCents: Value(product.priceInCents), - lowStockThreshold: Value(lowStockThreshold), - active: Value(!product.archived), - ), - ); + desired[product.id] = next; + } + for (final entry in desired.entries) { + if (!listed.contains(entry.key)) entry.value.active = false; } - final unknown = [ - for (final row in local.values) - if (row.active) row.id, - ]; - if (unknown.isNotEmpty) { - await (update(products)..where((row) => row.id.isIn(unknown))).write( - const ProductsCompanion(active: Value(false)), - ); + for (final event in await _pendingProductEvents()) { + final productId = event['productId'] as String?; + final local = productId == null ? null : desired[productId]; + if (local == null) continue; + switch (TabEventType.fromValue(event['type'] as String? ?? '')) { + case TabEventType.productCreated || TabEventType.productUpdated: + local.apply(event['product'] as Map? ?? {}); + if (event['type'] == TabEventType.productCreated.value) { + local.active = true; + } + case TabEventType.productArchived: + local.active = false; + default: + break; + } + } + + for (final MapEntry(key: id, value: product) in desired.entries) { + final row = rows[id]; + if (row == null) { + await into(products).insert( + product.toCompanion().copyWith( + id: Value(id), + // Untracked until the stock rebase sets its stock + stockQuantity: const Value(0), + infiniteStock: const Value(true), + ), + ); + } else if (!product.sameAs(row)) { + await (update( + products, + )..where((r) => r.id.equals(id))).write(product.toCompanion()); + } } }); } + + /// Queues a picture taken on this tablet for upload; a newer picture of the + /// same product replaces one still waiting. + Future queueImageUpload(String productId, String localPath) async { + await into(pendingImageUploads).insertOnConflictUpdate( + PendingImageUploadsCompanion.insert( + productId: productId, + localPath: localPath, + createdAt: DateTime.now(), + ), + ); + } + + Future>> _pendingProductEvents() async { + final types = TabEventType.products.map((type) => type.value).toSet(); + final rows = + await (select(tabEvents)..orderBy([ + (row) => OrderingTerm.asc(row.createdAt), + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + return [ + for (final row in rows) + if (jsonDecode(row.payload) case final Map event + when types.contains(event['type'])) + event, + ]; + } } diff --git a/lib/database/stock_queries.dart b/lib/database/stock_queries.dart index f67c166..179114a 100644 --- a/lib/database/stock_queries.dart +++ b/lib/database/stock_queries.dart @@ -50,10 +50,10 @@ extension StockQueries on AppDatabase { ); } - /// Queues a `stock_set` event, so a stock count or tracking change made on - /// this tablet reaches the server. Call it inside the transaction that - /// writes the stock. - Future queueStockSet(TabEvent event) async { + /// Queues an event that is not about a tab (a stock count, a product + /// change) in the outbox. Call it inside the transaction that makes the + /// change, so the change and its event are stored together. + Future queueEvent(TabEvent event) async { await into(tabEvents).insert( TabEventsCompanion.insert( id: event.id, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 67172e8..1480a1b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,7 +4,6 @@ "barTabs": "Bar Tabs", "products": "Products", "addProduct": "Add product", - "productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.", "noProductsYet": "No products yet.", "addFirstProduct": "Add your first product to start selling.", "manageProducts": "Manage products", @@ -319,5 +318,11 @@ "registeringDevice": "Registering device…", "deviceRegistered": "Device registered!", "openTabsWillSync": "Open tabs will sync to the dashboard.", - "done": "Done" + "done": "Done", + "scanToPay": "Let the customer scan this code to pay {amount}", + "@scanToPay": { + "placeholders": { + "amount": {"type": "String"} + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index da0cecb..7b17d04 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -122,12 +122,6 @@ abstract class AppLocalizations { /// **'Add product'** String get addProduct; - /// No description provided for @productsManagedOnWebsite. - /// - /// In en, this message translates to: - /// **'Products are managed on the website. Here you can change the stock and the picture.'** - String get productsManagedOnWebsite; - /// No description provided for @noProductsYet. /// /// In en, this message translates to: @@ -1885,6 +1879,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Done'** String get done; + + /// No description provided for @scanToPay. + /// + /// In en, this message translates to: + /// **'Let the customer scan this code to pay {amount}'** + String scanToPay(String amount); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b3ebb35..48d35e1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -20,10 +20,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addProduct => 'Add product'; - @override - String get productsManagedOnWebsite => - 'Products are managed on the website. Here you can change the stock and the picture.'; - @override String get noProductsYet => 'No products yet.'; @@ -980,4 +976,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get done => 'Done'; + + @override + String scanToPay(String amount) { + return 'Let the customer scan this code to pay $amount'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 8b5b856..1896988 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -20,10 +20,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get addProduct => 'Product toevoegen'; - @override - String get productsManagedOnWebsite => - 'Producten beheer je op de website. Hier pas je de voorraad en de foto aan.'; - @override String get noProductsYet => 'Nog geen producten.'; @@ -993,4 +989,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get done => 'Klaar'; + + @override + String scanToPay(String amount) { + return 'Laat de klant deze code scannen om $amount te betalen'; + } } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 63ac973..1d8b6b2 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4,7 +4,6 @@ "barTabs": "Poefs", "products": "Producten", "addProduct": "Product toevoegen", - "productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.", "noProductsYet": "Nog geen producten.", "addFirstProduct": "Voeg je eerste product toe om te beginnen.", "manageProducts": "Producten beheren", @@ -318,5 +317,6 @@ "registeringDevice": "Toestel registreren…", "deviceRegistered": "Toestel geregistreerd!", "openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.", - "done": "Klaar" + "done": "Klaar", + "scanToPay": "Laat de klant deze code scannen om {amount} te betalen" } diff --git a/lib/main.dart b/lib/main.dart index 3c7f673..bab4425 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,12 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -14,6 +18,7 @@ import 'services/admin_pin_service.dart'; import 'services/bar_tab_service.dart'; import 'services/kooltap_api_client.dart'; import 'services/pin_lock_service.dart'; +import 'services/product_image_sync.dart'; import 'services/product_service.dart'; import 'services/remote_sync_service.dart'; import 'services/settings_service.dart'; @@ -70,6 +75,16 @@ Future _runApp() async { database: database, barTabService: barTabService, apiClient: apiClient, + imageSync: ProductImageSync( + database: database, + apiClient: apiClient, + imagesDirectory: () async => Directory( + path.join( + (await getApplicationSupportDirectory()).path, + 'product_images', + ), + ), + ), ); final pinLockViewModel = PinLockViewModel( diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart index 05f3a9a..80ef128 100644 --- a/lib/models/remote_command.dart +++ b/lib/models/remote_command.dart @@ -170,6 +170,9 @@ class ServerProduct { /// Deleted on the website: hidden, kept for open tabs and the history. final bool archived; + /// The picture, at `/images/`; null: none. + final String? imageId; + const ServerProduct({ required this.id, required this.name, @@ -177,6 +180,7 @@ class ServerProduct { required this.priceInCents, this.lowStockThreshold, this.archived = false, + this.imageId, }); factory ServerProduct.fromJson(Map json) { @@ -187,6 +191,7 @@ class ServerProduct { priceInCents: json['priceInCents'] as int, lowStockThreshold: json['lowStockThreshold'] as int?, archived: json['archived'] as bool? ?? false, + imageId: json['imageId'] as String?, ); } } diff --git a/lib/models/tab_event.dart b/lib/models/tab_event.dart index b8fd69a..a039740 100644 --- a/lib/models/tab_event.dart +++ b/lib/models/tab_event.dart @@ -13,7 +13,16 @@ enum TabEventType { /// A stock count or tracking change made on the tablet. Not about a tab: /// it has no `tabId`. - stockSet('stock_set'); + stockSet('stock_set'), + + /// A product created, changed or deleted (archived) on the tablet. Not + /// about a tab either. + productCreated('product_created'), + productUpdated('product_updated'), + productArchived('product_archived'); + + /// The product events, which the catalog replays instead of the tabs. + static const products = {productCreated, productUpdated, productArchived}; final String value; @@ -177,6 +186,47 @@ class TabEventFactory { ); } + /// A product created on this tablet: [fields] holds `name`, `category` + /// (null: none), `priceInCents` and `lowStockThreshold`. The id is the + /// tablet's, so the product can be sold before the server knows it. + TabEvent productCreated({ + required String productId, + required Map fields, + }) { + return _productEvent(TabEventType.productCreated, productId, fields); + } + + /// Only the [fields] that changed; `imageId` is a picture uploaded first. + TabEvent productUpdated({ + required String productId, + required Map fields, + }) { + return _productEvent(TabEventType.productUpdated, productId, fields); + } + + TabEvent productArchived({required String productId}) { + return _productEvent(TabEventType.productArchived, productId, null); + } + + TabEvent _productEvent( + TabEventType type, + String productId, + Map? fields, + ) { + final id = _uuid.v4(); + + return TabEvent( + id: id, + payload: { + 'id': id, + 'type': type.value, + 'productId': productId, + 'product': ?fields, + 'occurredAt': _now().toUtc().toIso8601String(), + }, + ); + } + /// The server's payment method names: `cash`, `payconiq`, /// `payment_pending` or `other`. static String paymentMethodValue(PaymentMethod method) { diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 91777db..3a28894 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -892,8 +892,12 @@ class DriftBarTabService implements BarTabService { for (final item in event['items'] as List? ?? []) { await _replaySettledLine(tabId, item as Map); } - // Stock is rebased separately, see StockQueries.rebaseStock + // Stock and products are rebased separately, see StockQueries and + // CatalogQueries case TabEventType.stockSet: + case TabEventType.productCreated: + case TabEventType.productUpdated: + case TabEventType.productArchived: case null: return; } diff --git a/lib/services/default_product_seeder.dart b/lib/services/default_product_seeder.dart index 9f1de22..b6d98ae 100644 --- a/lib/services/default_product_seeder.dart +++ b/lib/services/default_product_seeder.dart @@ -71,7 +71,7 @@ class DefaultProductSeeder { imagePath: Value(imagePath), ), ); - await database.queueStockSet( + await database.queueEvent( events.stockSet(productId: id, quantity: stock), ); } diff --git a/lib/services/kooltap_api_client.dart b/lib/services/kooltap_api_client.dart index 0736d18..03dd337 100644 --- a/lib/services/kooltap_api_client.dart +++ b/lib/services/kooltap_api_client.dart @@ -104,6 +104,43 @@ class KoolTapApiClient { ); } + /// Uploads a picture taken on this tablet; the server crops and stores it + /// and returns its id, which a `product_updated` event then links. + Future uploadImage({ + required String token, + required String deviceId, + required List bytes, + required String filename, + }) async { + final request = + http.MultipartRequest( + 'POST', + Uri.parse('$baseUrl/devices/$deviceId/images'), + ) + ..headers['Authorization'] = 'Bearer $token' + ..files.add( + http.MultipartFile.fromBytes('file', bytes, filename: filename), + ); + // Uploading a photo over a slow connection takes longer than a sync + final response = await http.Response.fromStream( + await _client.send(request).timeout(_timeout * 4), + ); + + final data = _decode(response, authenticated: true) as Map; + return data['imageId'] as String; + } + + /// A product picture (public: pictures never change at their address). + Future> downloadImage(String imageId) async { + final response = await _client + .get(Uri.parse('$baseUrl/images/$imageId')) + .timeout(_timeout * 2); + if (response.statusCode != 200) { + throw ApiException(response.statusCode, 'Image $imageId not available'); + } + return response.bodyBytes; + } + Future _post( String path, Map body, { diff --git a/lib/services/product_image_sync.dart b/lib/services/product_image_sync.dart new file mode 100644 index 0000000..40662e2 --- /dev/null +++ b/lib/services/product_image_sync.dart @@ -0,0 +1,180 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; + +import '../database/app_database.dart'; +import '../database/stock_queries.dart'; +import '../models/tab_event.dart'; +import 'kooltap_api_client.dart'; + +/// Moves product pictures between this tablet and the server. Pictures are +/// kept as local files, so the tiles show them offline. +/// +/// - A picture taken here waits in [PendingImageUploads]; once uploaded, a +/// `product_updated` event links it to its product for every tablet. +/// - A picture set elsewhere (the website, another tablet) is downloaded in +/// the background after a sync, as `.webp`; until then the tile +/// keeps showing the previous picture. +/// - Files no product uses anymore are deleted. +class ProductImageSync { + final AppDatabase database; + final KoolTapApiClient apiClient; + final TabEventFactory eventFactory; + + /// Where the pictures are kept: the folder the product form copies picked + /// pictures into as well. + final Future Function() imagesDirectory; + + /// A file nobody refers to is only deleted after this long: the product + /// form copies a picked picture here before the product is saved. + final Duration unusedFileAge; + + ProductImageSync({ + required this.database, + required this.apiClient, + required this.imagesDirectory, + TabEventFactory? eventFactory, + this.unusedFileAge = const Duration(hours: 1), + }) : eventFactory = eventFactory ?? TabEventFactory(); + + Future? _downloading; + + /// Uploads the waiting pictures, oldest first. Throws at the first upload + /// that fails for a reason that may pass (offline): the rest waits for the + /// next sync. A picture the server can't read is dropped instead of + /// blocking the queue forever. + Future uploadPending({ + required String token, + required String deviceId, + }) async { + final waiting = await (database.select( + database.pendingImageUploads, + )..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get(); + + for (final upload in waiting) { + final file = File(upload.localPath); + if (!await file.exists()) { + await _forget(upload); + continue; + } + + final String imageId; + try { + imageId = await apiClient.uploadImage( + token: token, + deviceId: deviceId, + bytes: await file.readAsBytes(), + filename: path.basename(upload.localPath), + ); + } on ApiException catch (e) { + if (e.statusCode != 400 && e.statusCode != 413) rethrow; + debugPrint('ProductImageSync: picture refused: $e'); + await _forget(upload); + continue; + } + + await database.transaction(() async { + // Replaced by a newer picture while uploading: that one goes next + final current = + await (database.select(database.pendingImageUploads) + ..where((row) => row.productId.equals(upload.productId))) + .getSingleOrNull(); + if (current?.localPath != upload.localPath) return; + + await database.queueEvent( + eventFactory.productUpdated( + productId: upload.productId, + fields: {'imageId': imageId}, + ), + ); + await (database.update(database.products) + ..where((row) => row.id.equals(upload.productId))) + .write(ProductsCompanion(imageId: Value(imageId))); + await _forget(upload); + }); + } + } + + /// Downloads the pictures this tablet doesn't have yet and points their + /// products at them, then deletes files no product uses. A picture that + /// fails (offline) keeps the old one and is tried again after the next + /// sync. Runs once at a time: a call during a run waits for that run. + Future downloadMissing() { + return _downloading ??= _download().whenComplete(() => _downloading = null); + } + + Future _download() async { + final directory = await imagesDirectory(); + await directory.create(recursive: true); + final uploading = { + for (final row + in await database.select(database.pendingImageUploads).get()) + row.productId, + }; + final products = await (database.select( + database.products, + )..where((row) => row.imageId.isNotNull())).get(); + + for (final product in products) { + if (uploading.contains(product.id)) continue; + final imageId = product.imageId!; + final target = path.join(directory.path, '$imageId.webp'); + if (product.imagePath == target && await File(target).exists()) { + continue; + } + + try { + if (!await File(target).exists()) { + final bytes = await apiClient.downloadImage(imageId); + // Written aside first: a tile never shows half a picture + final partial = File('$target.part'); + await partial.writeAsBytes(bytes, flush: true); + await partial.rename(target); + } + } catch (e) { + debugPrint('ProductImageSync: picture $imageId not downloaded: $e'); + continue; + } + + // Unless the product got another picture meanwhile + await (database.update(database.products)..where( + (row) => row.id.equals(product.id) & row.imageId.equals(imageId), + )) + .write(ProductsCompanion(imagePath: Value(target))); + } + + await _deleteUnused(directory); + } + + Future _forget(PendingImageUploadRow upload) async { + await (database.delete(database.pendingImageUploads)..where( + (row) => + row.productId.equals(upload.productId) & + row.localPath.equals(upload.localPath), + )) + .go(); + } + + Future _deleteUnused(Directory directory) async { + final used = { + for (final row in await database.select(database.products).get()) + ?row.imagePath, + for (final row + in await database.select(database.pendingImageUploads).get()) + row.localPath, + }; + final cutoff = DateTime.now().subtract(unusedFileAge); + + await for (final entity in directory.list()) { + if (entity is! File || used.contains(entity.path)) continue; + if ((await entity.lastModified()).isAfter(cutoff)) continue; + try { + await entity.delete(); + } on FileSystemException catch (e) { + debugPrint('ProductImageSync: could not delete ${entity.path}: $e'); + } + } + } +} diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 4f4a5c9..ed674d3 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; +import '../database/catalog_queries.dart'; import '../database/stock_queries.dart'; import '../models/product.dart'; import '../models/tab_event.dart'; @@ -87,6 +88,8 @@ class DriftProductService implements ProductService { return _mapRowToProduct(row); } + /// Queues the new product for the server (which takes this tablet's id), its + /// starting stock as a count, and its picture for upload. @override Future createProduct({ required String name, @@ -114,16 +117,29 @@ class DriftProductService implements ProductService { imagePath: Value(imagePath), ), ); + await database.queueEvent( + eventFactory.productCreated( + productId: id, + fields: { + 'name': name, + 'category': _serverCategory(category), + 'priceInCents': priceInCents, + 'lowStockThreshold': lowStockThreshold, + }, + ), + ); // The server starts a product it doesn't know as untracked if (!infiniteStock) { - await database.queueStockSet( + await database.queueEvent( eventFactory.stockSet(productId: id, quantity: stockQuantity), ); } + if (imagePath != null) await database.queueImageUpload(id, imagePath); }); } - /// A changed stock or tracking is queued for the server as a count. + /// Queues what changed for the server: the changed fields, a changed stock + /// or tracking as a count, and a new picture for upload. @override Future updateProduct(Product product) async { await database.transaction(() async { @@ -147,28 +163,57 @@ class DriftProductService implements ProductService { ); if (current == null) return; + final fields = { + if (current.name != product.name) 'name': product.name, + if (current.category != product.category) + 'category': _serverCategory(product.category), + if (current.priceInCents != product.priceInCents) + 'priceInCents': product.priceInCents, + if (current.lowStockThreshold != product.lowStockThreshold) + 'lowStockThreshold': product.lowStockThreshold, + }; + if (fields.isNotEmpty) { + await database.queueEvent( + eventFactory.productUpdated(productId: product.id, fields: fields), + ); + } + final stockChanged = product.infiniteStock ? !current.infiniteStock : current.infiniteStock || current.stockQuantity != product.stockQuantity; if (stockChanged) { - await database.queueStockSet( + await database.queueEvent( eventFactory.stockSet( productId: product.id, quantity: product.infiniteStock ? null : product.stockQuantity, ), ); } + + final picture = product.imagePath; + if (picture != null && picture != current.imagePath) { + await database.queueImageUpload(product.id, picture); + } }); } - /// Only hidden, since open tabs and history still refer to it. + /// Only hidden, since open tabs and history still refer to it; the server + /// archives it for every tablet. @override Future deleteProduct(String id) async { - final query = database.update(database.products) - ..where((product) => product.id.equals(id)); + await database.transaction(() async { + await (database.update(database.products) + ..where((product) => product.id.equals(id))) + .write(const ProductsCompanion(active: Value(false))); + await database.queueEvent(eventFactory.productArchived(productId: id)); + }); + } - await query.write(const ProductsCompanion(active: Value(false))); + /// The server has no category for an empty one. + static String? _serverCategory(String category) { + final trimmed = category.trim(); + return trimmed.isEmpty ? null : trimmed; } @override diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 449801a..05707f8 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -17,6 +17,7 @@ import '../models/remote_command.dart'; import '../utils/app_config.dart'; import 'bar_tab_service.dart'; import 'kooltap_api_client.dart'; +import 'product_image_sync.dart'; typedef _Credentials = ({String deviceId, String token}); @@ -38,6 +39,10 @@ class RemoteSyncService { final KoolTapApiClient apiClient; final FlutterSecureStorage secureStorage; + /// Uploads pictures taken here before a sync and downloads new ones after; + /// null leaves pictures alone. + final ProductImageSync? imageSync; + /// How long tab changes must be quiet before they are synced. final Duration changeDebounce; @@ -49,6 +54,7 @@ class RemoteSyncService { required this.barTabService, required this.apiClient, FlutterSecureStorage? secureStorage, + this.imageSync, this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs), this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs), }) : secureStorage = secureStorage ?? const FlutterSecureStorage(); @@ -222,6 +228,14 @@ class RemoteSyncService { try { final appliedStock = await _doSync(credentials); _consecutiveFailures = 0; + // In the background: a slow download must not hold up the next sync + if (imageSync case final images?) { + unawaited( + images.downloadMissing().catchError( + (Object e) => debugPrint('RemoteSyncService: pictures: $e'), + ), + ); + } // Show the new stock on the dashboard within seconds. The follow-up // skips the adjustments it already applied, so this doesn't loop. nextDelay = appliedStock ? Duration.zero : _pollingInterval; @@ -278,6 +292,21 @@ class RemoteSyncService { /// Returns whether any stock adjustment was applied, which only happens /// with a server that doesn't own the stock. Future _doSync(_Credentials credentials) async { + // Pictures taken here first, so the events linking them go in this sync. + // One that doesn't upload (offline) waits; the sync itself still goes. + if (imageSync case final images?) { + try { + await images.uploadPending( + token: credentials.token, + deviceId: credentials.deviceId, + ); + } on UnauthorizedException { + rethrow; + } catch (e) { + debugPrint('RemoteSyncService: picture upload failed: $e'); + } + } + final state = await _getSyncState(); // One read, so the snapshot and the stock match the events that are sent final (outbox, openTabs, products, stockResults) = await database diff --git a/lib/views/dialogs/close_tab_dialog.dart b/lib/views/dialogs/close_tab_dialog.dart index af5a113..5f2dbc4 100644 --- a/lib/views/dialogs/close_tab_dialog.dart +++ b/lib/views/dialogs/close_tab_dialog.dart @@ -64,7 +64,12 @@ Future confirmCloseTab(BuildContext context) async { }, ); - if (confirmed != true) return; + if (confirmed != true || !context.mounted) return; + + if (paymentMethod == PaymentMethod.payconiq) { + final paid = await _showPayconiqQr(context, tab.totalInCents.asEuro); + if (paid != true || !context.mounted) return; + } try { await viewModel.closeSelectedTab(paymentMethod: paymentMethod); @@ -76,17 +81,62 @@ Future confirmCloseTab(BuildContext context) async { } } +const _payconiqQrAsset = 'assets/payconiq/qr.png'; + +/// Shows the Payconiq QR code for the customer to scan. Resolves to true +/// once the bartender taps Done; dismissing it leaves the tab open. +Future _showPayconiqQr(BuildContext context, String amount) { + final l10n = AppLocalizations.of(context); + + return showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(l10n.payconiq), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.scanToPay(amount), textAlign: TextAlign.center), + const SizedBox(height: 20), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + _payconiqQrAsset, + width: 280, + height: 280, + fit: BoxFit.contain, + errorBuilder: (context, _, _) => const SizedBox( + width: 280, + height: 280, + child: Icon(Icons.qr_code_2, size: 160), + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.done), + ), + ), + ], + ), + ), + ); + }, + ); +} + class _PaymentPicker extends StatelessWidget { final PaymentMethod selected; final ValueChanged onChanged; const _PaymentPicker({required this.selected, required this.onChanged}); - static const _options = [ - PaymentMethod.cash, - PaymentMethod.payconiq, - PaymentMethod.paymentPending, - ]; + static const _options = [PaymentMethod.cash, PaymentMethod.payconiq]; @override Widget build(BuildContext context) { diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index f37bd25..abd198d 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -20,7 +20,8 @@ class PinEntryView extends StatefulWidget { final Future Function(String)? onVerify; final Future Function(String)? onSet; final AppError? Function()? error; - final bool showBack; + /// Shows a close button that leaves the screen without entering a PIN. + final VoidCallback? onClose; const PinEntryView({ super.key, @@ -30,7 +31,7 @@ class PinEntryView extends StatefulWidget { this.onVerify, this.onSet, this.error, - this.showBack = false, + this.onClose, }); @override @@ -185,18 +186,16 @@ class _PinEntryViewState extends State { padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( children: [ - if (widget.showBack) ...[ - const Spacer(flex: 1), - Row( - children: [ - IconButton( - onPressed: () => {}, - icon: const Icon(Icons.close), - tooltip: 'Back', - ), - ], - ), - ], + Align( + alignment: Alignment.centerLeft, + child: widget.onClose != null + ? IconButton( + onPressed: _isSubmitting ? null : widget.onClose, + icon: const Icon(Icons.close), + tooltip: l10n.close, + ) + : const SizedBox(height: 48), + ), const Spacer(flex: 2), Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary), const SizedBox(height: 16), diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart index f586513..b067427 100644 --- a/lib/views/product_form_view.dart +++ b/lib/views/product_form_view.dart @@ -12,8 +12,6 @@ import '../models/product.dart'; import '../utils/money.dart'; import '../utils/navigation.dart'; import '../viewmodels/product_list_view_model.dart'; -import '../viewmodels/remote_sync_view_model.dart'; -import 'widgets/managed_on_website_notice.dart'; class ProductFormView extends StatefulWidget { final String? productId; @@ -315,9 +313,6 @@ class _ProductFormViewState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final title = widget.isEditing ? l10n.editProduct : l10n.addProduct; - // A paired tablet gets its products from the website: only the stock and - // the picture are changed here - final managedOnWebsite = context.watch().isConfigured; return Scaffold( appBar: AppBar( @@ -327,7 +322,7 @@ class _ProductFormViewState extends State { onPressed: _leaveProductForm, ), actions: [ - if (widget.isEditing && !managedOnWebsite) + if (widget.isEditing) IconButton( onPressed: _delete, icon: const Icon(Icons.delete_outline), @@ -347,15 +342,10 @@ class _ProductFormViewState extends State { ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.all(24), children: [ - if (managedOnWebsite) ...[ - const ManagedOnWebsiteNotice(), - const SizedBox(height: 16), - ], _buildImagePicker(context), const SizedBox(height: 24), TextFormField( controller: _nameController, - enabled: !managedOnWebsite, decoration: InputDecoration( labelText: l10n.productName, border: OutlineInputBorder(), @@ -376,7 +366,6 @@ class _ProductFormViewState extends State { builder: (context, constraints) { return DropdownMenu( width: constraints.maxWidth, - enabled: !managedOnWebsite, initialSelection: _selectedCategory, enableFilter: true, enableSearch: true, @@ -406,7 +395,6 @@ class _ProductFormViewState extends State { const SizedBox(height: 16), TextFormField( controller: _priceController, - enabled: !managedOnWebsite, decoration: InputDecoration( labelText: l10n.price, prefixText: '€ ', @@ -467,7 +455,6 @@ class _ProductFormViewState extends State { const SizedBox(height: 16), TextFormField( controller: _lowStockController, - enabled: !managedOnWebsite, decoration: InputDecoration( labelText: l10n.lowStockThreshold, border: OutlineInputBorder(), diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 6a5fb66..0e61dfc 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -9,8 +9,6 @@ import '../l10n/app_localizations_helpers.dart'; import '../utils/money.dart'; import '../utils/navigation.dart'; import '../viewmodels/product_list_view_model.dart'; -import '../viewmodels/remote_sync_view_model.dart'; -import 'widgets/managed_on_website_notice.dart'; class ProductListView extends StatefulWidget { const ProductListView({super.key}); @@ -33,8 +31,6 @@ class _ProductListViewState extends State { Widget build(BuildContext context) { final viewModel = context.watch(); final l10n = AppLocalizations.of(context); - // A paired tablet gets its products from the website - final managedOnWebsite = context.watch().isConfigured; return Scaffold( appBar: AppBar( @@ -45,13 +41,11 @@ class _ProductListViewState extends State { title: Text(l10n.products), ), resizeToAvoidBottomInset: false, - floatingActionButton: managedOnWebsite - ? null - : FloatingActionButton.extended( - onPressed: () => context.push('/products/new'), - icon: const Icon(Icons.add), - label: Text(l10n.addProduct), - ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/products/new'), + icon: const Icon(Icons.add), + label: Text(l10n.addProduct), + ), body: Builder( builder: (context) { if (viewModel.isLoading) { @@ -62,19 +56,16 @@ class _ProductListViewState extends State { return Center(child: Text(l10n.errorText(viewModel.loadError))); } - if (viewModel.products.isEmpty && !managedOnWebsite) { + if (viewModel.products.isEmpty) { return Center(child: Text(l10n.noProductsYet)); } - // The notice takes the first row - final offset = managedOnWebsite ? 1 : 0; return ListView.separated( padding: const EdgeInsets.all(16), - itemCount: viewModel.products.length + offset, + itemCount: viewModel.products.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { - if (index < offset) return const ManagedOnWebsiteNotice(); - final product = viewModel.products[index - offset]; + final product = viewModel.products[index]; return Card( child: ListTile( diff --git a/lib/views/widgets/managed_on_website_notice.dart b/lib/views/widgets/managed_on_website_notice.dart deleted file mode 100644 index 23e6451..0000000 --- a/lib/views/widgets/managed_on_website_notice.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../l10n/app_localizations.dart'; - -/// Tells that the products of a paired tablet are managed on the website. -class ManagedOnWebsiteNotice extends StatelessWidget { - const ManagedOnWebsiteNotice({super.key}); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - - return Card( - color: colors.secondaryContainer, - child: ListTile( - leading: Icon(Icons.language, color: colors.onSecondaryContainer), - title: Text( - AppLocalizations.of(context).productsManagedOnWebsite, - style: TextStyle(color: colors.onSecondaryContainer), - ), - ), - ); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index d6f9dd6..3fc6717 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.3.4+0 +version: 1.3.8+0 environment: sdk: ^3.12.2 @@ -51,4 +51,5 @@ flutter: assets: - assets/icons/payconic.svg + - assets/payconiq/ - assets/products/ diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart index b7b3c61..4f22964 100644 --- a/test/database_migration_test.dart +++ b/test/database_migration_test.dart @@ -102,4 +102,11 @@ void main() { ); expect((await database.select(database.products).get()), hasLength(1)); }); + + test('gives products no server picture yet and an empty upload queue', () async { + final product = await database.select(database.products).getSingle(); + + expect(product.imageId, isNull); + expect(await database.select(database.pendingImageUploads).get(), isEmpty); + }); } diff --git a/test/e2e/website_catalog_e2e_test.dart b/test/e2e/website_catalog_e2e_test.dart index ef71c94..d7fb710 100644 --- a/test/e2e/website_catalog_e2e_test.dart +++ b/test/e2e/website_catalog_e2e_test.dart @@ -19,8 +19,10 @@ import 'package:kooltap/models/product.dart'; import 'package:kooltap/models/remote_command.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/kooltap_api_client.dart'; +import 'package:kooltap/services/product_image_sync.dart'; import 'package:kooltap/services/product_service.dart'; import 'package:kooltap/services/remote_sync_service.dart'; +import 'package:path/path.dart' as path; import '../helpers/memory_secure_storage.dart'; @@ -74,6 +76,30 @@ class _Website { return call('PATCH', '/venues/$venueId/stock/products/$id', changes); } + /// Uploads a picture for a product, as the products page does. + Future> uploadImage( + String productId, + List png, + ) async { + final request = + http.MultipartRequest( + 'POST', + Uri.parse('$_api/venues/$venueId/stock/products/$productId/image'), + ) + ..headers['Authorization'] = 'Bearer $token' + ..files.add( + http.MultipartFile.fromBytes('file', png, filename: 'photo.png'), + ); + return _decode(await http.Response.fromStream(await request.send())) + as Map; + } + + /// The products page: every product with its stock and picture. + Future> products() async { + final body = await call('GET', '/venues/$venueId/stock'); + return (body as Map)['products'] as List; + } + Future> tabs() async { final body = await call('GET', '/venues/$venueId/tabs'); return (body as Map)['tabs'] as List; @@ -93,9 +119,18 @@ class _Tablet { final DriftBarTabService tabs; final DriftProductService products; final RemoteSyncService sync; + final ProductImageSync images; + final Directory pictures; final List _events = []; - _Tablet._(this.database, this.tabs, this.products, this.sync) { + _Tablet._( + this.database, + this.tabs, + this.products, + this.sync, + this.images, + this.pictures, + ) { sync.events.listen(_events.add); } @@ -103,11 +138,18 @@ class _Tablet { final database = AppDatabase(NativeDatabase.memory()); final tabs = DriftBarTabService(database: database); final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!); + final pictures = await Directory.systemTemp.createTemp('kooltap_e2e_'); + final images = ProductImageSync( + database: database, + apiClient: api, + imagesDirectory: () async => pictures, + ); final sync = RemoteSyncService( database: database, barTabService: tabs, apiClient: api, secureStorage: MemorySecureStorage(), + imageSync: images, ); final credentials = await api.registerDevice( venueId: website.venueId, @@ -124,6 +166,8 @@ class _Tablet { tabs, DriftProductService(database: database), sync, + images, + pictures, ); } @@ -146,9 +190,16 @@ class _Tablet { return (await products.getProducts()).singleWhere((p) => p.name == name); } + /// Syncs, then waits for the pictures it started downloading. + Future syncWithPictures() async { + await syncNow(); + await images.downloadMissing(); + } + Future close() async { sync.dispose(); await database.close(); + await pictures.delete(recursive: true); } } @@ -304,4 +355,105 @@ void main() { skip: _skip, timeout: const Timeout(Duration(minutes: 2)), ); + + test( + 'pictures, and products edited on a tablet', + () async { + final suffix = DateTime.now().millisecondsSinceEpoch; + // A 40x60 red PNG: taller than wide, so fitted whole + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAACgAAAA8CAIAAACb22+3AAAAOElEQVR4nO3NQQkAAAgEsEty/aMYyxgiDPZfpj0RsVgsFovFYrFYLBaLxWKxWCwWi8VisVg8j+IFPRuGCFZP0vAAAAAASUVORK5CYII=', + ); + bool isWebp(List bytes) => + String.fromCharCodes(bytes.sublist(0, 4)) == 'RIFF' && + String.fromCharCodes(bytes.sublist(8, 12)) == 'WEBP'; + + // 1. A picture uploaded on the website reaches both tablets, as WebP + final shotName = 'E2E Shot $suffix'; + final shot = await website.createProduct({ + 'name': shotName, + 'priceInCents': 300, + 'stock': 10, + }); + final uploaded = await website.uploadImage( + shot['productId'] as String, + png, + ); + expect(uploaded['imageId'], matches(RegExp(r'^[0-9a-f]{64}$'))); + for (final tablet in [a, b]) { + await tablet.syncWithPictures(); + final product = await tablet.product(shotName); + expect(product.imagePath, endsWith('${uploaded['imageId']}.webp')); + expect(isWebp(File(product.imagePath!).readAsBytesSync()), isTrue); + } + + // 2. A product made on tablet A, with a photo, reaches the website and + // tablet B with the server's picture + final drinkName = 'E2E Tablet Drink $suffix'; + final photo = File(path.join(a.pictures.path, 'photo.png')) + ..writeAsBytesSync(png); + await a.products.createProduct( + name: drinkName, + category: 'Frisdrank', + stockQuantity: 8, + lowStockThreshold: 2, + priceInCents: 220, + imagePath: photo.path, + ); + // The tile shows the photo at once, before any sync + expect((await a.product(drinkName)).imagePath, photo.path); + await a.syncWithPictures(); + final onWebsite = (await website.products()).singleWhere( + (p) => p['name'] == drinkName, + ); + expect(onWebsite, containsPair('stock', 8)); + expect(onWebsite['imageId'], isNotNull); + await b.syncWithPictures(); + final onB = await b.product(drinkName); + expect(onB.priceInCents, 220); + expect(onB.imagePath, endsWith('${onWebsite['imageId']}.webp')); + // Tablet A now shows the server's cropped copy as well + expect( + (await a.product(drinkName)).imagePath, + endsWith('${onWebsite['imageId']}.webp'), + ); + + // 3. Tablet A renames it offline while the website changes its price: + // both survive + final drinkId = onWebsite['productId'] as String; + final renamed = 'E2E Renamed $suffix'; + await a.products.updateProduct( + (await a.product(drinkName)).copyWith(name: renamed), + ); + await website.changeProduct(drinkId, {'priceInCents': 240}); + await a.syncNow(); + await b.syncNow(); + for (final tablet in [a, b]) { + final product = await tablet.product(renamed); + expect(product.priceInCents, 240); + } + + // 4. Deleted on tablet A: archived for the website and tablet B + await a.products.deleteProduct(drinkId); + await a.syncNow(); + await b.syncNow(); + expect( + (await b.products.getProducts()).where((p) => p.id == drinkId), + isEmpty, + ); + expect( + (await website.products()).singleWhere( + (p) => p['productId'] == drinkId, + )['archivedAt'], + isNotNull, + ); + + // Leave the venue as it was + await website.changeProduct(shot['productId'] as String, { + 'archived': true, + }); + }, + skip: _skip, + timeout: const Timeout(Duration(minutes: 2)), + ); } diff --git a/test/product_image_sync_test.dart b/test/product_image_sync_test.dart new file mode 100644 index 0000000..546a20a --- /dev/null +++ b/test/product_image_sync_test.dart @@ -0,0 +1,273 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/database/catalog_queries.dart'; +import 'package:kooltap/services/kooltap_api_client.dart'; +import 'package:kooltap/services/product_image_sync.dart'; +import 'package:path/path.dart' as path; + +const _picture = [1, 2, 3, 4]; +final _imageId = 'a' * 64; + +void main() { + late AppDatabase database; + late Directory directory; + late ProductImageSync images; + late List requests; + late Future Function(http.Request) respond; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + directory = await Directory.systemTemp.createTemp('kooltap_images_'); + requests = []; + respond = (request) async { + if (request.url.path.endsWith('/images') && request.method == 'POST') { + return http.Response(jsonEncode({'imageId': _imageId}), 201); + } + if (request.url.path.endsWith('/images/$_imageId')) { + return http.Response.bytes(_picture, 200); + } + return http.Response('not found', 404); + }; + images = ProductImageSync( + database: database, + apiClient: KoolTapApiClient( + client: MockClient((request) { + requests.add(request); + return respond(request); + }), + baseUrl: 'https://api.test', + ), + imagesDirectory: () async => directory, + ); + + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'cola', + name: 'Cola', + category: 'Frisdrank', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 250, + ), + ); + }); + + tearDown(() async { + await database.close(); + await directory.delete(recursive: true); + }); + + Future cola() => (database.select( + database.products, + )..where((row) => row.id.equals('cola'))).getSingle(); + + Future>> outbox() async => [ + for (final row in await database.select(database.tabEvents).get()) + jsonDecode(row.payload) as Map, + ]; + + Future takePicture(String name) async { + final file = File(path.join(directory.path, name)); + await file.writeAsBytes([9, 9, 9]); + return file.path; + } + + group('uploadPending', () { + test( + 'uploads a picture taken here and links it for every tablet', + () async { + final picture = await takePicture('taken.jpg'); + await database.queueImageUpload('cola', picture); + + await images.uploadPending(token: 'token', deviceId: 'd1'); + + expect(requests.single.url.path, '/devices/d1/images'); + expect(requests.single.headers['Authorization'], 'Bearer token'); + expect( + (await outbox()).single, + containsPair('type', 'product_updated'), + ); + expect((await outbox()).single['product'], {'imageId': _imageId}); + expect((await cola()).imageId, _imageId); + // The tile keeps the picture it shows until the server's copy is here + expect( + await database.select(database.pendingImageUploads).get(), + isEmpty, + ); + }, + ); + + test('offline, the picture waits for the next sync', () async { + respond = (_) async => throw http.ClientException('Failed host lookup'); + await database.queueImageUpload('cola', await takePicture('taken.jpg')); + + await expectLater( + images.uploadPending(token: 'token', deviceId: 'd1'), + throwsA(isA()), + ); + + expect( + await database.select(database.pendingImageUploads).get(), + hasLength(1), + ); + expect(await outbox(), isEmpty); + }); + + test( + 'a picture the server cannot read is dropped instead of blocking the queue', + () async { + respond = (_) async => http.Response( + jsonEncode({ + 'error': {'code': 'invalid_image', 'message': 'Not an image'}, + }), + 400, + ); + await database.queueImageUpload( + 'cola', + await takePicture('broken.jpg'), + ); + + await images.uploadPending(token: 'token', deviceId: 'd1'); + + expect( + await database.select(database.pendingImageUploads).get(), + isEmpty, + ); + expect(await outbox(), isEmpty); + }, + ); + + test('a picture whose file is gone is forgotten', () async { + await database.queueImageUpload( + 'cola', + path.join(directory.path, 'gone.jpg'), + ); + + await images.uploadPending(token: 'token', deviceId: 'd1'); + + expect(requests, isEmpty); + expect( + await database.select(database.pendingImageUploads).get(), + isEmpty, + ); + }); + + test( + 'a picture replaced while uploading is not linked; the newer one goes next', + () async { + final first = await takePicture('first.jpg'); + final second = await takePicture('second.jpg'); + await database.queueImageUpload('cola', first); + respond = (request) async { + // Another picture is taken while this one uploads + await database.queueImageUpload('cola', second); + return http.Response(jsonEncode({'imageId': _imageId}), 201); + }; + + await images.uploadPending(token: 'token', deviceId: 'd1'); + + expect(await outbox(), isEmpty); + expect( + (await database.select(database.pendingImageUploads).getSingle()) + .localPath, + second, + ); + }, + ); + }); + + group('downloadMissing', () { + Future setImage(String? imageId, {String? imagePath}) async { + await (database.update( + database.products, + )..where((row) => row.id.equals('cola'))).write( + ProductsCompanion(imageId: Value(imageId), imagePath: Value(imagePath)), + ); + } + + test('downloads a new picture and shows it', () async { + await setImage(_imageId); + + await images.downloadMissing(); + + final target = path.join(directory.path, '$_imageId.webp'); + expect((await cola()).imagePath, target); + expect(await File(target).readAsBytes(), _picture); + expect(File('$target.part').existsSync(), isFalse); + }); + + test('downloads a picture once', () async { + await setImage(_imageId); + + await images.downloadMissing(); + await images.downloadMissing(); + + expect(requests, hasLength(1)); + }); + + test( + 'offline, keeps showing the old picture and tries again later', + () async { + final old = await takePicture('old.webp'); + await setImage(_imageId, imagePath: old); + respond = (_) async => throw http.ClientException('Failed host lookup'); + + await images.downloadMissing(); + expect((await cola()).imagePath, old); + + respond = (_) async => http.Response.bytes(_picture, 200); + await images.downloadMissing(); + expect( + (await cola()).imagePath, + path.join(directory.path, '$_imageId.webp'), + ); + }, + ); + + test( + 'leaves a product alone while its own picture waits for upload', + () async { + final taken = await takePicture('taken.jpg'); + await setImage(_imageId, imagePath: taken); + await database.queueImageUpload('cola', taken); + + await images.downloadMissing(); + + expect(requests, isEmpty); + expect((await cola()).imagePath, taken); + }, + ); + + test( + 'deletes old pictures nothing uses, keeps ones in use and just picked ones', + () async { + final unused = File(path.join(directory.path, 'unused.webp')); + await unused.writeAsBytes([1]); + await unused.setLastModified( + DateTime.now().subtract(const Duration(hours: 2)), + ); + final justPicked = await takePicture('just_picked.jpg'); + final shown = await takePicture('shown.webp'); + await File( + shown, + ).setLastModified(DateTime.now().subtract(const Duration(hours: 2))); + await setImage(null, imagePath: shown); + + await images.downloadMissing(); + + expect(unused.existsSync(), isFalse); + expect(File(justPicked).existsSync(), isTrue); + expect(File(shown).existsSync(), isTrue); + }, + ); + }); +} diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart index 06f93f8..e4569c3 100644 --- a/test/remote_sync_service_test.dart +++ b/test/remote_sync_service_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; @@ -11,8 +12,10 @@ import 'package:kooltap/models/product.dart'; import 'package:kooltap/models/remote_command.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/kooltap_api_client.dart'; +import 'package:kooltap/services/product_image_sync.dart'; import 'package:kooltap/services/product_service.dart'; import 'package:kooltap/services/remote_sync_service.dart'; +import 'package:path/path.dart' as path; import 'helpers/memory_secure_storage.dart'; @@ -36,15 +39,27 @@ class _FakeServer { /// While set, `/sync` responses wait for it to complete. Completer? holdSync; - late final client = MockClient((request) async { - final body = jsonDecode(request.body) as Map; + /// Pictures uploaded by terminals, by the id the server gave them. + final Map uploadedImages = {}; - if (request.url.path.endsWith('/sync')) { + late final client = MockClient((request) async { + final path = request.url.path; + if (path.endsWith('/images') && request.method == 'POST') { + final imageId = 'img${uploadedImages.length + 1}'.padRight(64, '0'); + uploadedImages[imageId] = request.bodyBytes.length; + return http.Response(jsonEncode({'imageId': imageId}), 201); + } + if (path.contains('/images/')) { + return http.Response.bytes([1, 2, 3], 200); + } + + final body = jsonDecode(request.body) as Map; + if (path.endsWith('/sync')) { syncRequests.add(body); await holdSync?.future; return syncResponse; } - if (request.url.path.endsWith('/ack')) { + if (path.endsWith('/ack')) { acks.add(body); return http.Response('', 200); } @@ -1460,7 +1475,8 @@ void main() { .having((p) => p.priceInCents, 'price', 450) .having((p) => p.category, 'category', '') .having((p) => p.lowStockThreshold, 'threshold', 6) - .having((p) => p.imagePath, 'image', '/beer.png') + // The website has no picture: the old local one is dropped + .having((p) => p.imagePath, 'image', isNull) .having((p) => p.active, 'active', isTrue), ); // New, and its stock arrives in the same sync @@ -1764,6 +1780,224 @@ void main() { }); }); + group('products edited on this tablet', () { + late DriftProductService products; + + setUp(() { + products = DriftProductService(database: database); + }); + + Map beer({ + String name = 'Beer', + int price = 400, + bool archived = false, + String? imageId, + }) { + return { + 'id': 'prod-1', + 'name': name, + 'category': 'Drinks', + 'priceInCents': price, + 'lowStockThreshold': 10, + 'archived': archived, + 'imageId': imageId, + }; + } + + Future row(String id) => (database.select( + database.products, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + + test('an offline rename stays until the server has it', () async { + await products.updateProduct(_beer.copyWith(name: 'Pils')); + // The server hasn't seen the rename yet: its catalog still says Beer + server.respondWith({ + 'catalog': [beer()], + }); + + await service.sync(); + expect((await row('prod-1'))!.name, 'Pils'); + + // Once acknowledged, the server's catalog has it + server.respondWith({ + 'acknowledgedEventIds': [ + for (final event in server.syncRequests.last['events'] as List) + event['id'], + ], + 'catalog': [beer(name: 'Pils')], + }); + await service.sync(); + expect((await row('prod-1'))!.name, 'Pils'); + }); + + test('a product created offline stays on the bar screen', () async { + await products.createProduct( + name: 'Ice Coffee', + category: 'Koffie', + stockQuantity: 12, + lowStockThreshold: 3, + priceInCents: 350, + imagePath: null, + ); + server.respondWith({ + 'catalog': [beer()], + }); + + await service.sync(); + + expect( + (await products.getProducts()).map((p) => p.name), + containsAll(['Beer', 'Ice Coffee']), + ); + }); + + test('a product deleted offline stays hidden', () async { + await products.deleteProduct('prod-1'); + server.respondWith({ + 'catalog': [beer()], + }); + + await service.sync(); + + expect(await products.getProducts(), isEmpty); + }); + + test( + 'a website change of another field is kept next to the offline one', + () async { + await products.updateProduct(_beer.copyWith(name: 'Pils')); + server.respondWith({ + 'catalog': [beer(price: 450)], + }); + + await service.sync(); + + final pils = (await row('prod-1'))!; + expect(pils.name, 'Pils'); + expect(pils.priceInCents, 450); + }, + ); + + test('a changed picture keeps the old one until downloaded', () async { + await (database.update( + database.products, + )..where((row) => row.id.equals('prod-1'))).write( + ProductsCompanion( + imageId: Value('o' * 64), + imagePath: const Value('/pictures/old.webp'), + ), + ); + server.respondWith({ + 'catalog': [beer(imageId: 'n' * 64)], + }); + + await service.sync(); + + final row1 = (await row('prod-1'))!; + expect(row1.imageId, 'n' * 64); + expect(row1.imagePath, '/pictures/old.webp'); + }); + + test( + 'a picture waiting for upload is kept, also when the website has none', + () async { + await products.updateProduct( + _beer.copyWith(imagePath: '/pictures/taken.jpg'), + ); + server.respondWith({ + 'catalog': [beer()], + }); + + await service.sync(); + + expect((await row('prod-1'))!.imagePath, '/pictures/taken.jpg'); + }, + ); + }); + + group('pictures through the sync', () { + late Directory directory; + late RemoteSyncService withPictures; + + setUp(() async { + directory = await Directory.systemTemp.createTemp('kooltap_sync_images_'); + final api = KoolTapApiClient(client: server.client, baseUrl: _baseUrl); + withPictures = RemoteSyncService( + database: database, + barTabService: barTabService, + apiClient: api, + secureStorage: storage, + imageSync: ProductImageSync( + database: database, + apiClient: api, + imagesDirectory: () async => directory, + ), + ); + }); + + tearDown(() async { + withPictures.dispose(); + await directory.delete(recursive: true); + }); + + test( + 'a picture taken here is uploaded and linked in the same sync', + () async { + final taken = File(path.join(directory.path, 'taken.jpg')); + await taken.writeAsBytes([9, 9, 9]); + await DriftProductService( + database: database, + ).updateProduct(_beer.copyWith(imagePath: taken.path)); + + await withPictures.sync(); + + final imageId = server.uploadedImages.keys.single; + expect( + server.syncRequests.single['events'], + contains( + allOf( + containsPair('type', 'product_updated'), + containsPair('product', {'imageId': imageId}), + ), + ), + ); + }, + ); + + test("the server's picture is downloaded after the sync", () async { + final imageId = 'f' * 64; + server.respondWith({ + 'catalog': [ + { + 'id': 'prod-1', + 'name': 'Beer', + 'category': 'Drinks', + 'priceInCents': 400, + 'lowStockThreshold': 10, + 'archived': false, + 'imageId': imageId, + }, + ], + }); + + await withPictures.sync(); + // The download runs in the background + for (var i = 0; i < 50; i++) { + final row = await (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + if (row.imagePath != null) break; + await Future.delayed(const Duration(milliseconds: 20)); + } + + final beer = await (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + expect(beer.imagePath, path.join(directory.path, '$imageId.webp')); + expect(File(beer.imagePath!).readAsBytesSync(), [1, 2, 3]); + }); + }); + group('a part of the response that fails', () { Map brokenTab() { // Two lines claiming the same unit: the database refuses the second diff --git a/test/tab_events_test.dart b/test/tab_events_test.dart index 2dc85ed..c77c3f6 100644 --- a/test/tab_events_test.dart +++ b/test/tab_events_test.dart @@ -433,6 +433,159 @@ void main() { }); }); + group('product events', () { + late DriftProductService products; + + setUp(() { + products = DriftProductService( + database: database, + eventFactory: TabEventFactory( + now: () => DateTime.utc(2026, 10, 24, 15, 3, 40), + ), + ); + }); + + Future>> productEvents() async => [ + for (final event in await outbox()) + if ((event['type'] as String).startsWith('product_')) event, + ]; + + Future> uploads() => + database.select(database.pendingImageUploads).get(); + + test('a new product goes out with its fields, then its stock', () async { + await products.createProduct( + name: 'Ice Coffee', + category: 'Koffie', + stockQuantity: 12, + lowStockThreshold: 3, + priceInCents: 350, + imagePath: null, + ); + + final id = (await products.getProducts()) + .firstWhere((p) => p.name == 'Ice Coffee') + .id; + final events = await outbox(); + expect(events.map((e) => e['type']), ['product_created', 'stock_set']); + expect(events.first, { + 'id': events.first['id'], + 'type': 'product_created', + 'productId': id, + 'product': { + 'name': 'Ice Coffee', + 'category': 'Koffie', + 'priceInCents': 350, + 'lowStockThreshold': 3, + }, + 'occurredAt': '2026-10-24T15:03:40.000Z', + }); + expect(events.first.containsKey('tabId'), isFalse); + }); + + test( + 'an untracked new product has no count, and no category is null', + () async { + await products.createProduct( + name: 'Tap water', + category: ' ', + stockQuantity: 0, + lowStockThreshold: 0, + infiniteStock: true, + priceInCents: 0, + imagePath: null, + ); + + final events = await outbox(); + expect(events.map((e) => e['type']), ['product_created']); + expect(events.single['product'], containsPair('category', null)); + }, + ); + + test('a change sends only the fields that changed', () async { + final cola = (await products.getProductById('prod-1'))!; + + await products.updateProduct( + cola.copyWith(name: 'Coca-Cola', priceInCents: 270), + ); + + expect( + (await productEvents()).single, + containsPair('product', {'name': 'Coca-Cola', 'priceInCents': 270}), + ); + }); + + test('saving without changes sends nothing', () async { + final cola = (await products.getProductById('prod-1'))!; + + await products.updateProduct(cola); + + expect(await outbox(), isEmpty); + }); + + test('deleting archives it for every tablet', () async { + await products.deleteProduct('prod-1'); + + expect( + (await productEvents()).single, + containsPair('type', 'product_archived'), + ); + expect(await products.getProducts(), isEmpty); + }); + + test( + 'a picked picture waits for upload, a newer one replaces it', + () async { + final cola = (await products.getProductById('prod-1'))!; + + await products.updateProduct( + cola.copyWith(imagePath: '/pictures/a.jpg'), + ); + await products.updateProduct( + cola.copyWith(imagePath: '/pictures/b.jpg'), + ); + // Saving again with the same picture uploads nothing new + await products.updateProduct( + cola.copyWith(imagePath: '/pictures/b.jpg', name: 'Coke'), + ); + + final waiting = await uploads(); + expect(waiting.single.productId, 'prod-1'); + expect(waiting.single.localPath, '/pictures/b.jpg'); + // The picture itself is linked once uploaded, not now + expect( + (await productEvents()).where( + (e) => (e['product'] as Map?)?.containsKey('imageId') ?? false, + ), + isEmpty, + ); + }, + ); + + test('a new product with a picture queues it for upload', () async { + await products.createProduct( + name: 'Ice Coffee', + category: 'Koffie', + stockQuantity: 12, + lowStockThreshold: 3, + priceInCents: 350, + imagePath: '/pictures/ice.jpg', + ); + + expect((await uploads()).single.localPath, '/pictures/ice.jpg'); + }); + + test('are skipped when replaying the outbox onto server tabs', () async { + final tab = await openTab('Jan'); + await products.deleteProduct('prod-1'); + + await service.applyServerState([]); + + expect(await service.getTabById(tab.id), isNull); + expect(await productEvents(), hasLength(1)); + }); + }); + group('stock_set events', () { late DriftProductService products; @@ -481,27 +634,36 @@ void main() { final water = (await products.getProducts()).firstWhere( (product) => product.name == 'Water', ); - final events = await outbox(); - expect(events, hasLength(1)); - expect(events.single, containsPair('type', 'stock_set')); - expect(events.single, containsPair('productId', water.id)); - expect(events.single, containsPair('quantity', 24)); + final counts = [ + for (final event in await outbox()) + if (event['type'] == 'stock_set') event, + ]; + expect(counts, hasLength(1)); + expect(counts.single, containsPair('productId', water.id)); + expect(counts.single, containsPair('quantity', 24)); }); test('a stock or tracking change queues a count', () async { final cola = (await products.getProductById('prod-1'))!; - await products.updateProduct(cola.copyWith(name: 'Coca-Cola')); - expect(await outbox(), isEmpty); - - await products.updateProduct(cola.copyWith(stockQuantity: 40)); - await products.updateProduct( - cola.copyWith(stockQuantity: 40, infiniteStock: true), + final renamed = cola.copyWith(name: 'Coca-Cola'); + await products.updateProduct(renamed); + expect( + (await outbox()).where((event) => event['type'] == 'stock_set'), + isEmpty, ); - await products.updateProduct(cola.copyWith(stockQuantity: 40)); + + await products.updateProduct(renamed.copyWith(stockQuantity: 40)); + await products.updateProduct( + renamed.copyWith(stockQuantity: 40, infiniteStock: true), + ); + await products.updateProduct(renamed.copyWith(stockQuantity: 40)); expect( - [for (final event in await outbox()) event['quantity']], + [ + for (final event in await outbox()) + if (event['type'] == 'stock_set') event['quantity'], + ], [40, null, 40], ); });