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
+5 -1
View File
@@ -892,8 +892,12 @@ class DriftBarTabService implements BarTabService {
for (final item in event['items'] as List<dynamic>? ?? []) {
await _replaySettledLine(tabId, item as Map<String, dynamic>);
}
// 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;
}
+1 -1
View File
@@ -71,7 +71,7 @@ class DefaultProductSeeder {
imagePath: Value(imagePath),
),
);
await database.queueStockSet(
await database.queueEvent(
events.stockSet(productId: id, quantity: stock),
);
}
+37
View File
@@ -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<String> uploadImage({
required String token,
required String deviceId,
required List<int> 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<String, dynamic>;
return data['imageId'] as String;
}
/// A product picture (public: pictures never change at their address).
Future<List<int>> 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<Object?> _post(
String path,
Map<String, dynamic> body, {
+180
View File
@@ -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 `<imageId>.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<Directory> 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<void>? _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<void> 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<void> downloadMissing() {
return _downloading ??= _download().whenComplete(() => _downloading = null);
}
Future<void> _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<void> _forget(PendingImageUploadRow upload) async {
await (database.delete(database.pendingImageUploads)..where(
(row) =>
row.productId.equals(upload.productId) &
row.localPath.equals(upload.localPath),
))
.go();
}
Future<void> _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');
}
}
}
}
+52 -7
View File
@@ -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<void> 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<void> updateProduct(Product product) async {
await database.transaction(() async {
@@ -147,28 +163,57 @@ class DriftProductService implements ProductService {
);
if (current == null) return;
final fields = <String, Object?>{
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<void> 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
+29
View File
@@ -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<bool> _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