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
+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');
}
}
}
}