feat: add remote products and qr code for payconic
This commit is contained in:
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user