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