feat: add remote products and qr code for payconic
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class DefaultProductSeeder {
|
||||
imagePath: Value(imagePath),
|
||||
),
|
||||
);
|
||||
await database.queueStockSet(
|
||||
await database.queueEvent(
|
||||
events.stockSet(productId: id, quantity: stock),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user