185 lines
6.4 KiB
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,
|
|
];
|
|
}
|
|
}
|