feat: add alot
This commit is contained in:
+332
-393
@@ -1,7 +1,10 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/closed_tab.dart';
|
||||
import '../models/closed_tab_item.dart';
|
||||
@@ -9,9 +12,36 @@ import '../models/payment_method.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/tab_item.dart';
|
||||
import '../models/tab_item_purchase.dart';
|
||||
import 'product_service.dart';
|
||||
|
||||
class TabNotFoundException implements Exception {
|
||||
final String tabId;
|
||||
|
||||
const TabNotFoundException(this.tabId);
|
||||
|
||||
@override
|
||||
String toString() => 'Tab not found: $tabId';
|
||||
}
|
||||
|
||||
class TabVersionConflictException implements Exception {
|
||||
final int expectedVersion;
|
||||
final int actualVersion;
|
||||
|
||||
const TabVersionConflictException({
|
||||
required this.expectedVersion,
|
||||
required this.actualVersion,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Tab is at version $actualVersion, expected $expectedVersion';
|
||||
}
|
||||
|
||||
abstract class BarTabService {
|
||||
/// Open tabs with their visible items, re-emitted on every change.
|
||||
Stream<List<BarTab>> watchOpenTabs();
|
||||
|
||||
/// With [includeRemoved], items whose purchases were all undone are kept
|
||||
/// so history can show them.
|
||||
Future<List<BarTab>> getOpenTabs({bool includeRemoved = false});
|
||||
|
||||
Future<BarTab?> getTabById(String id);
|
||||
@@ -21,13 +51,15 @@ abstract class BarTabService {
|
||||
Future<void> renameTab({required String tabId, required String customerName});
|
||||
|
||||
/// Deletes an open tab and returns its items to inventory.
|
||||
Future<List<TabItem>> deleteTab(String tabId);
|
||||
Future<void> deleteTab(String tabId);
|
||||
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
});
|
||||
|
||||
/// Returns the delta that was actually applied, which is smaller than
|
||||
/// [delta] when it would push the quantity below zero.
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int delta,
|
||||
@@ -35,9 +67,13 @@ abstract class BarTabService {
|
||||
|
||||
/// Archives the tab's current items into history and clears them.
|
||||
/// The tab itself stays open under the same customer name.
|
||||
///
|
||||
/// Throws [TabVersionConflictException] when [expectedVersion] is given and
|
||||
/// the tab changed since.
|
||||
Future<void> closeTab(
|
||||
String tabId, {
|
||||
PaymentMethod paymentMethod = PaymentMethod.cash,
|
||||
int? expectedVersion,
|
||||
});
|
||||
|
||||
Future<List<ClosedTab>> getClosedTabs();
|
||||
@@ -84,9 +120,7 @@ class DriftBarTabService implements BarTabService {
|
||||
return BarTab(
|
||||
id: row.id,
|
||||
customerName: row.customerName,
|
||||
status: row.status,
|
||||
openedAt: row.openedAt,
|
||||
closedAt: row.closedAt,
|
||||
version: row.version,
|
||||
items: items,
|
||||
);
|
||||
@@ -105,222 +139,133 @@ class DriftBarTabService implements BarTabService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TabItem>> _getItemsForTab(String tabId) async {
|
||||
final query = database.select(database.tabItems)
|
||||
..where((item) => item.tabId.equals(tabId))
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)]);
|
||||
/// Tabs, items and purchases in a single query, so loading N tabs doesn't
|
||||
/// cost N * items extra round trips.
|
||||
JoinedSelectStatement<HasResultSet, dynamic> _tabsQuery({String? tabId}) {
|
||||
final query = database.select(database.barTabs).join([
|
||||
leftOuterJoin(
|
||||
database.tabItems,
|
||||
database.tabItems.tabId.equalsExp(database.barTabs.id),
|
||||
),
|
||||
leftOuterJoin(
|
||||
database.tabItemPurchases,
|
||||
database.tabItemPurchases.tabItemId.equalsExp(database.tabItems.id),
|
||||
),
|
||||
]);
|
||||
|
||||
final rows = await query.get();
|
||||
if (tabId != null) query.where(database.barTabs.id.equals(tabId));
|
||||
|
||||
final items = <TabItem>[];
|
||||
query.orderBy([
|
||||
OrderingTerm.desc(database.barTabs.openedAt),
|
||||
OrderingTerm.asc(database.tabItems.createdAt),
|
||||
OrderingTerm.asc(database.tabItemPurchases.purchasedAt),
|
||||
OrderingTerm.asc(database.tabItemPurchases.id),
|
||||
]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
List<BarTab> _groupTabs(
|
||||
List<TypedResult> rows, {
|
||||
required bool includeRemoved,
|
||||
}) {
|
||||
final tabRows = <String, BarTabRow>{};
|
||||
final itemRowsByTab = <String, Map<String, TabItemRow>>{};
|
||||
final purchasesByItem = <String, List<TabItemPurchase>>{};
|
||||
|
||||
for (final row in rows) {
|
||||
if (row.quantity <= 0) continue;
|
||||
final tab = row.readTable(database.barTabs);
|
||||
tabRows[tab.id] = tab;
|
||||
final itemRows = itemRowsByTab.putIfAbsent(tab.id, () => {});
|
||||
|
||||
final purchases = await _getPurchaseRecords(row);
|
||||
items.add(_mapItemRow(row, purchases));
|
||||
final item = row.readTableOrNull(database.tabItems);
|
||||
if (item == null) continue;
|
||||
|
||||
itemRows[item.id] = item;
|
||||
final purchases = purchasesByItem.putIfAbsent(item.id, () => []);
|
||||
|
||||
final purchase = row.readTableOrNull(database.tabItemPurchases);
|
||||
if (purchase != null) purchases.add(_mapPurchaseRow(purchase));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<List<TabItem>> _getItemsForHistory(String tabId) async {
|
||||
final query = database.select(database.tabItems)
|
||||
..where((item) => item.tabId.equals(tabId))
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)]);
|
||||
|
||||
final rows = await query.get();
|
||||
final items = <TabItem>[];
|
||||
|
||||
for (final row in rows) {
|
||||
final purchases = await _getPurchaseRecords(row);
|
||||
if (purchases.isEmpty) continue;
|
||||
|
||||
items.add(_mapItemRow(row, purchases));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<List<TabItemPurchaseRow>> _getPurchaseRows(String tabItemId) async {
|
||||
final query = database.select(database.tabItemPurchases)
|
||||
..where((purchase) => purchase.tabItemId.equals(tabItemId))
|
||||
..orderBy([
|
||||
(purchase) => OrderingTerm.asc(purchase.purchasedAt),
|
||||
(purchase) => OrderingTerm.asc(purchase.id),
|
||||
]);
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
Future<List<TabItemPurchase>> _getPurchaseRecords(TabItemRow item) async {
|
||||
final rows = await _getPurchaseRows(item.id);
|
||||
final purchases = rows.map(_mapPurchaseRow).toList();
|
||||
final activeCount = purchases
|
||||
.where((purchase) => !purchase.isRemoved)
|
||||
.length;
|
||||
final missingActivePurchases = item.quantity - activeCount;
|
||||
|
||||
if (missingActivePurchases <= 0) return purchases;
|
||||
|
||||
return [
|
||||
...purchases,
|
||||
for (var index = 0; index < missingActivePurchases; index++)
|
||||
TabItemPurchase(
|
||||
id: 'legacy-${item.id}-$index',
|
||||
tabItemId: item.id,
|
||||
purchasedAt: item.createdAt,
|
||||
),
|
||||
for (final tab in tabRows.values)
|
||||
_mapTabRow(tab, [
|
||||
for (final item in itemRowsByTab[tab.id]!.values)
|
||||
if (includeRemoved
|
||||
? purchasesByItem[item.id]!.isNotEmpty
|
||||
: item.quantity > 0)
|
||||
_mapItemRow(item, purchasesByItem[item.id]!),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
Future<List<TabItemPurchaseRow>> _ensurePurchaseRows(TabItemRow item) async {
|
||||
final rows = await _getPurchaseRows(item.id);
|
||||
final activeCount = rows
|
||||
.where((purchase) => purchase.removedAt == null)
|
||||
.length;
|
||||
final missingActivePurchases = item.quantity - activeCount;
|
||||
|
||||
for (var index = 0; index < missingActivePurchases; index++) {
|
||||
await _recordPurchase(tabItemId: item.id, purchasedAt: item.createdAt);
|
||||
}
|
||||
|
||||
if (missingActivePurchases <= 0) return rows;
|
||||
|
||||
return _getPurchaseRows(item.id);
|
||||
Future<BarTabRow?> _getTabRow(String tabId) {
|
||||
return (database.select(
|
||||
database.barTabs,
|
||||
)..where((tab) => tab.id.equals(tabId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> _recordPurchase({
|
||||
required String tabItemId,
|
||||
DateTime? purchasedAt,
|
||||
}) async {
|
||||
Future<void> _recordPurchase(String tabItemId) async {
|
||||
await database
|
||||
.into(database.tabItemPurchases)
|
||||
.insert(
|
||||
TabItemPurchasesCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
tabItemId: tabItemId,
|
||||
purchasedAt: purchasedAt ?? DateTime.now(),
|
||||
purchasedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _decreaseProductStock(String productId, int amount) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError.value(amount, 'amount', 'Must be greater than zero');
|
||||
}
|
||||
/// Marks the most recent active purchases as removed, keeping them in the
|
||||
/// audit trail.
|
||||
Future<void> _removeLatestPurchases(String tabItemId, int count) async {
|
||||
final latest =
|
||||
await (database.select(database.tabItemPurchases)
|
||||
..where(
|
||||
(purchase) =>
|
||||
purchase.tabItemId.equals(tabItemId) &
|
||||
purchase.removedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(purchase) => OrderingTerm.desc(purchase.purchasedAt),
|
||||
(purchase) => OrderingTerm.desc(purchase.id),
|
||||
])
|
||||
..limit(count))
|
||||
.get();
|
||||
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity - ? '
|
||||
'WHERE id = ? AND stock_quantity >= ?',
|
||||
variables: [
|
||||
Variable.withInt(amount),
|
||||
Variable.withString(productId),
|
||||
Variable.withInt(amount),
|
||||
],
|
||||
updates: {database.products},
|
||||
await (database.update(database.tabItemPurchases)
|
||||
..where((purchase) => purchase.id.isIn(latest.map((row) => row.id))))
|
||||
.write(TabItemPurchasesCompanion(removedAt: Value(DateTime.now())));
|
||||
}
|
||||
|
||||
/// Foreign keys aren't enforced, so purchases are deleted explicitly.
|
||||
Future<void> _deleteItemsOfTab(String tabId) async {
|
||||
final itemIds = database.selectOnly(database.tabItems)
|
||||
..addColumns([database.tabItems.id])
|
||||
..where(database.tabItems.tabId.equals(tabId));
|
||||
|
||||
await (database.delete(
|
||||
database.tabItemPurchases,
|
||||
)..where((purchase) => purchase.tabItemId.isInQuery(itemIds))).go();
|
||||
|
||||
await (database.delete(
|
||||
database.tabItems,
|
||||
)..where((item) => item.tabId.equals(tabId))).go();
|
||||
}
|
||||
|
||||
Future<void> _incrementTabVersion(String tabId) async {
|
||||
await (database.update(
|
||||
database.barTabs,
|
||||
)..where((tab) => tab.id.equals(tabId))).write(
|
||||
BarTabsCompanion.custom(
|
||||
version: database.barTabs.version + const Constant(1),
|
||||
),
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
final product = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(productId))).getSingleOrNull();
|
||||
|
||||
if (product == null) {
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
throw const InsufficientStockException();
|
||||
}
|
||||
|
||||
Future<void> _increaseProductStock(String productId, int amount) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError.value(amount, 'amount', 'Must be greater than zero');
|
||||
}
|
||||
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity + ? '
|
||||
'WHERE id = ?',
|
||||
variables: [Variable.withInt(amount), Variable.withString(productId)],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<BarTab>> getOpenTabs({bool includeRemoved = false}) async {
|
||||
final query = database.select(database.barTabs)
|
||||
..where((tab) => tab.status.equals('open'))
|
||||
..orderBy([(tab) => OrderingTerm.desc(tab.openedAt)]);
|
||||
|
||||
final tabRows = await query.get();
|
||||
|
||||
final tabs = <BarTab>[];
|
||||
|
||||
for (final tabRow in tabRows) {
|
||||
final items = includeRemoved
|
||||
? await _getItemsForHistory(tabRow.id)
|
||||
: await _getItemsForTab(tabRow.id);
|
||||
|
||||
tabs.add(_mapTabRow(tabRow, items));
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BarTab?> getTabById(String id) async {
|
||||
final query = database.select(database.barTabs)
|
||||
..where((tab) => tab.id.equals(id));
|
||||
|
||||
final tabRow = await query.getSingleOrNull();
|
||||
|
||||
if (tabRow == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final items = await _getItemsForTab(tabRow.id);
|
||||
|
||||
return _mapTabRow(tabRow, items);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BarTab> createTab({required String customerName}) async {
|
||||
final id = _uuid.v4();
|
||||
final now = DateTime.now();
|
||||
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: id,
|
||||
customerName: customerName,
|
||||
status: const Value('open'),
|
||||
openedAt: now,
|
||||
version: const Value(1),
|
||||
),
|
||||
);
|
||||
|
||||
final tab = await getTabById(id);
|
||||
|
||||
if (tab == null) {
|
||||
throw Exception('Could not create tab.');
|
||||
}
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> renameTab({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
}) async {
|
||||
String _validateCustomerName(String customerName) {
|
||||
final name = customerName.trim();
|
||||
if (name.isEmpty) {
|
||||
throw ArgumentError.value(
|
||||
@@ -329,56 +274,80 @@ class DriftBarTabService implements BarTabService {
|
||||
'Must not be empty',
|
||||
);
|
||||
}
|
||||
|
||||
final updatedRows =
|
||||
await (database.update(database.barTabs)
|
||||
..where((tab) => tab.id.equals(tabId)))
|
||||
.write(BarTabsCompanion(customerName: Value(name)));
|
||||
|
||||
if (updatedRows != 1) {
|
||||
throw StateError('Tab not found.');
|
||||
}
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
return name;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TabItem>> deleteTab(String tabId) async {
|
||||
return database.transaction(() async {
|
||||
final tab = await (database.select(
|
||||
database.barTabs,
|
||||
)..where((row) => row.id.equals(tabId))).getSingleOrNull();
|
||||
Stream<List<BarTab>> watchOpenTabs() {
|
||||
return _tabsQuery().watch().map(
|
||||
(rows) => _groupTabs(rows, includeRemoved: false),
|
||||
);
|
||||
}
|
||||
|
||||
if (tab == null) return <TabItem>[];
|
||||
@override
|
||||
Future<List<BarTab>> getOpenTabs({bool includeRemoved = false}) async {
|
||||
final rows = await _tabsQuery().get();
|
||||
return _groupTabs(rows, includeRemoved: includeRemoved);
|
||||
}
|
||||
|
||||
final items = await _getItemsForTab(tabId);
|
||||
@override
|
||||
Future<BarTab?> getTabById(String id) async {
|
||||
final rows = await _tabsQuery(tabId: id).get();
|
||||
return _groupTabs(rows, includeRemoved: false).firstOrNull;
|
||||
}
|
||||
|
||||
for (final item in items) {
|
||||
await _increaseProductStock(item.productId, item.quantity);
|
||||
}
|
||||
@override
|
||||
Future<BarTab> createTab({required String customerName}) async {
|
||||
final id = _uuid.v4();
|
||||
|
||||
await (database.delete(
|
||||
database.tabItems,
|
||||
)..where((item) => item.tabId.equals(tabId))).go();
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: id,
|
||||
customerName: _validateCustomerName(customerName),
|
||||
openedAt: DateTime.now(),
|
||||
version: const Value(1),
|
||||
),
|
||||
);
|
||||
|
||||
final deletedRows = await (database.delete(
|
||||
database.barTabs,
|
||||
)..where((row) => row.id.equals(tabId))).go();
|
||||
return (await getTabById(id))!;
|
||||
}
|
||||
|
||||
if (deletedRows != 1) {
|
||||
throw StateError('Tab was changed before it could be deleted');
|
||||
}
|
||||
@override
|
||||
Future<void> renameTab({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
}) async {
|
||||
final name = _validateCustomerName(customerName);
|
||||
|
||||
return items;
|
||||
await database.transaction(() async {
|
||||
final updatedRows =
|
||||
await (database.update(database.barTabs)
|
||||
..where((tab) => tab.id.equals(tabId)))
|
||||
.write(BarTabsCompanion(customerName: Value(name)));
|
||||
|
||||
if (updatedRows != 1) throw TabNotFoundException(tabId);
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _incrementTabVersion(String tabId) async {
|
||||
await database.customUpdate(
|
||||
'UPDATE bar_tabs SET version = version + 1 WHERE id = ?',
|
||||
variables: [Variable.withString(tabId)],
|
||||
updates: {database.barTabs},
|
||||
);
|
||||
@override
|
||||
Future<void> deleteTab(String tabId) async {
|
||||
await database.transaction(() async {
|
||||
final tab = await getTabById(tabId);
|
||||
if (tab == null) return;
|
||||
|
||||
for (final item in tab.items) {
|
||||
await database.increaseStock(item.productId, item.quantity);
|
||||
}
|
||||
|
||||
await _deleteItemsOfTab(tabId);
|
||||
await (database.delete(
|
||||
database.barTabs,
|
||||
)..where((row) => row.id.equals(tabId))).go();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -387,48 +356,44 @@ class DriftBarTabService implements BarTabService {
|
||||
required Product product,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
await _decreaseProductStock(product.id, 1);
|
||||
await database.decreaseStock(product.id, 1);
|
||||
|
||||
final existingItemQuery = database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabId) & item.productId.equals(product.id),
|
||||
);
|
||||
|
||||
final existingItem = await existingItemQuery.getSingleOrNull();
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabId) &
|
||||
item.productId.equals(product.id),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (existingItem != null) {
|
||||
final updateQuery = database.update(database.tabItems)
|
||||
..where((item) => item.id.equals(existingItem.id));
|
||||
|
||||
await updateQuery.write(
|
||||
await (database.update(
|
||||
database.tabItems,
|
||||
)..where((item) => item.id.equals(existingItem.id))).write(
|
||||
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
||||
);
|
||||
await _recordPurchase(tabItemId: existingItem.id);
|
||||
await _recordPurchase(existingItem.id);
|
||||
} else {
|
||||
final tabItemId = _uuid.v4();
|
||||
|
||||
return;
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: tabItemId,
|
||||
tabId: tabId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: 1,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await _recordPurchase(tabItemId);
|
||||
}
|
||||
|
||||
final tabItemId = _uuid.v4();
|
||||
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: tabItemId,
|
||||
tabId: tabId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: 1,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
await _recordPurchase(tabItemId: tabItemId);
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -436,100 +401,61 @@ class DriftBarTabService implements BarTabService {
|
||||
required String tabItemId,
|
||||
required int delta,
|
||||
}) async {
|
||||
String? tabId;
|
||||
if (delta == 0) return 0;
|
||||
|
||||
final actualDelta = await database.transaction(() async {
|
||||
return database.transaction(() async {
|
||||
final item = await (database.select(
|
||||
database.tabItems,
|
||||
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
||||
|
||||
if (item == null || delta == 0 || (item.quantity == 0 && delta < 0)) {
|
||||
return 0;
|
||||
}
|
||||
if (item == null) return 0;
|
||||
|
||||
tabId = item.tabId;
|
||||
final newQuantity = max(item.quantity + delta, 0);
|
||||
final actualDelta = newQuantity - item.quantity;
|
||||
|
||||
final requestedQuantity = item.quantity + delta;
|
||||
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
|
||||
if (actualDelta == 0) return 0;
|
||||
|
||||
if (actualDelta > 0) {
|
||||
await _decreaseProductStock(item.productId, actualDelta);
|
||||
|
||||
await database.decreaseStock(item.productId, actualDelta);
|
||||
for (var index = 0; index < actualDelta; index++) {
|
||||
await _recordPurchase(tabItemId: item.id);
|
||||
await _recordPurchase(item.id);
|
||||
}
|
||||
} else {
|
||||
await _increaseProductStock(item.productId, -actualDelta);
|
||||
|
||||
final purchaseRows = await _ensurePurchaseRows(item);
|
||||
final activePurchaseRows =
|
||||
purchaseRows
|
||||
.where((purchase) => purchase.removedAt == null)
|
||||
.toList()
|
||||
..sort((a, b) => b.purchasedAt.compareTo(a.purchasedAt));
|
||||
final removedAt = DateTime.now();
|
||||
|
||||
for (final purchase in activePurchaseRows.take(-actualDelta)) {
|
||||
await (database.update(database.tabItemPurchases)
|
||||
..where((row) => row.id.equals(purchase.id)))
|
||||
.write(TabItemPurchasesCompanion(removedAt: Value(removedAt)));
|
||||
}
|
||||
await database.increaseStock(item.productId, -actualDelta);
|
||||
await _removeLatestPurchases(item.id, -actualDelta);
|
||||
}
|
||||
|
||||
if (requestedQuantity <= 0) {
|
||||
final updatedRows =
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(const TabItemsCompanion(quantity: Value(0)));
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
||||
|
||||
if (updatedRows != 1) {
|
||||
throw StateError(
|
||||
'Tab item was changed before its quantity was updated',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final updatedRows =
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(TabItemsCompanion(quantity: Value(requestedQuantity)));
|
||||
|
||||
if (updatedRows != 1) {
|
||||
throw StateError(
|
||||
'Tab item was changed before its quantity was updated',
|
||||
);
|
||||
}
|
||||
}
|
||||
await _incrementTabVersion(item.tabId);
|
||||
|
||||
return actualDelta;
|
||||
});
|
||||
|
||||
if (tabId != null && actualDelta != 0) {
|
||||
await _incrementTabVersion(tabId!);
|
||||
}
|
||||
|
||||
return actualDelta;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> closeTab(
|
||||
String tabId, {
|
||||
PaymentMethod paymentMethod = PaymentMethod.cash,
|
||||
int? expectedVersion,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
final tabQuery = database.select(database.barTabs)
|
||||
..where((tab) => tab.id.equals(tabId));
|
||||
final tabRow = await _getTabRow(tabId);
|
||||
if (tabRow == null) throw TabNotFoundException(tabId);
|
||||
|
||||
final tabRow = await tabQuery.getSingleOrNull();
|
||||
|
||||
if (tabRow == null) {
|
||||
return;
|
||||
if (expectedVersion != null && tabRow.version != expectedVersion) {
|
||||
throw TabVersionConflictException(
|
||||
expectedVersion: expectedVersion,
|
||||
actualVersion: tabRow.version,
|
||||
);
|
||||
}
|
||||
|
||||
final items = await _getItemsForHistory(tabId);
|
||||
final rows = await _tabsQuery(tabId: tabId).get();
|
||||
final items = _groupTabs(rows, includeRemoved: true).first.items;
|
||||
|
||||
if (items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final closedTabId = _uuid.v4();
|
||||
|
||||
@@ -541,44 +467,83 @@ class DriftBarTabService implements BarTabService {
|
||||
originalTabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
closedAt: DateTime.now(),
|
||||
paymentMethod: Value(paymentMethod.value),
|
||||
paymentMethod: Value(paymentMethod),
|
||||
),
|
||||
);
|
||||
|
||||
for (final item in items) {
|
||||
for (final purchase in item.purchases) {
|
||||
await database
|
||||
.into(database.closedTabItems)
|
||||
.insert(
|
||||
ClosedTabItemsCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
closedTabId: closedTabId,
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
quantity: 1,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
purchasedAt: Value(purchase.purchasedAt),
|
||||
removedAt: Value(purchase.removedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await database.batch((batch) {
|
||||
batch.insertAll(database.closedTabItems, [
|
||||
for (final item in items)
|
||||
for (final purchase in item.purchases)
|
||||
ClosedTabItemsCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
closedTabId: closedTabId,
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
quantity: 1,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
purchasedAt: Value(purchase.purchasedAt),
|
||||
removedAt: Value(purchase.removedAt),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
final deleteQuery = database.delete(database.tabItems)
|
||||
..where((item) => item.tabId.equals(tabId));
|
||||
|
||||
await deleteQuery.go();
|
||||
await _deleteItemsOfTab(tabId);
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
/// Loads the items for every row in one query instead of one per tab.
|
||||
Future<List<ClosedTab>> _withItems(List<ClosedTabsRow> tabRows) async {
|
||||
if (tabRows.isEmpty) return [];
|
||||
|
||||
final itemRows =
|
||||
await (database.select(database.closedTabItems)
|
||||
..where(
|
||||
(item) => item.closedTabId.isIn(tabRows.map((row) => row.id)),
|
||||
)
|
||||
..orderBy([
|
||||
(item) => OrderingTerm.desc(item.purchasedAt),
|
||||
(item) => OrderingTerm.desc(item.id),
|
||||
]))
|
||||
.get();
|
||||
|
||||
final itemsByTab = <String, List<ClosedTabItem>>{};
|
||||
for (final row in itemRows) {
|
||||
itemsByTab
|
||||
.putIfAbsent(row.closedTabId, () => [])
|
||||
.add(_mapClosedItemRow(row));
|
||||
}
|
||||
|
||||
return [
|
||||
for (final row in tabRows)
|
||||
ClosedTab(
|
||||
id: row.id,
|
||||
originalTabId: row.originalTabId,
|
||||
customerName: row.customerName,
|
||||
closedAt: row.closedAt,
|
||||
paymentMethod: row.paymentMethod,
|
||||
items: itemsByTab[row.id] ?? [],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
SimpleSelectStatement<$ClosedTabsTable, ClosedTabsRow> _closedTabsQuery({
|
||||
String? customerName,
|
||||
}) {
|
||||
final query = database.select(database.closedTabs)
|
||||
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]);
|
||||
|
||||
if (customerName != null && customerName.isNotEmpty) {
|
||||
query.where((tab) => tab.customerName.equals(customerName));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ClosedTab>> getClosedTabs() async {
|
||||
final count = await getClosedTabCount();
|
||||
if (count == 0) return [];
|
||||
|
||||
return getClosedTabsPaginated(limit: count, offset: 0);
|
||||
return _withItems(await _closedTabsQuery().get());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -587,53 +552,21 @@ class DriftBarTabService implements BarTabService {
|
||||
int offset = 0,
|
||||
String? customerName,
|
||||
}) async {
|
||||
var query = database.select(database.closedTabs)
|
||||
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)])
|
||||
final query = _closedTabsQuery(customerName: customerName)
|
||||
..limit(limit, offset: offset);
|
||||
|
||||
if (customerName != null && customerName.isNotEmpty) {
|
||||
query = query..where((tab) => tab.customerName.equals(customerName));
|
||||
}
|
||||
|
||||
final closedTabRows = await query.get();
|
||||
|
||||
final closedTabs = <ClosedTab>[];
|
||||
|
||||
for (final row in closedTabRows) {
|
||||
final itemsQuery = database.select(database.closedTabItems)
|
||||
..where((item) => item.closedTabId.equals(row.id))
|
||||
..orderBy([
|
||||
(item) => OrderingTerm.desc(item.purchasedAt),
|
||||
(item) => OrderingTerm.desc(item.id),
|
||||
]);
|
||||
|
||||
final itemRows = await itemsQuery.get();
|
||||
|
||||
closedTabs.add(
|
||||
ClosedTab(
|
||||
id: row.id,
|
||||
originalTabId: row.originalTabId,
|
||||
customerName: row.customerName,
|
||||
closedAt: row.closedAt,
|
||||
paymentMethod: PaymentMethod.fromValue(row.paymentMethod),
|
||||
items: itemRows.map<ClosedTabItem>(_mapClosedItemRow).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return closedTabs;
|
||||
return _withItems(await query.get());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getClosedTabCount({String? customerName}) async {
|
||||
final countExpr = database.closedTabs.id.count();
|
||||
|
||||
var query = database.selectOnly(database.closedTabs)
|
||||
final query = database.selectOnly(database.closedTabs)
|
||||
..addColumns([countExpr]);
|
||||
|
||||
if (customerName != null && customerName.isNotEmpty) {
|
||||
query = query
|
||||
..where(database.closedTabs.customerName.equals(customerName));
|
||||
query.where(database.closedTabs.customerName.equals(customerName));
|
||||
}
|
||||
|
||||
final row = await query.getSingle();
|
||||
@@ -642,7 +575,13 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
@override
|
||||
Future<List<String>> getDistinctCustomerNames() async {
|
||||
final rows = await database.select(database.closedTabs).get();
|
||||
return rows.map((row) => row.customerName).toSet().toList()..sort();
|
||||
final customerName = database.closedTabs.customerName;
|
||||
|
||||
final query = database.selectOnly(database.closedTabs, distinct: true)
|
||||
..addColumns([customerName])
|
||||
..orderBy([OrderingTerm.asc(customerName)]);
|
||||
|
||||
final rows = await query.get();
|
||||
return rows.map((row) => row.read(customerName)!).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
|
||||
@@ -52,7 +54,9 @@ class DefaultProductSeeder {
|
||||
imagePath = await _copyBundledImage(name, imagesDir);
|
||||
}
|
||||
|
||||
await database.into(database.products).insert(
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: uuid.v4(),
|
||||
name: name,
|
||||
@@ -67,7 +71,7 @@ class DefaultProductSeeder {
|
||||
});
|
||||
} catch (e, stack) {
|
||||
debugPrint('DefaultProductSeeder: seedIfEmpty error: $e');
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +99,10 @@ class DefaultProductSeeder {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _copyBundledImage(String productName, Directory imagesDir) async {
|
||||
Future<String?> _copyBundledImage(
|
||||
String productName,
|
||||
Directory imagesDir,
|
||||
) async {
|
||||
final slug = _toSlug(productName);
|
||||
|
||||
for (final ext in ['png', 'jpg', 'webp']) {
|
||||
@@ -107,10 +114,9 @@ class DefaultProductSeeder {
|
||||
final destPath = path.join(imagesDir.path, fileName);
|
||||
final destFile = File(destPath);
|
||||
|
||||
await destFile.writeAsBytes(bytes.buffer.asUint8List(
|
||||
bytes.offsetInBytes,
|
||||
bytes.lengthInBytes,
|
||||
));
|
||||
await destFile.writeAsBytes(
|
||||
bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes),
|
||||
);
|
||||
|
||||
return destPath;
|
||||
} catch (_) {
|
||||
@@ -118,8 +124,10 @@ class DefaultProductSeeder {
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('DefaultProductSeeder: no bundled image found for "$productName" '
|
||||
'(looked for assets/products/$slug.{png,jpg,webp})');
|
||||
debugPrint(
|
||||
'DefaultProductSeeder: no bundled image found for "$productName" '
|
||||
'(looked for assets/products/$slug.{png,jpg,webp})',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,157 +1,89 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../models/closed_tab.dart';
|
||||
import '../models/closed_tab_item.dart';
|
||||
import '../utils/app_config.dart';
|
||||
import 'bar_tab_service.dart';
|
||||
|
||||
abstract class ExportService {
|
||||
Future<void> uploadToServer();
|
||||
}
|
||||
|
||||
/// Uploads the closed-tab history to the backup server. Best effort: a
|
||||
/// failed backup is logged but never interrupts the bar.
|
||||
class DefaultExportService implements ExportService {
|
||||
final AppDatabase database;
|
||||
final BarTabService barTabService;
|
||||
final http.Client _client;
|
||||
|
||||
DefaultExportService({required this.database});
|
||||
DefaultExportService({required this.barTabService, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
@override
|
||||
Future<void> uploadToServer() async {
|
||||
try {
|
||||
final closedTabs = await _getAllClosedTabs();
|
||||
final exportData = _buildExportPayload(closedTabs);
|
||||
final closedTabs = await barTabService.getClosedTabs();
|
||||
final appVersion = (await PackageInfo.fromPlatform()).version;
|
||||
|
||||
final response = await http.post(
|
||||
final response = await _client.post(
|
||||
Uri.parse(kExportServerUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(exportData),
|
||||
body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
// Should prob log this one tho
|
||||
unawaited(
|
||||
Sentry.captureMessage(
|
||||
'Backup upload failed with HTTP ${response.statusCode}',
|
||||
level: SentryLevel.warning,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// if this fails who cares
|
||||
} on Exception catch (e) {
|
||||
// Mostly offline tablets; not worth an error report.
|
||||
debugPrint('ExportService: backup upload failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<_ClosedTabExport>> _getAllClosedTabs() async {
|
||||
final tabQuery = database.select(database.closedTabs)
|
||||
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]);
|
||||
|
||||
final tabRows = await tabQuery.get();
|
||||
|
||||
final result = <_ClosedTabExport>[];
|
||||
|
||||
for (final tabRow in tabRows) {
|
||||
final itemsQuery = database.select(database.closedTabItems)
|
||||
..where((item) => item.closedTabId.equals(tabRow.id));
|
||||
|
||||
final itemRows = await itemsQuery.get();
|
||||
|
||||
result.add(
|
||||
_ClosedTabExport(
|
||||
id: tabRow.id,
|
||||
originalTabId: tabRow.originalTabId,
|
||||
customerName: tabRow.customerName,
|
||||
closedAt: tabRow.closedAt.toIso8601String(),
|
||||
paymentMethod: tabRow.paymentMethod,
|
||||
items: itemRows
|
||||
.map(
|
||||
(row) => _ClosedTabItemExport(
|
||||
productId: row.productId,
|
||||
productName: row.productName,
|
||||
quantity: row.quantity,
|
||||
unitPriceInCents: row.unitPriceInCents,
|
||||
purchasedAt: row.purchasedAt?.toIso8601String(),
|
||||
removedAt: row.removedAt?.toIso8601String(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildExportPayload(List<_ClosedTabExport> tabs) {
|
||||
Map<String, dynamic> _buildPayload(
|
||||
List<ClosedTab> tabs, {
|
||||
required String appVersion,
|
||||
}) {
|
||||
return {
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'appVersion': '1.0.7',
|
||||
'closedTabs': tabs
|
||||
.map(
|
||||
(tab) => {
|
||||
'id': tab.id,
|
||||
'originalTabId': tab.originalTabId,
|
||||
'customerName': tab.customerName,
|
||||
'closedAt': tab.closedAt,
|
||||
'paymentMethod': tab.paymentMethod,
|
||||
'items': tab.items
|
||||
.map(
|
||||
(item) => {
|
||||
'productId': item.productId,
|
||||
'productName': item.productName,
|
||||
'quantity': item.quantity,
|
||||
'unitPriceInCents': item.unitPriceInCents,
|
||||
'purchasedAt': item.purchasedAt,
|
||||
'removedAt': item.removedAt,
|
||||
'isRemoved': item.isRemoved,
|
||||
'lineTotalInCents': item.lineTotalInCents,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'itemCount': tab.items.fold(
|
||||
0,
|
||||
(sum, item) => sum + (item.isRemoved ? 0 : item.quantity),
|
||||
),
|
||||
'totalInCents': tab.items.fold(
|
||||
0,
|
||||
(sum, item) => sum + item.lineTotalInCents,
|
||||
),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'appVersion': appVersion,
|
||||
'closedTabs': tabs.map(_tabToJson).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _tabToJson(ClosedTab tab) {
|
||||
return {
|
||||
'id': tab.id,
|
||||
'originalTabId': tab.originalTabId,
|
||||
'customerName': tab.customerName,
|
||||
'closedAt': tab.closedAt.toIso8601String(),
|
||||
'paymentMethod': tab.paymentMethod.value,
|
||||
'items': tab.items.map(_itemToJson).toList(),
|
||||
'itemCount': tab.itemCount,
|
||||
'totalInCents': tab.totalInCents,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _itemToJson(ClosedTabItem item) {
|
||||
return {
|
||||
'productId': item.productId,
|
||||
'productName': item.productName,
|
||||
'quantity': item.quantity,
|
||||
'unitPriceInCents': item.unitPriceInCents,
|
||||
'purchasedAt': item.purchasedAt?.toIso8601String(),
|
||||
'removedAt': item.removedAt?.toIso8601String(),
|
||||
'isRemoved': item.isRemoved,
|
||||
'lineTotalInCents': item.lineTotalInCents,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ClosedTabItemExport {
|
||||
final String productId;
|
||||
final String productName;
|
||||
final int quantity;
|
||||
final int unitPriceInCents;
|
||||
final String? purchasedAt;
|
||||
final String? removedAt;
|
||||
|
||||
_ClosedTabItemExport({
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
required this.quantity,
|
||||
required this.unitPriceInCents,
|
||||
required this.purchasedAt,
|
||||
required this.removedAt,
|
||||
});
|
||||
|
||||
bool get isRemoved => removedAt != null;
|
||||
|
||||
int get lineTotalInCents => isRemoved ? 0 : quantity * unitPriceInCents;
|
||||
}
|
||||
|
||||
class _ClosedTabExport {
|
||||
final String id;
|
||||
final String originalTabId;
|
||||
final String customerName;
|
||||
final String closedAt;
|
||||
final String paymentMethod;
|
||||
final List<_ClosedTabItemExport> items;
|
||||
|
||||
_ClosedTabExport({
|
||||
required this.id,
|
||||
required this.originalTabId,
|
||||
required this.customerName,
|
||||
required this.closedAt,
|
||||
required this.paymentMethod,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/remote_command.dart';
|
||||
import '../models/venue.dart';
|
||||
import '../utils/app_config.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
final int statusCode;
|
||||
|
||||
/// Machine-readable reason from the server, e.g. `invalid_registration_code`.
|
||||
final String? code;
|
||||
final String message;
|
||||
|
||||
const ApiException(this.statusCode, this.message, {this.code});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'HTTP $statusCode${code == null ? '' : ' ($code)'}: $message';
|
||||
}
|
||||
|
||||
/// The device token was revoked or is invalid.
|
||||
class UnauthorizedException extends ApiException {
|
||||
const UnauthorizedException({super.code, String? message})
|
||||
: super(401, message ?? 'Unauthorized');
|
||||
}
|
||||
|
||||
class RateLimitedException extends ApiException {
|
||||
final Duration retryAfter;
|
||||
|
||||
const RateLimitedException(this.retryAfter) : super(429, 'Rate limited');
|
||||
}
|
||||
|
||||
class DeviceCredentials {
|
||||
final String deviceId;
|
||||
final String token;
|
||||
|
||||
const DeviceCredentials({required this.deviceId, required this.token});
|
||||
}
|
||||
|
||||
class KoolTapApiClient {
|
||||
final http.Client _client;
|
||||
final String baseUrl;
|
||||
|
||||
KoolTapApiClient({http.Client? client, this.baseUrl = kRemoteServerUrl})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
static const _timeout = Duration(seconds: 15);
|
||||
static const _defaultRetryAfter = Duration(seconds: 60);
|
||||
|
||||
Future<List<Venue>> getVenues() async {
|
||||
final response = await _client
|
||||
.get(Uri.parse('$baseUrl/venues/'))
|
||||
.timeout(_timeout);
|
||||
|
||||
final data = _decode(response) as List<dynamic>;
|
||||
return [
|
||||
for (final venue in data) Venue.fromJson(venue as Map<String, dynamic>),
|
||||
];
|
||||
}
|
||||
|
||||
Future<DeviceCredentials> registerDevice({
|
||||
required String venueId,
|
||||
required String deviceName,
|
||||
required String registrationCode,
|
||||
}) async {
|
||||
final data =
|
||||
await _post('/devices/register', {
|
||||
'venueId': venueId,
|
||||
'name': deviceName,
|
||||
'registrationCode': registrationCode,
|
||||
})
|
||||
as Map<String, dynamic>;
|
||||
|
||||
return DeviceCredentials(
|
||||
deviceId: data['deviceId'] as String,
|
||||
token: data['token'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SyncResponse> sync({
|
||||
required String token,
|
||||
required SyncRequest request,
|
||||
}) async {
|
||||
final data = await _post(
|
||||
'/devices/${request.deviceId}/sync',
|
||||
request.toJson(),
|
||||
token: token,
|
||||
);
|
||||
|
||||
return SyncResponse.fromJson(data as Map<String, dynamic>? ?? {});
|
||||
}
|
||||
|
||||
Future<void> ackCommand({
|
||||
required String token,
|
||||
required String deviceId,
|
||||
required CommandAck ack,
|
||||
}) async {
|
||||
await _post(
|
||||
'/devices/$deviceId/commands/${ack.commandId}/ack',
|
||||
ack.toJson(),
|
||||
token: token,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Object?> _post(
|
||||
String path,
|
||||
Map<String, dynamic> body, {
|
||||
String? token,
|
||||
}) async {
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$baseUrl$path'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
|
||||
return _decode(response, authenticated: token != null);
|
||||
}
|
||||
|
||||
/// A 401 only means the device token is no longer valid when the request
|
||||
/// used one; while pairing it means the registration code was rejected.
|
||||
Object? _decode(http.Response response, {bool authenticated = false}) {
|
||||
final status = response.statusCode;
|
||||
|
||||
if (status == 401 && authenticated) {
|
||||
final error = _error(response);
|
||||
throw UnauthorizedException(code: error.code, message: error.message);
|
||||
}
|
||||
|
||||
if (status == 429) {
|
||||
final seconds = int.tryParse(response.headers['retry-after'] ?? '');
|
||||
throw RateLimitedException(
|
||||
seconds == null ? _defaultRetryAfter : Duration(seconds: seconds),
|
||||
);
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
final error = _error(response);
|
||||
throw ApiException(status, error.message ?? '', code: error.code);
|
||||
}
|
||||
|
||||
if (response.body.isEmpty) return null;
|
||||
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
|
||||
/// The server reports errors as `{"error": {"code": ..., "message": ...}}`;
|
||||
/// anything else (e.g. a proxy's HTML error page) falls back to the reason
|
||||
/// phrase.
|
||||
({String? code, String? message}) _error(http.Response response) {
|
||||
final fallback = response.reasonPhrase;
|
||||
try {
|
||||
final error =
|
||||
(jsonDecode(response.body) as Map<String, dynamic>)['error'];
|
||||
if (error is! Map<String, dynamic>) {
|
||||
return (code: null, message: fallback);
|
||||
}
|
||||
|
||||
return (
|
||||
code: error['code'] as String?,
|
||||
message: error['message'] as String? ?? fallback,
|
||||
);
|
||||
} on FormatException {
|
||||
return (code: null, message: fallback);
|
||||
} on TypeError {
|
||||
return (code: null, message: fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,45 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class PinLockedOutException implements Exception {
|
||||
final Duration remaining;
|
||||
|
||||
const PinLockedOutException(this.remaining);
|
||||
|
||||
@override
|
||||
String toString() => 'PIN locked for ${remaining.inSeconds}s';
|
||||
}
|
||||
|
||||
class PinLockService {
|
||||
PinLockService({
|
||||
FlutterSecureStorage? storage,
|
||||
String saltKey = _defaultSaltKey,
|
||||
String hashKey = _defaultHashKey,
|
||||
this._saltKey = _defaultSaltKey,
|
||||
this._hashKey = _defaultHashKey,
|
||||
DateTime Function()? clock,
|
||||
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||
_saltKey = saltKey,
|
||||
_hashKey = hashKey;
|
||||
_clock = clock ?? DateTime.now;
|
||||
|
||||
static const _defaultSaltKey = 'pin_salt';
|
||||
static const _defaultHashKey = 'pin_hash';
|
||||
|
||||
static const _hashPrefix = 'pbkdf2-sha256';
|
||||
static const _iterations = 50000;
|
||||
|
||||
static const _freeAttempts = 5;
|
||||
static const _baseLockout = Duration(seconds: 30);
|
||||
static const _maxLockout = Duration(minutes: 15);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
final String _saltKey;
|
||||
final String _hashKey;
|
||||
final DateTime Function() _clock;
|
||||
|
||||
String get _failuresKey => '${_hashKey}_failures';
|
||||
String get _lockedUntilKey => '${_hashKey}_locked_until';
|
||||
|
||||
Future<bool> hasPin() async {
|
||||
final hash = await _storage.read(key: _hashKey);
|
||||
@@ -27,24 +48,82 @@ class PinLockService {
|
||||
|
||||
Future<void> setPin(String pin) async {
|
||||
final salt = _generateSalt();
|
||||
final hash = _hash(pin, salt);
|
||||
final hash = await _derive(pin, salt, _iterations);
|
||||
|
||||
await _storage.write(key: _saltKey, value: salt);
|
||||
await _storage.write(key: _hashKey, value: hash);
|
||||
await _clearFailures();
|
||||
}
|
||||
|
||||
/// Throws [PinLockedOutException] after too many wrong attempts. The
|
||||
/// lockout is persisted, so restarting the app doesn't reset it.
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
await _throwIfLockedOut();
|
||||
|
||||
final salt = await _storage.read(key: _saltKey);
|
||||
final storedHash = await _storage.read(key: _hashKey);
|
||||
|
||||
if (salt == null || storedHash == null) return false;
|
||||
|
||||
return _hash(pin, salt) == storedHash;
|
||||
final isLegacy = !storedHash.startsWith('$_hashPrefix\$');
|
||||
final candidate = isLegacy
|
||||
? _legacyHash(pin, salt)
|
||||
: await _derive(pin, salt, _iterationsOf(storedHash));
|
||||
|
||||
if (!_constantTimeEquals(candidate, storedHash)) {
|
||||
await _registerFailure();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLegacy) {
|
||||
await setPin(pin);
|
||||
} else {
|
||||
await _clearFailures();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> clearPin() async {
|
||||
await _storage.delete(key: _saltKey);
|
||||
await _storage.delete(key: _hashKey);
|
||||
await _clearFailures();
|
||||
}
|
||||
|
||||
Future<void> _throwIfLockedOut() async {
|
||||
final lockedUntil = int.tryParse(
|
||||
await _storage.read(key: _lockedUntilKey) ?? '',
|
||||
);
|
||||
if (lockedUntil == null) return;
|
||||
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(
|
||||
lockedUntil,
|
||||
).difference(_clock());
|
||||
|
||||
if (remaining > Duration.zero) throw PinLockedOutException(remaining);
|
||||
}
|
||||
|
||||
/// Every failure past the free attempts doubles the lockout.
|
||||
Future<void> _registerFailure() async {
|
||||
final failures =
|
||||
(int.tryParse(await _storage.read(key: _failuresKey) ?? '') ?? 0) + 1;
|
||||
await _storage.write(key: _failuresKey, value: '$failures');
|
||||
|
||||
if (failures < _freeAttempts) return;
|
||||
|
||||
final exponent = min(failures - _freeAttempts, 10);
|
||||
final lockout = _baseLockout * (1 << exponent);
|
||||
final capped = lockout > _maxLockout ? _maxLockout : lockout;
|
||||
|
||||
await _storage.write(
|
||||
key: _lockedUntilKey,
|
||||
value: '${_clock().add(capped).millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearFailures() async {
|
||||
await _storage.delete(key: _failuresKey);
|
||||
await _storage.delete(key: _lockedUntilKey);
|
||||
}
|
||||
|
||||
String _generateSalt([int length = 16]) {
|
||||
@@ -53,8 +132,52 @@ class PinLockService {
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
String _hash(String pin, String salt) {
|
||||
final bytes = utf8.encode('$salt:$pin');
|
||||
return sha256.convert(bytes).toString();
|
||||
int _iterationsOf(String hash) {
|
||||
return int.parse(hash.split('\$')[1]);
|
||||
}
|
||||
|
||||
/// Hashes are stored as `pbkdf2-sha256$<iterations>$<hex>`, so the
|
||||
/// iteration count can be raised later without breaking existing PINs.
|
||||
///
|
||||
/// Static so the isolate closure can't capture `this`.
|
||||
static Future<String> _derive(String pin, String salt, int iterations) async {
|
||||
final key = await Isolate.run(
|
||||
() => _pbkdf2(utf8.encode(pin), utf8.encode(salt), iterations),
|
||||
);
|
||||
final hex = key.map((byte) => byte.toRadixString(16).padLeft(2, '0'));
|
||||
return '$_hashPrefix\$$iterations\$${hex.join()}';
|
||||
}
|
||||
|
||||
/// Single-block PBKDF2-HMAC-SHA256 (a 32-byte key).
|
||||
static List<int> _pbkdf2(List<int> password, List<int> salt, int iterations) {
|
||||
final hmac = Hmac(sha256, password);
|
||||
|
||||
var block = hmac.convert([...salt, 0, 0, 0, 1]).bytes;
|
||||
final key = List<int>.of(block);
|
||||
|
||||
for (var round = 1; round < iterations; round++) {
|
||||
block = hmac.convert(block).bytes;
|
||||
for (var index = 0; index < key.length; index++) {
|
||||
key[index] ^= block[index];
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// The original single-SHA-256 format, only used to verify and upgrade
|
||||
/// PINs set by older versions.
|
||||
String _legacyHash(String pin, String salt) {
|
||||
return sha256.convert(utf8.encode('$salt:$pin')).toString();
|
||||
}
|
||||
|
||||
bool _constantTimeEquals(String a, String b) {
|
||||
if (a.length != b.length) return false;
|
||||
|
||||
var difference = 0;
|
||||
for (var index = 0; index < a.length; index++) {
|
||||
difference |= a.codeUnitAt(index) ^ b.codeUnitAt(index);
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import 'package:drift/drift.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/product.dart';
|
||||
|
||||
class InsufficientStockException implements Exception {
|
||||
const InsufficientStockException();
|
||||
|
||||
@override
|
||||
String toString() => 'Not enough stock';
|
||||
}
|
||||
export '../database/stock_queries.dart'
|
||||
show InsufficientStockException, ProductNotFoundException;
|
||||
|
||||
abstract class ProductService {
|
||||
/// Active products, re-emitted whenever the products table changes.
|
||||
Stream<List<Product>> watchProducts();
|
||||
|
||||
Future<List<Product>> getProducts();
|
||||
|
||||
Future<Product?> getProductById(String id);
|
||||
@@ -53,15 +53,20 @@ class DriftProductService implements ProductService {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Product>> getProducts() async {
|
||||
final query = database.select(database.products)
|
||||
SimpleSelectStatement<$ProductsTable, ProductRow> _activeProductsQuery() {
|
||||
return database.select(database.products)
|
||||
..where((product) => product.active.equals(true))
|
||||
..orderBy([(product) => OrderingTerm.asc(product.name)]);
|
||||
}
|
||||
|
||||
final rows = await query.get();
|
||||
@override
|
||||
Stream<List<Product>> watchProducts() {
|
||||
return _activeProductsQuery().map(_mapRowToProduct).watch();
|
||||
}
|
||||
|
||||
return rows.map(_mapRowToProduct).toList();
|
||||
@override
|
||||
Future<List<Product>> getProducts() {
|
||||
return _activeProductsQuery().map(_mapRowToProduct).get();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -129,49 +134,12 @@ class DriftProductService implements ProductService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> decreaseStock(String productId, int amount) async {
|
||||
_validateStockAmount(amount);
|
||||
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity - ? '
|
||||
'WHERE id = ? AND stock_quantity >= ?',
|
||||
variables: [
|
||||
Variable.withInt(amount),
|
||||
Variable.withString(productId),
|
||||
Variable.withInt(amount),
|
||||
],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
final product = await getProductById(productId);
|
||||
if (product == null) throw Exception('Product not found');
|
||||
|
||||
throw const InsufficientStockException();
|
||||
Future<void> decreaseStock(String productId, int amount) {
|
||||
return database.decreaseStock(productId, amount);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> increaseStock(String productId, int amount) async {
|
||||
_validateStockAmount(amount);
|
||||
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity + ? '
|
||||
'WHERE id = ?',
|
||||
variables: [Variable.withInt(amount), Variable.withString(productId)],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
void _validateStockAmount(int amount) {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError.value(amount, 'amount', 'Must be greater than zero');
|
||||
}
|
||||
Future<void> increaseStock(String productId, int amount) {
|
||||
return database.increaseStock(productId, amount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,52 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/payment_method.dart';
|
||||
import '../models/remote_command.dart';
|
||||
import '../utils/app_config.dart';
|
||||
import 'bar_tab_service.dart';
|
||||
import 'kooltap_api_client.dart';
|
||||
|
||||
typedef _Credentials = ({String deviceId, String token});
|
||||
|
||||
typedef _CommandOutcome = ({CommandResult result, String? message});
|
||||
|
||||
class RemoteSyncService {
|
||||
final AppDatabase database;
|
||||
final BarTabService barTabService;
|
||||
final KoolTapApiClient apiClient;
|
||||
final FlutterSecureStorage secureStorage;
|
||||
|
||||
/// How long tab changes must be quiet before they are synced.
|
||||
final Duration changeDebounce;
|
||||
|
||||
/// The longest a tab change waits for a sync while changes keep coming.
|
||||
final Duration changeMaxDelay;
|
||||
|
||||
RemoteSyncService({
|
||||
required this.database,
|
||||
required this.barTabService,
|
||||
required this.apiClient,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs),
|
||||
this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs),
|
||||
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
static const _deviceIdKey = 'remote_device_id';
|
||||
static const _authTokenKey = 'remote_auth_token';
|
||||
static const _serverUrlKey = 'remote_server_url';
|
||||
static const _venueIdKey = 'remote_venue_id';
|
||||
static const _syncStateId = 'default';
|
||||
|
||||
static const _pollingInterval = Duration(
|
||||
seconds: kSyncPollingIntervalSeconds,
|
||||
);
|
||||
static const _maxBackoff = Duration(seconds: kSyncMaxBackoffSeconds);
|
||||
|
||||
final _eventController = StreamController<RemoteSyncEvent>.broadcast();
|
||||
|
||||
@@ -36,38 +55,52 @@ class RemoteSyncService {
|
||||
bool _isSyncing = false;
|
||||
bool get isSyncing => _isSyncing;
|
||||
|
||||
Timer? _syncTimer;
|
||||
int _consecutiveFailures = 0;
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
bool _paused = false;
|
||||
int _consecutiveFailures = 0;
|
||||
|
||||
Future<String?> get deviceId async =>
|
||||
await secureStorage.read(key: _deviceIdKey);
|
||||
StreamSubscription<List<BarTab>>? _tabChanges;
|
||||
Timer? _changeTimer;
|
||||
DateTime? _firstPendingChange;
|
||||
bool _syncQueued = false;
|
||||
|
||||
Future<String?> get authToken async =>
|
||||
await secureStorage.read(key: _authTokenKey);
|
||||
|
||||
Future<String?> get venueId async =>
|
||||
await secureStorage.read(key: _venueIdKey);
|
||||
Future<String?> get venueId => secureStorage.read(key: _venueIdKey);
|
||||
|
||||
Future<bool> get isConfigured async {
|
||||
final token = await authToken;
|
||||
final vid = await venueId;
|
||||
return token != null && token.isNotEmpty && vid != null && vid.isNotEmpty;
|
||||
final token = await secureStorage.read(key: _authTokenKey);
|
||||
final venueId = await this.venueId;
|
||||
return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
String get _baseUrl => kRemoteServerUrl;
|
||||
Future<_Credentials?> _credentials() async {
|
||||
final deviceId = await secureStorage.read(key: _deviceIdKey);
|
||||
final token = await secureStorage.read(key: _authTokenKey);
|
||||
if (deviceId == null || token == null) return null;
|
||||
|
||||
void startPeriodicSync({int intervalSeconds = 90}) {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer.periodic(
|
||||
Duration(seconds: intervalSeconds),
|
||||
(_) => _onTick(),
|
||||
);
|
||||
return (deviceId: deviceId, token: token);
|
||||
}
|
||||
|
||||
void startPeriodicSync() {
|
||||
_isRunning = true;
|
||||
// The first emission is the current state, not a change
|
||||
_tabChanges ??= barTabService
|
||||
.watchOpenTabs()
|
||||
.skip(1)
|
||||
.listen((_) => _onTabsChanged());
|
||||
_scheduleNext(_pollingInterval);
|
||||
}
|
||||
|
||||
void stopPeriodicSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = null;
|
||||
_isRunning = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_changeTimer?.cancel();
|
||||
_changeTimer = null;
|
||||
_firstPendingChange = null;
|
||||
_syncQueued = false;
|
||||
unawaited(_tabChanges?.cancel());
|
||||
_tabChanges = null;
|
||||
}
|
||||
|
||||
void pause() {
|
||||
@@ -76,321 +109,285 @@ class RemoteSyncService {
|
||||
|
||||
void resume() {
|
||||
_paused = false;
|
||||
// Catch up right away instead of waiting for the next poll
|
||||
_onTabsChanged();
|
||||
}
|
||||
|
||||
Future<void> _onTick() async {
|
||||
if (_isSyncing || _paused) return;
|
||||
await sync();
|
||||
/// Pushes tab changes to the server soon after they happen instead of
|
||||
/// waiting for the next poll. A burst of changes (several drinks rung up in
|
||||
/// a row) becomes one sync, sent once things are quiet for [changeDebounce]
|
||||
/// but never later than [changeMaxDelay] after the first change.
|
||||
void _onTabsChanged() {
|
||||
// While failing, the backoff timer decides when to retry
|
||||
if (!_isRunning || _paused || _consecutiveFailures > 0) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
_firstPendingChange ??= now;
|
||||
final untilMaxDelay = changeMaxDelay - now.difference(_firstPendingChange!);
|
||||
final delay = untilMaxDelay < changeDebounce
|
||||
? (untilMaxDelay.isNegative ? Duration.zero : untilMaxDelay)
|
||||
: changeDebounce;
|
||||
|
||||
_changeTimer?.cancel();
|
||||
_changeTimer = Timer(delay, () {
|
||||
_firstPendingChange = null;
|
||||
unawaited(sync());
|
||||
});
|
||||
}
|
||||
|
||||
/// Every sync schedules the next one, so a failure can stretch the delay
|
||||
/// without stopping the loop.
|
||||
void _scheduleNext(Duration delay) {
|
||||
_timer?.cancel();
|
||||
if (!_isRunning) return;
|
||||
|
||||
_timer = Timer(delay, () {
|
||||
if (_paused) {
|
||||
_scheduleNext(_pollingInterval);
|
||||
return;
|
||||
}
|
||||
unawaited(sync());
|
||||
});
|
||||
}
|
||||
|
||||
Duration _backoff() {
|
||||
final exponent = (_consecutiveFailures - 1).clamp(0, 3);
|
||||
final delay = _pollingInterval * (1 << exponent);
|
||||
return delay > _maxBackoff ? _maxBackoff : delay;
|
||||
}
|
||||
|
||||
Future<void> sync() async {
|
||||
if (_isSyncing) return;
|
||||
if (_isSyncing) {
|
||||
// Changes made during a sync must not wait for the next poll
|
||||
_syncQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final configured = await isConfigured;
|
||||
if (!configured) return;
|
||||
final credentials = await _credentials();
|
||||
if (credentials == null || !await isConfigured) return;
|
||||
|
||||
_isSyncing = true;
|
||||
Duration nextDelay;
|
||||
|
||||
try {
|
||||
await _doSync();
|
||||
await _doSync(credentials);
|
||||
_consecutiveFailures = 0;
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.syncCompleted,
|
||||
));
|
||||
nextDelay = _pollingInterval;
|
||||
_emit(RemoteSyncEventType.syncCompleted);
|
||||
} on UnauthorizedException {
|
||||
debugPrint('RemoteSyncService: token revoked');
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
stopPeriodicSync();
|
||||
_emit(RemoteSyncEventType.unauthorized);
|
||||
return;
|
||||
} on RateLimitedException catch (e) {
|
||||
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
|
||||
} catch (e, stack) {
|
||||
_consecutiveFailures++;
|
||||
debugPrint('RemoteSyncService: sync failed: $e');
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
_eventController.add(RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.syncFailed,
|
||||
message: e.toString(),
|
||||
));
|
||||
_applyBackoff();
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
nextDelay = await _recordFailure(e);
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
|
||||
_scheduleNext(nextDelay);
|
||||
|
||||
if (_syncQueued) {
|
||||
_syncQueued = false;
|
||||
if (_consecutiveFailures == 0) unawaited(sync());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doSync() async {
|
||||
final token = await authToken;
|
||||
if (token == null) return;
|
||||
Future<Duration> _recordFailure(Object error, {Duration? retryAfter}) async {
|
||||
debugPrint('RemoteSyncService: sync failed: $error');
|
||||
_consecutiveFailures++;
|
||||
await _updateSyncState(success: false);
|
||||
_emit(RemoteSyncEventType.syncFailed, message: error.toString());
|
||||
|
||||
final deviceId = await this.deviceId;
|
||||
if (deviceId == null) return;
|
||||
return retryAfter ?? _backoff();
|
||||
}
|
||||
|
||||
Future<void> _doSync(_Credentials credentials) async {
|
||||
final state = await _getSyncState();
|
||||
final openTabs = await _buildSnapshot();
|
||||
final openTabs = await barTabService.getOpenTabs();
|
||||
|
||||
final request = SyncRequest(
|
||||
deviceId: deviceId,
|
||||
cursor: state?.serverCursor,
|
||||
openTabs: openTabs,
|
||||
final response = await apiClient.sync(
|
||||
token: credentials.token,
|
||||
request: SyncRequest(
|
||||
deviceId: credentials.deviceId,
|
||||
cursor: state?.serverCursor,
|
||||
openTabs: openTabs.map(_toSnapshot).toList(),
|
||||
),
|
||||
);
|
||||
|
||||
final response = await _httpPost(
|
||||
'$_baseUrl/devices/$deviceId/sync',
|
||||
body: request.toJson(),
|
||||
token: token,
|
||||
);
|
||||
|
||||
if (response == null) return;
|
||||
|
||||
final syncResponse = SyncResponse.fromJson(response);
|
||||
|
||||
for (final command in syncResponse.commands) {
|
||||
await _processCommand(command);
|
||||
for (final command in response.commands) {
|
||||
await _processCommand(command, credentials);
|
||||
}
|
||||
|
||||
for (final name in syncResponse.plannedTabNames) {
|
||||
final hasOpenTab = openTabs.any((t) => t.customerName == name);
|
||||
if (!hasOpenTab) {
|
||||
await barTabService.createTab(customerName: name);
|
||||
}
|
||||
final openNames = openTabs.map((tab) => tab.customerName).toSet();
|
||||
for (final name in response.plannedTabNames) {
|
||||
if (name.trim().isEmpty || !openNames.add(name)) continue;
|
||||
await barTabService.createTab(customerName: name);
|
||||
}
|
||||
|
||||
if (syncResponse.cursor != null) {
|
||||
await _saveCursor(syncResponse.cursor!);
|
||||
}
|
||||
|
||||
await _updateSyncState(success: true);
|
||||
await _updateSyncState(success: true, cursor: response.cursor);
|
||||
}
|
||||
|
||||
Future<List<SnapshotTab>> _buildSnapshot() async {
|
||||
final openTabs = await barTabService.getOpenTabs();
|
||||
return openTabs.map((tab) => SnapshotTab(
|
||||
SnapshotTab _toSnapshot(BarTab tab) {
|
||||
return SnapshotTab(
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
)).toList();
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processCommand(RemoteCommand command) async {
|
||||
final alreadyProcessed = await _isCommandProcessed(command.id);
|
||||
if (alreadyProcessed) return;
|
||||
Future<void> _processCommand(
|
||||
RemoteCommand command,
|
||||
_Credentials credentials,
|
||||
) async {
|
||||
final processed = await _getProcessedCommand(command.id);
|
||||
if (processed != null) {
|
||||
// The server only returns pending commands, so the earlier ack never arrived: resend it
|
||||
await _ackCommand(credentials, command.id, processed.result);
|
||||
return;
|
||||
}
|
||||
|
||||
final outcome = await _executeCommand(command);
|
||||
|
||||
// Recorded before acking, so a failed ack never re-executes the command.
|
||||
await _recordProcessed(command, outcome.result);
|
||||
await _ackCommand(credentials, command.id, outcome.result, outcome.message);
|
||||
}
|
||||
|
||||
Future<_CommandOutcome> _executeCommand(RemoteCommand command) async {
|
||||
if (command.isExpired) {
|
||||
await _ackCommand(command.id, 'expired', 'Command expired');
|
||||
await _recordProcessed(command.id, command.type, 'expired');
|
||||
return;
|
||||
return (result: CommandResult.expired, message: 'Command expired');
|
||||
}
|
||||
|
||||
if (command.type != 'close_tab') {
|
||||
await _ackCommand(command.id, 'failed', 'Unknown command type: ${command.type}');
|
||||
await _recordProcessed(command.id, command.type, 'failed');
|
||||
return;
|
||||
}
|
||||
return switch (command.type) {
|
||||
CommandType.closeTab => _closeTab(command),
|
||||
null => (
|
||||
result: CommandResult.failed,
|
||||
message: 'Unknown command type: ${command.rawType}',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<_CommandOutcome> _closeTab(RemoteCommand command) async {
|
||||
try {
|
||||
final tab = await barTabService.getTabById(command.tabId);
|
||||
|
||||
if (tab == null) {
|
||||
await _ackCommand(command.id, 'conflict', 'Tab not found');
|
||||
await _recordProcessed(command.id, command.type, 'conflict');
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.conflict,
|
||||
message: 'Tab not found',
|
||||
));
|
||||
return;
|
||||
}
|
||||
await barTabService.closeTab(
|
||||
command.tabId,
|
||||
paymentMethod: PaymentMethod.fromValue(command.paymentMethod),
|
||||
expectedVersion: command.expectedVersion,
|
||||
);
|
||||
|
||||
if (!tab.isOpen) {
|
||||
await _ackCommand(command.id, 'conflict', 'Tab already closed');
|
||||
await _recordProcessed(command.id, command.type, 'conflict');
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.conflict,
|
||||
message: 'Tab already closed',
|
||||
));
|
||||
return;
|
||||
}
|
||||
_eventController.add(
|
||||
RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.tabClosedRemotely,
|
||||
tabCustomerName: tab?.customerName,
|
||||
requestedBy: command.requestedBy,
|
||||
),
|
||||
);
|
||||
|
||||
if (tab.version != command.expectedVersion) {
|
||||
await _ackCommand(command.id, 'conflict', 'Version mismatch');
|
||||
await _recordProcessed(command.id, command.type, 'conflict');
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.conflict,
|
||||
message: 'Tab was modified since the request',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
final method = PaymentMethod.fromValue(command.paymentMethod);
|
||||
await barTabService.closeTab(command.tabId, paymentMethod: method);
|
||||
|
||||
await _ackCommand(command.id, 'completed');
|
||||
await _recordProcessed(command.id, command.type, 'completed');
|
||||
|
||||
_eventController.add(RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.tabClosedRemotely,
|
||||
tabCustomerName: tab.customerName,
|
||||
requestedBy: command.requestedBy,
|
||||
));
|
||||
return (result: CommandResult.completed, message: null);
|
||||
} on TabNotFoundException {
|
||||
return _conflict('Tab not found');
|
||||
} on TabVersionConflictException {
|
||||
return _conflict('Tab was modified since the request');
|
||||
} catch (e, stack) {
|
||||
debugPrint('RemoteSyncService: command processing failed: $e');
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
await _ackCommand(command.id, 'failed', e.toString());
|
||||
await _recordProcessed(command.id, command.type, 'failed');
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
return (result: CommandResult.failed, message: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _httpPost(
|
||||
String url, {
|
||||
required Map<String, dynamic> body,
|
||||
required String token,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
_CommandOutcome _conflict(String message) {
|
||||
_emit(RemoteSyncEventType.conflict, message: message);
|
||||
return (result: CommandResult.conflict, message: message);
|
||||
}
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
debugPrint('RemoteSyncService: token revoked');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
final retryAfter = response.headers['retry-after'];
|
||||
if (retryAfter != null) {
|
||||
final seconds = int.tryParse(retryAfter) ?? 60;
|
||||
_applyBackoff(forcedDelay: seconds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
debugPrint('RemoteSyncService: HTTP ${response.statusCode}: ${response.body}');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.body.isEmpty) return {};
|
||||
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
void _emit(RemoteSyncEventType type, {String? message}) {
|
||||
_eventController.add(RemoteSyncEvent(type: type, message: message));
|
||||
}
|
||||
|
||||
Future<void> _ackCommand(
|
||||
_Credentials credentials,
|
||||
String commandId,
|
||||
String status, [
|
||||
CommandResult status, [
|
||||
String? result,
|
||||
]) async {
|
||||
final token = await authToken;
|
||||
final deviceId = await this.deviceId;
|
||||
if (token == null || deviceId == null) return;
|
||||
|
||||
try {
|
||||
final ack = CommandAck(commandId: commandId, status: status, result: result);
|
||||
await _httpPost(
|
||||
'$_baseUrl/devices/$deviceId/commands/$commandId/ack',
|
||||
body: ack.toJson(),
|
||||
token: token,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('RemoteSyncService: ack failed: $e');
|
||||
}
|
||||
]) {
|
||||
return apiClient.ackCommand(
|
||||
token: credentials.token,
|
||||
deviceId: credentials.deviceId,
|
||||
ack: CommandAck(commandId: commandId, status: status, result: result),
|
||||
);
|
||||
}
|
||||
|
||||
Future<RemoteSyncStateRow?> _getSyncState() async {
|
||||
final query = database.select(database.remoteSyncState)
|
||||
..where((row) => row.deviceId.equals('default'));
|
||||
return query.getSingleOrNull();
|
||||
Future<RemoteSyncStateRow?> _getSyncState() {
|
||||
return (database.select(
|
||||
database.remoteSyncState,
|
||||
)..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> _saveCursor(String cursor) async {
|
||||
Future<void> _updateSyncState({required bool success, String? cursor}) async {
|
||||
final now = DateTime.now();
|
||||
final existing = await _getSyncState();
|
||||
|
||||
await database
|
||||
.into(database.remoteSyncState)
|
||||
.insertOnConflictUpdate(
|
||||
RemoteSyncStateCompanion(
|
||||
deviceId: const Value('default'),
|
||||
serverCursor: Value(cursor),
|
||||
RemoteSyncStateCompanion.insert(
|
||||
deviceId: _syncStateId,
|
||||
serverCursor: Value(cursor ?? existing?.serverCursor),
|
||||
lastSuccessfulSyncAt: Value(
|
||||
success ? now : existing?.lastSuccessfulSyncAt,
|
||||
),
|
||||
lastAttemptedSyncAt: Value(now),
|
||||
consecutiveFailureCount: Value(
|
||||
success ? 0 : (existing?.consecutiveFailureCount ?? 0) + 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateSyncState({required bool success}) async {
|
||||
final now = DateTime.now();
|
||||
final existing = await _getSyncState();
|
||||
|
||||
if (existing == null) {
|
||||
await database.into(database.remoteSyncState).insert(
|
||||
RemoteSyncStateCompanion.insert(
|
||||
deviceId: 'default',
|
||||
lastSuccessfulSyncAt: Value(success ? now : null),
|
||||
lastAttemptedSyncAt: Value(now),
|
||||
consecutiveFailureCount: Value(success ? 0 : 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await (database.update(database.remoteSyncState)
|
||||
..where((row) => row.deviceId.equals('default')))
|
||||
.write(RemoteSyncStateCompanion(
|
||||
lastSuccessfulSyncAt: Value(success ? now : existing.lastSuccessfulSyncAt),
|
||||
lastAttemptedSyncAt: Value(now),
|
||||
consecutiveFailureCount: Value(
|
||||
success ? 0 : existing.consecutiveFailureCount + 1,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isCommandProcessed(String commandId) async {
|
||||
final query = database.select(database.processedRemoteCommands)
|
||||
..where((row) => row.commandId.equals(commandId));
|
||||
final result = await query.getSingleOrNull();
|
||||
return result != null;
|
||||
Future<ProcessedRemoteCommandRow?> _getProcessedCommand(String commandId) {
|
||||
return (database.select(
|
||||
database.processedRemoteCommands,
|
||||
)..where((row) => row.commandId.equals(commandId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> _recordProcessed(
|
||||
String commandId,
|
||||
String commandType,
|
||||
String result,
|
||||
RemoteCommand command,
|
||||
CommandResult result,
|
||||
) async {
|
||||
await database
|
||||
.into(database.processedRemoteCommands)
|
||||
.insertOnConflictUpdate(
|
||||
ProcessedRemoteCommandsCompanion.insert(
|
||||
commandId: commandId,
|
||||
commandType: commandType,
|
||||
commandId: command.id,
|
||||
commandType: command.rawType,
|
||||
processedAt: DateTime.now(),
|
||||
result: result,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyBackoff({int? forcedDelay}) {
|
||||
final delay = forcedDelay ?? _calculateBackoff();
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer(Duration(seconds: delay), () => _onTick());
|
||||
}
|
||||
|
||||
int _calculateBackoff() {
|
||||
if (_consecutiveFailures <= 1) return 90;
|
||||
final backoff = 90 * (1 << (_consecutiveFailures - 1).clamp(0, 3));
|
||||
return backoff.clamp(90, 600);
|
||||
}
|
||||
|
||||
Future<void> setCredentials({
|
||||
required String deviceId,
|
||||
required String token,
|
||||
required String venueId,
|
||||
String? serverUrl,
|
||||
}) async {
|
||||
await secureStorage.write(key: _deviceIdKey, value: deviceId);
|
||||
await secureStorage.write(key: _authTokenKey, value: token);
|
||||
await secureStorage.write(key: _venueIdKey, value: venueId);
|
||||
if (serverUrl != null) {
|
||||
await secureStorage.write(key: _serverUrlKey, value: serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
await secureStorage.delete(key: _deviceIdKey);
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
await secureStorage.delete(key: _serverUrlKey);
|
||||
await secureStorage.delete(key: _venueIdKey);
|
||||
}
|
||||
|
||||
@@ -399,20 +396,15 @@ class RemoteSyncService {
|
||||
return state?.lastSuccessfulSyncAt;
|
||||
}
|
||||
|
||||
Future<int> get lastFailureCount async {
|
||||
final state = await _getSyncState();
|
||||
return state?.consecutiveFailureCount ?? 0;
|
||||
}
|
||||
|
||||
Future<List<ProcessedRemoteCommandRow>> getRecentActivity() async {
|
||||
final query = database.select(database.processedRemoteCommands)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.processedAt)])
|
||||
..limit(50);
|
||||
return query.get();
|
||||
Future<List<ProcessedRemoteCommandRow>> getRecentActivity() {
|
||||
return (database.select(database.processedRemoteCommands)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.processedAt)])
|
||||
..limit(50))
|
||||
.get();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_syncTimer?.cancel();
|
||||
stopPeriodicSync();
|
||||
_eventController.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user