feat: add remote products and qr code for payconic

This commit is contained in:
2026-10-22 00:10:26 +02:00
parent b15d94a805
commit 71af6f9ffe
31 changed files with 2053 additions and 179 deletions
+153 -1
View File
@@ -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<Map<String, dynamic>> uploadImage(
String productId,
List<int> 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<String, dynamic>;
}
/// The products page: every product with its stock and picture.
Future<List<dynamic>> products() async {
final body = await call('GET', '/venues/$venueId/stock');
return (body as Map<String, dynamic>)['products'] as List<dynamic>;
}
Future<List<dynamic>> tabs() async {
final body = await call('GET', '/venues/$venueId/tabs');
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
@@ -93,9 +119,18 @@ class _Tablet {
final DriftBarTabService tabs;
final DriftProductService products;
final RemoteSyncService sync;
final ProductImageSync images;
final Directory pictures;
final List<RemoteSyncEvent> _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<void> syncWithPictures() async {
await syncNow();
await images.downloadMissing();
}
Future<void> 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<int> 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)),
);
}