feat: make server own products
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../models/remote_command.dart';
|
||||
import 'app_database.dart';
|
||||
|
||||
/// The product catalog as the website manages it.
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
Future<void> applyServerCatalog(List<ServerProduct> catalog) {
|
||||
return transaction(() async {
|
||||
final local = {
|
||||
for (final row in await select(products).get()) row.id: row,
|
||||
};
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.name == product.name &&
|
||||
existing.category == category &&
|
||||
existing.priceInCents == product.priceInCents &&
|
||||
existing.lowStockThreshold == lowStockThreshold &&
|
||||
existing.active == !product.archived) {
|
||||
continue;
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"barTabs": "Bar Tabs",
|
||||
"products": "Products",
|
||||
"addProduct": "Add product",
|
||||
"productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.",
|
||||
"noProductsYet": "No products yet.",
|
||||
"addFirstProduct": "Add your first product to start selling.",
|
||||
"manageProducts": "Manage products",
|
||||
@@ -276,6 +277,10 @@
|
||||
"simulatedUpdateNotes": "Bug fixes and performance improvements.",
|
||||
"remoteActivity": "Remote activity",
|
||||
"remoteActivityHistory": "Recent remote activity",
|
||||
"offline": "Offline",
|
||||
"offlineDescription": "No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.",
|
||||
"syncProblem": "Sync problem",
|
||||
"syncProblemDescription": "Changes from the website don't reach this tablet. Error:",
|
||||
"syncNow": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"lastSync": "Last sync: {time}",
|
||||
|
||||
@@ -122,6 +122,12 @@ abstract class AppLocalizations {
|
||||
/// **'Add product'**
|
||||
String get addProduct;
|
||||
|
||||
/// No description provided for @productsManagedOnWebsite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Products are managed on the website. Here you can change the stock and the picture.'**
|
||||
String get productsManagedOnWebsite;
|
||||
|
||||
/// No description provided for @noProductsYet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1640,6 +1646,30 @@ abstract class AppLocalizations {
|
||||
/// **'Recent remote activity'**
|
||||
String get remoteActivityHistory;
|
||||
|
||||
/// No description provided for @offline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline'**
|
||||
String get offline;
|
||||
|
||||
/// No description provided for @offlineDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.'**
|
||||
String get offlineDescription;
|
||||
|
||||
/// No description provided for @syncProblem.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync problem'**
|
||||
String get syncProblem;
|
||||
|
||||
/// No description provided for @syncProblemDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Changes from the website don\'t reach this tablet. Error:'**
|
||||
String get syncProblemDescription;
|
||||
|
||||
/// No description provided for @syncNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -20,6 +20,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get addProduct => 'Add product';
|
||||
|
||||
@override
|
||||
String get productsManagedOnWebsite =>
|
||||
'Products are managed on the website. Here you can change the stock and the picture.';
|
||||
|
||||
@override
|
||||
String get noProductsYet => 'No products yet.';
|
||||
|
||||
@@ -846,6 +850,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get remoteActivityHistory => 'Recent remote activity';
|
||||
|
||||
@override
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get offlineDescription =>
|
||||
'No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.';
|
||||
|
||||
@override
|
||||
String get syncProblem => 'Sync problem';
|
||||
|
||||
@override
|
||||
String get syncProblemDescription =>
|
||||
'Changes from the website don\'t reach this tablet. Error:';
|
||||
|
||||
@override
|
||||
String get syncNow => 'Sync now';
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get addProduct => 'Product toevoegen';
|
||||
|
||||
@override
|
||||
String get productsManagedOnWebsite =>
|
||||
'Producten beheer je op de website. Hier pas je de voorraad en de foto aan.';
|
||||
|
||||
@override
|
||||
String get noProductsYet => 'Nog geen producten.';
|
||||
|
||||
@@ -858,6 +862,20 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get remoteActivityHistory => 'Recente externe activiteit';
|
||||
|
||||
@override
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get offlineDescription =>
|
||||
'Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.';
|
||||
|
||||
@override
|
||||
String get syncProblem => 'Synchronisatieprobleem';
|
||||
|
||||
@override
|
||||
String get syncProblemDescription =>
|
||||
'Wijzigingen van de website komen niet op deze tablet aan. Fout:';
|
||||
|
||||
@override
|
||||
String get syncNow => 'Nu synchroniseren';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"barTabs": "Poefs",
|
||||
"products": "Producten",
|
||||
"addProduct": "Product toevoegen",
|
||||
"productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.",
|
||||
"noProductsYet": "Nog geen producten.",
|
||||
"addFirstProduct": "Voeg je eerste product toe om te beginnen.",
|
||||
"manageProducts": "Producten beheren",
|
||||
@@ -276,6 +277,10 @@
|
||||
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.",
|
||||
"remoteActivity": "Externe activiteit",
|
||||
"remoteActivityHistory": "Recente externe activiteit",
|
||||
"offline": "Offline",
|
||||
"offlineDescription": "Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.",
|
||||
"syncProblem": "Synchronisatieprobleem",
|
||||
"syncProblemDescription": "Wijzigingen van de website komen niet op deze tablet aan. Fout:",
|
||||
"syncNow": "Nu synchroniseren",
|
||||
"syncing": "Bezig met synchroniseren…",
|
||||
"lastSync": "Laatste sync: {time}",
|
||||
|
||||
@@ -157,6 +157,40 @@ class ServerStock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A product as the website manages it.
|
||||
class ServerProduct {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? category;
|
||||
final int priceInCents;
|
||||
|
||||
/// Null: no low stock warning.
|
||||
final int? lowStockThreshold;
|
||||
|
||||
/// Deleted on the website: hidden, kept for open tabs and the history.
|
||||
final bool archived;
|
||||
|
||||
const ServerProduct({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.category,
|
||||
required this.priceInCents,
|
||||
this.lowStockThreshold,
|
||||
this.archived = false,
|
||||
});
|
||||
|
||||
factory ServerProduct.fromJson(Map<String, dynamic> json) {
|
||||
return ServerProduct(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
category: json['category'] as String?,
|
||||
priceInCents: json['priceInCents'] as int,
|
||||
lowStockThreshold: json['lowStockThreshold'] as int?,
|
||||
archived: json['archived'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncResponse {
|
||||
final List<RemoteCommand> commands;
|
||||
final String? cursor;
|
||||
@@ -183,6 +217,10 @@ class SyncResponse {
|
||||
/// sales and applies [stockAdjustments] itself.
|
||||
final List<ServerStock>? stock;
|
||||
|
||||
/// Every product of the venue, managed on the website. Null from a server
|
||||
/// that doesn't manage the catalog; the tablet then manages its own.
|
||||
final List<ServerProduct>? catalog;
|
||||
|
||||
const SyncResponse({
|
||||
required this.commands,
|
||||
this.cursor,
|
||||
@@ -196,6 +234,7 @@ class SyncResponse {
|
||||
this.settlements = const [],
|
||||
this.settlementsCursor,
|
||||
this.stock,
|
||||
this.catalog,
|
||||
});
|
||||
|
||||
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||
@@ -251,6 +290,13 @@ class SyncResponse {
|
||||
],
|
||||
_ => null,
|
||||
},
|
||||
catalog: switch (json['catalog']) {
|
||||
final List<dynamic> catalog => [
|
||||
for (final product in catalog)
|
||||
ServerProduct.fromJson(product as Map<String, dynamic>),
|
||||
],
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,10 +437,15 @@ class RemoteSyncEvent {
|
||||
final String? requestedBy;
|
||||
final String? message;
|
||||
|
||||
/// A failed sync that only lacked a connection: the bar keeps working and
|
||||
/// everything is sent once it is back.
|
||||
final bool offline;
|
||||
|
||||
const RemoteSyncEvent({
|
||||
required this.type,
|
||||
this.tabCustomerName,
|
||||
this.requestedBy,
|
||||
this.message,
|
||||
this.offline = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ class TabEventFactory {
|
||||
customerName: customerName,
|
||||
fields: {
|
||||
'lineId': lineId,
|
||||
'productId': productId,
|
||||
// Lines taken over from a tablet's own tabs have no product: the
|
||||
// server then matches them by name and price, and refuses an empty id
|
||||
if (productId.isNotEmpty) 'productId': productId,
|
||||
'productName': productName,
|
||||
'unitPriceInCents': unitPriceInCents,
|
||||
'quantity': quantity,
|
||||
|
||||
@@ -461,68 +461,87 @@ class DriftBarTabService implements BarTabService {
|
||||
final tabRow = await _getTabRow(tabId);
|
||||
if (tabRow == null) throw TabNotFoundException(tabId);
|
||||
|
||||
// A merge on the server can leave two lines of one product at
|
||||
// different prices; new drinks go on the oldest
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabId) &
|
||||
item.productId.equals(product.id),
|
||||
)
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (existingItem != null) {
|
||||
await (database.update(
|
||||
database.tabItems,
|
||||
)..where((item) => item.id.equals(existingItem.id))).write(
|
||||
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
||||
);
|
||||
await _recordPurchase(existingItem.id);
|
||||
// The line keeps the price it was first added at
|
||||
await _recordLineChange(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: existingItem.id,
|
||||
productId: existingItem.productId,
|
||||
productName: existingItem.productName,
|
||||
unitPriceInCents: existingItem.unitPriceInCents,
|
||||
delta: 1,
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
await _recordLineChange(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: tabItemId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
delta: 1,
|
||||
);
|
||||
}
|
||||
|
||||
await _addDrinks(
|
||||
tabRow,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
quantity: 1,
|
||||
);
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Puts [quantity] drinks on the tab, charged [unitPriceInCents]: on the
|
||||
/// line of that product, name and price, or a new line when there is none.
|
||||
/// Every new drink goes through here, so it is always charged the current
|
||||
/// price: after a price or name change on the website the drinks already
|
||||
/// on the tab keep what they were rung up at, and new ones start a new
|
||||
/// line. Of several matching lines (a merge on the server) the oldest gets
|
||||
/// them.
|
||||
Future<void> _addDrinks(
|
||||
BarTabRow tabRow, {
|
||||
required String productId,
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabRow.id) &
|
||||
item.productId.equals(productId) &
|
||||
item.unitPriceInCents.equals(unitPriceInCents) &
|
||||
item.productName.equals(productName),
|
||||
)
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
final String lineId;
|
||||
if (existingItem != null) {
|
||||
lineId = existingItem.id;
|
||||
await (database.update(
|
||||
database.tabItems,
|
||||
)..where((item) => item.id.equals(lineId))).write(
|
||||
TabItemsCompanion(quantity: Value(existingItem.quantity + quantity)),
|
||||
);
|
||||
} else {
|
||||
lineId = _uuid.v4();
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: lineId,
|
||||
tabId: tabRow.id,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
quantity: quantity,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (var index = 0; index < quantity; index++) {
|
||||
await _recordPurchase(lineId);
|
||||
}
|
||||
await _recordLineChange(
|
||||
tabId: tabRow.id,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
delta: quantity,
|
||||
);
|
||||
}
|
||||
|
||||
/// Taking drinks off changes [tabItemId] itself. Adding is ringing up new
|
||||
/// drinks of its product, at the current price: on this line while its
|
||||
/// price and name are still current, else on the line at the current price
|
||||
/// (see [_addDrinks]). Returns how many were added (positive) or taken off.
|
||||
@override
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
@@ -537,19 +556,31 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
if (item == null) return 0;
|
||||
|
||||
if (delta > 0) {
|
||||
final tabRow = await _getTabRow(item.tabId);
|
||||
if (tabRow == null) return 0;
|
||||
|
||||
// Hidden products still count: the drink is on the tab already
|
||||
final product = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(item.productId))).getSingleOrNull();
|
||||
await _addDrinks(
|
||||
tabRow,
|
||||
productId: item.productId,
|
||||
productName: product?.name ?? item.productName,
|
||||
unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents,
|
||||
quantity: delta,
|
||||
);
|
||||
await _incrementTabVersion(item.tabId);
|
||||
return delta;
|
||||
}
|
||||
|
||||
final newQuantity = max(item.quantity + delta, 0);
|
||||
final actualDelta = newQuantity - item.quantity;
|
||||
|
||||
if (actualDelta == 0) return 0;
|
||||
|
||||
if (actualDelta > 0) {
|
||||
for (var index = 0; index < actualDelta; index++) {
|
||||
await _recordPurchase(item.id);
|
||||
}
|
||||
} else {
|
||||
await _removeLatestPurchases(item.id, -actualDelta);
|
||||
}
|
||||
|
||||
await _removeLatestPurchases(item.id, -actualDelta);
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/catalog_queries.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/payment_method.dart';
|
||||
@@ -19,6 +20,16 @@ import 'kooltap_api_client.dart';
|
||||
|
||||
typedef _Credentials = ({String deviceId, String token});
|
||||
|
||||
/// Parts of a sync response the tablet could not apply; the rest was applied.
|
||||
class SyncApplyException implements Exception {
|
||||
final List<String> failures;
|
||||
|
||||
const SyncApplyException(this.failures);
|
||||
|
||||
@override
|
||||
String toString() => failures.join('\n');
|
||||
}
|
||||
|
||||
typedef _CommandOutcome = ({CommandResult result, String? message});
|
||||
|
||||
class RemoteSyncService {
|
||||
@@ -225,7 +236,8 @@ class RemoteSyncService {
|
||||
} on RateLimitedException catch (e) {
|
||||
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
|
||||
} catch (e, stack) {
|
||||
if (!_isConnectivityError(e)) {
|
||||
// A failed part was reported when it failed
|
||||
if (!_isConnectivityError(e) && e is! SyncApplyException) {
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
}
|
||||
nextDelay = await _recordFailure(e);
|
||||
@@ -252,7 +264,13 @@ class RemoteSyncService {
|
||||
debugPrint('RemoteSyncService: sync failed: $error');
|
||||
_consecutiveFailures++;
|
||||
await _updateSyncState(success: false);
|
||||
_emit(RemoteSyncEventType.syncFailed, message: error.toString());
|
||||
_eventController.add(
|
||||
RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.syncFailed,
|
||||
message: error.toString(),
|
||||
offline: _isConnectivityError(error),
|
||||
),
|
||||
);
|
||||
|
||||
return retryAfter ?? _backoff();
|
||||
}
|
||||
@@ -293,18 +311,46 @@ class RemoteSyncService {
|
||||
|
||||
await _clearSentEvents(response);
|
||||
await _markStockResultsReceived(response);
|
||||
await barTabService.recordSettlements(response.settlements);
|
||||
|
||||
// Each part is applied on its own: one that fails (a tab or settlement
|
||||
// the tablet can't store) must not keep prices and stock from arriving
|
||||
final failures = <String>[];
|
||||
final settlementsApplied = await _applyPart(
|
||||
'settlements',
|
||||
failures,
|
||||
() => barTabService.recordSettlements(response.settlements),
|
||||
);
|
||||
|
||||
final serverTabs = response.tabs;
|
||||
if (serverTabs != null) {
|
||||
// After the acked events left the outbox: what is left still has to
|
||||
// reach the server and is replayed on top
|
||||
await barTabService.applyServerState(serverTabs);
|
||||
await _applyPart(
|
||||
'tabs',
|
||||
failures,
|
||||
() => barTabService.applyServerState(serverTabs),
|
||||
);
|
||||
}
|
||||
|
||||
// The website manages the products; first, so new ones get their stock
|
||||
final catalog = response.catalog;
|
||||
if (catalog != null) {
|
||||
await _applyPart(
|
||||
'products',
|
||||
failures,
|
||||
() => database.applyServerCatalog(catalog),
|
||||
);
|
||||
}
|
||||
|
||||
// Same rebase for the stock: the server counts every tablet's sales
|
||||
final serverStock = response.stock;
|
||||
if (serverStock != null) await database.rebaseStock(serverStock);
|
||||
if (serverStock != null) {
|
||||
await _applyPart(
|
||||
'stock',
|
||||
failures,
|
||||
() => database.rebaseStock(serverStock),
|
||||
);
|
||||
}
|
||||
|
||||
for (final command in response.commands) {
|
||||
await _processCommand(command, credentials);
|
||||
@@ -329,15 +375,35 @@ class RemoteSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements that weren't stored are asked for again next time
|
||||
await _updateSyncState(
|
||||
success: true,
|
||||
success: failures.isEmpty,
|
||||
cursor: response.cursor,
|
||||
settlementsCursor: response.settlementsCursor,
|
||||
settlementsCursor: settlementsApplied ? response.settlementsCursor : null,
|
||||
);
|
||||
if (failures.isNotEmpty) throw SyncApplyException(failures);
|
||||
|
||||
return appliedStock;
|
||||
}
|
||||
|
||||
/// Runs one part of applying a sync response. Returns whether it worked; a
|
||||
/// failure is reported and added to [failures] instead of thrown.
|
||||
Future<bool> _applyPart(
|
||||
String part,
|
||||
List<String> failures,
|
||||
Future<void> Function() apply,
|
||||
) async {
|
||||
try {
|
||||
await apply();
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
debugPrint('RemoteSyncService: applying $part failed: $e');
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
failures.add('$part: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TabEventRow>> _pendingEvents() {
|
||||
return (database.select(database.tabEvents)
|
||||
..orderBy([
|
||||
|
||||
@@ -35,6 +35,11 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
String? _lastError;
|
||||
String? get lastError => _lastError;
|
||||
|
||||
bool _isOffline = false;
|
||||
|
||||
/// The last sync failed for lack of a connection, not because of an error.
|
||||
bool get isOffline => _isOffline;
|
||||
|
||||
String? _venueId;
|
||||
String? get venueId => _venueId;
|
||||
|
||||
@@ -109,8 +114,10 @@ class RemoteSyncViewModel extends ChangeNotifier {
|
||||
case RemoteSyncEventType.syncCompleted:
|
||||
_lastSyncAt = DateTime.now();
|
||||
_lastError = null;
|
||||
_isOffline = false;
|
||||
case RemoteSyncEventType.syncFailed:
|
||||
_lastError = event.message;
|
||||
_isOffline = event.offline;
|
||||
case RemoteSyncEventType.unauthorized:
|
||||
_isConfigured = false;
|
||||
_wasRevoked = true;
|
||||
|
||||
@@ -90,6 +90,23 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showSyncProblem({required String title, required String description}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: SelectableText(description),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(l10n.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _logout() {
|
||||
context.read<PinLockViewModel>().lock();
|
||||
context.read<AdminPinViewModel>().lock();
|
||||
@@ -142,6 +159,32 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
icon: const Icon(Icons.cloud_off_rounded),
|
||||
),
|
||||
],
|
||||
// A failing sync is otherwise invisible: the bar keeps working
|
||||
// offline while nothing from the website arrives
|
||||
if (context.watch<RemoteSyncViewModel>().lastError
|
||||
case final error?) ...[
|
||||
const SizedBox(width: 6),
|
||||
// Offline is normal for a bar; anything else needs attention
|
||||
if (context.watch<RemoteSyncViewModel>().isOffline)
|
||||
IconButton(
|
||||
tooltip: l10n.offline,
|
||||
onPressed: () => _showSyncProblem(
|
||||
title: l10n.offline,
|
||||
description: l10n.offlineDescription,
|
||||
),
|
||||
icon: const Icon(Icons.wifi_off_rounded),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: '${l10n.syncProblem}: $error',
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: () => _showSyncProblem(
|
||||
title: l10n.syncProblem,
|
||||
description: '${l10n.syncProblemDescription}\n\n$error',
|
||||
),
|
||||
icon: const Icon(Icons.sync_problem_rounded),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.refresh,
|
||||
|
||||
@@ -12,6 +12,8 @@ import '../models/product.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'widgets/managed_on_website_notice.dart';
|
||||
|
||||
class ProductFormView extends StatefulWidget {
|
||||
final String? productId;
|
||||
@@ -313,6 +315,9 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final title = widget.isEditing ? l10n.editProduct : l10n.addProduct;
|
||||
// A paired tablet gets its products from the website: only the stock and
|
||||
// the picture are changed here
|
||||
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -322,7 +327,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
onPressed: _leaveProductForm,
|
||||
),
|
||||
actions: [
|
||||
if (widget.isEditing)
|
||||
if (widget.isEditing && !managedOnWebsite)
|
||||
IconButton(
|
||||
onPressed: _delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
@@ -342,10 +347,15 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
if (managedOnWebsite) ...[
|
||||
const ManagedOnWebsiteNotice(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildImagePicker(context),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
enabled: !managedOnWebsite,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.productName,
|
||||
border: OutlineInputBorder(),
|
||||
@@ -366,6 +376,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
builder: (context, constraints) {
|
||||
return DropdownMenu<String>(
|
||||
width: constraints.maxWidth,
|
||||
enabled: !managedOnWebsite,
|
||||
initialSelection: _selectedCategory,
|
||||
enableFilter: true,
|
||||
enableSearch: true,
|
||||
@@ -395,6 +406,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _priceController,
|
||||
enabled: !managedOnWebsite,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.price,
|
||||
prefixText: '€ ',
|
||||
@@ -455,6 +467,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _lowStockController,
|
||||
enabled: !managedOnWebsite,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.lowStockThreshold,
|
||||
border: OutlineInputBorder(),
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../l10n/app_localizations_helpers.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'widgets/managed_on_website_notice.dart';
|
||||
|
||||
class ProductListView extends StatefulWidget {
|
||||
const ProductListView({super.key});
|
||||
@@ -31,6 +33,8 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
Widget build(BuildContext context) {
|
||||
final viewModel = context.watch<ProductListViewModel>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// A paired tablet gets its products from the website
|
||||
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -41,11 +45,13 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
title: Text(l10n.products),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/products/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l10n.addProduct),
|
||||
),
|
||||
floatingActionButton: managedOnWebsite
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/products/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l10n.addProduct),
|
||||
),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
if (viewModel.isLoading) {
|
||||
@@ -56,16 +62,19 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
return Center(child: Text(l10n.errorText(viewModel.loadError)));
|
||||
}
|
||||
|
||||
if (viewModel.products.isEmpty) {
|
||||
if (viewModel.products.isEmpty && !managedOnWebsite) {
|
||||
return Center(child: Text(l10n.noProductsYet));
|
||||
}
|
||||
|
||||
// The notice takes the first row
|
||||
final offset = managedOnWebsite ? 1 : 0;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: viewModel.products.length,
|
||||
itemCount: viewModel.products.length + offset,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final product = viewModel.products[index];
|
||||
if (index < offset) return const ManagedOnWebsiteNotice();
|
||||
final product = viewModel.products[index - offset];
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// Tells that the products of a paired tablet are managed on the website.
|
||||
class ManagedOnWebsiteNotice extends StatelessWidget {
|
||||
const ManagedOnWebsiteNotice({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
color: colors.secondaryContainer,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.language, color: colors.onSecondaryContainer),
|
||||
title: Text(
|
||||
AppLocalizations.of(context).productsManagedOnWebsite,
|
||||
style: TextStyle(color: colors.onSecondaryContainer),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user