Files
kooltab/lib/database/catalog_queries.dart

185 lines
6.4 KiB
Dart

import 'dart:convert';
import 'package:drift/drift.dart';
import '../models/remote_command.dart';
import '../models/tab_event.dart';
import 'app_database.dart';
/// 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], 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).
///
/// 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 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) {
listed.add(product.id);
final local = desired[product.id];
final next = _Product.fromServer(product);
if (local == null) {
desired[product.id] = next;
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;
}
desired[product.id] = next;
}
for (final entry in desired.entries) {
if (!listed.contains(entry.key)) entry.value.active = 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,
];
}
}