feat: add remote products and qr code for payconic
This commit is contained in:
@@ -1,73 +1,184 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../models/remote_command.dart';
|
||||
import '../models/tab_event.dart';
|
||||
import 'app_database.dart';
|
||||
|
||||
/// The product catalog as the website manages it.
|
||||
/// A product as the catalog and this tablet's unsent changes make it.
|
||||
class _Product {
|
||||
String name;
|
||||
String category;
|
||||
int priceInCents;
|
||||
int lowStockThreshold;
|
||||
bool active;
|
||||
String? imageId;
|
||||
String? imagePath;
|
||||
|
||||
_Product(ProductRow row)
|
||||
: name = row.name,
|
||||
category = row.category,
|
||||
priceInCents = row.priceInCents,
|
||||
lowStockThreshold = row.lowStockThreshold,
|
||||
active = row.active,
|
||||
imageId = row.imageId,
|
||||
imagePath = row.imagePath;
|
||||
|
||||
_Product.fromServer(ServerProduct product)
|
||||
: name = product.name,
|
||||
category = product.category ?? '',
|
||||
priceInCents = product.priceInCents,
|
||||
lowStockThreshold = product.lowStockThreshold ?? 0,
|
||||
active = !product.archived,
|
||||
imageId = product.imageId,
|
||||
imagePath = null;
|
||||
|
||||
bool sameAs(ProductRow row) =>
|
||||
name == row.name &&
|
||||
category == row.category &&
|
||||
priceInCents == row.priceInCents &&
|
||||
lowStockThreshold == row.lowStockThreshold &&
|
||||
active == row.active &&
|
||||
imageId == row.imageId &&
|
||||
imagePath == row.imagePath;
|
||||
|
||||
ProductsCompanion toCompanion() => ProductsCompanion(
|
||||
name: Value(name),
|
||||
category: Value(category),
|
||||
priceInCents: Value(priceInCents),
|
||||
lowStockThreshold: Value(lowStockThreshold),
|
||||
active: Value(active),
|
||||
imageId: Value(imageId),
|
||||
imagePath: Value(imagePath),
|
||||
);
|
||||
|
||||
/// Applies the fields of a `product_created` / `product_updated` event.
|
||||
void apply(Map<String, dynamic> fields) {
|
||||
if (fields['name'] case final String value) name = value;
|
||||
if (fields.containsKey('category')) {
|
||||
category = fields['category'] as String? ?? '';
|
||||
}
|
||||
if (fields['priceInCents'] case final int value) priceInCents = value;
|
||||
if (fields.containsKey('lowStockThreshold')) {
|
||||
lowStockThreshold = fields['lowStockThreshold'] as int? ?? 0;
|
||||
}
|
||||
if (fields.containsKey('imageId')) imageId = fields['imageId'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The product catalog: the website's, with this tablet's changes on top.
|
||||
extension CatalogQueries on AppDatabase {
|
||||
/// Makes the local products match [catalog]: new products are added,
|
||||
/// changed ones updated, archived ones and products the server doesn't
|
||||
/// know (such as the default products of a new tablet) are hidden. Hidden,
|
||||
/// not deleted: open tabs and the history refer to them.
|
||||
/// Makes the local products match [catalog], then replays this tablet's
|
||||
/// product changes the server hasn't applied yet on top, so an offline
|
||||
/// edit stays visible until the server has it (the same idea as the tabs).
|
||||
///
|
||||
/// The stock is not touched here: the stock rebase that follows sets it, a
|
||||
/// new product starts as untracked until then. Local images are kept.
|
||||
/// New products are added; archived ones, and products the server doesn't
|
||||
/// know and this tablet didn't create (such as the default products of a
|
||||
/// new tablet), are hidden: open tabs and the history refer to them.
|
||||
///
|
||||
/// Pictures: a changed one keeps showing the old file until
|
||||
/// [ProductImageSync] downloaded the new one; a picture waiting to be
|
||||
/// uploaded from this tablet is left alone. The stock is not touched here:
|
||||
/// the stock rebase that follows sets it.
|
||||
Future<void> applyServerCatalog(List<ServerProduct> catalog) {
|
||||
return transaction(() async {
|
||||
final local = {
|
||||
final rows = {
|
||||
for (final row in await select(products).get()) row.id: row,
|
||||
};
|
||||
final uploading = {
|
||||
for (final row in await select(pendingImageUploads).get())
|
||||
row.productId,
|
||||
};
|
||||
final desired = {for (final row in rows.values) row.id: _Product(row)};
|
||||
|
||||
final listed = <String>{};
|
||||
for (final product in catalog) {
|
||||
final category = product.category ?? '';
|
||||
final lowStockThreshold = product.lowStockThreshold ?? 0;
|
||||
final existing = local.remove(product.id);
|
||||
|
||||
if (existing == null) {
|
||||
await into(products).insert(
|
||||
ProductsCompanion.insert(
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
category: category,
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: const Value(true),
|
||||
priceInCents: product.priceInCents,
|
||||
active: Value(!product.archived),
|
||||
),
|
||||
);
|
||||
listed.add(product.id);
|
||||
final local = desired[product.id];
|
||||
final next = _Product.fromServer(product);
|
||||
if (local == null) {
|
||||
desired[product.id] = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.name == product.name &&
|
||||
existing.category == category &&
|
||||
existing.priceInCents == product.priceInCents &&
|
||||
existing.lowStockThreshold == lowStockThreshold &&
|
||||
existing.active == !product.archived) {
|
||||
continue;
|
||||
if (uploading.contains(product.id)) {
|
||||
next
|
||||
..imageId = local.imageId
|
||||
..imagePath = local.imagePath;
|
||||
} else if (next.imageId != null && next.imageId == local.imageId) {
|
||||
next.imagePath = local.imagePath;
|
||||
} else if (next.imageId != null) {
|
||||
// Until the new picture is downloaded, the old one is better than none
|
||||
next.imagePath = local.imagePath;
|
||||
}
|
||||
await (update(
|
||||
products,
|
||||
)..where((row) => row.id.equals(product.id))).write(
|
||||
ProductsCompanion(
|
||||
name: Value(product.name),
|
||||
category: Value(category),
|
||||
priceInCents: Value(product.priceInCents),
|
||||
lowStockThreshold: Value(lowStockThreshold),
|
||||
active: Value(!product.archived),
|
||||
),
|
||||
);
|
||||
desired[product.id] = next;
|
||||
}
|
||||
for (final entry in desired.entries) {
|
||||
if (!listed.contains(entry.key)) entry.value.active = false;
|
||||
}
|
||||
|
||||
final unknown = [
|
||||
for (final row in local.values)
|
||||
if (row.active) row.id,
|
||||
];
|
||||
if (unknown.isNotEmpty) {
|
||||
await (update(products)..where((row) => row.id.isIn(unknown))).write(
|
||||
const ProductsCompanion(active: Value(false)),
|
||||
);
|
||||
for (final event in await _pendingProductEvents()) {
|
||||
final productId = event['productId'] as String?;
|
||||
final local = productId == null ? null : desired[productId];
|
||||
if (local == null) continue;
|
||||
switch (TabEventType.fromValue(event['type'] as String? ?? '')) {
|
||||
case TabEventType.productCreated || TabEventType.productUpdated:
|
||||
local.apply(event['product'] as Map<String, dynamic>? ?? {});
|
||||
if (event['type'] == TabEventType.productCreated.value) {
|
||||
local.active = true;
|
||||
}
|
||||
case TabEventType.productArchived:
|
||||
local.active = false;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (final MapEntry(key: id, value: product) in desired.entries) {
|
||||
final row = rows[id];
|
||||
if (row == null) {
|
||||
await into(products).insert(
|
||||
product.toCompanion().copyWith(
|
||||
id: Value(id),
|
||||
// Untracked until the stock rebase sets its stock
|
||||
stockQuantity: const Value(0),
|
||||
infiniteStock: const Value(true),
|
||||
),
|
||||
);
|
||||
} else if (!product.sameAs(row)) {
|
||||
await (update(
|
||||
products,
|
||||
)..where((r) => r.id.equals(id))).write(product.toCompanion());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Queues a picture taken on this tablet for upload; a newer picture of the
|
||||
/// same product replaces one still waiting.
|
||||
Future<void> queueImageUpload(String productId, String localPath) async {
|
||||
await into(pendingImageUploads).insertOnConflictUpdate(
|
||||
PendingImageUploadsCompanion.insert(
|
||||
productId: productId,
|
||||
localPath: localPath,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _pendingProductEvents() async {
|
||||
final types = TabEventType.products.map((type) => type.value).toSet();
|
||||
final rows =
|
||||
await (select(tabEvents)..orderBy([
|
||||
(row) => OrderingTerm.asc(row.createdAt),
|
||||
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
|
||||
]))
|
||||
.get();
|
||||
return [
|
||||
for (final row in rows)
|
||||
if (jsonDecode(row.payload) case final Map<String, dynamic> event
|
||||
when types.contains(event['type']))
|
||||
event,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user