181 lines
6.3 KiB
Dart
181 lines
6.3 KiB
Dart
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');
|
|
}
|
|
}
|
|
}
|
|
}
|