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 `.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 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? _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 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 downloadMissing() { return _downloading ??= _download().whenComplete(() => _downloading = null); } Future _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 _forget(PendingImageUploadRow upload) async { await (database.delete(database.pendingImageUploads)..where( (row) => row.productId.equals(upload.productId) & row.localPath.equals(upload.localPath), )) .go(); } Future _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'); } } } }