feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+178
View File
@@ -1,4 +1,5 @@
import '../utils/app_config.dart';
import 'server_tab.dart';
enum CommandType {
closeTab('close_tab');
@@ -74,6 +75,88 @@ class RejectedEvent {
}
}
enum StockAdjustmentType {
/// `stock += quantity`; quantity may be negative (breakage, own use).
add('add'),
/// `stock = quantity`, a stock count. Starts tracking an untracked product.
set('set');
final String value;
const StockAdjustmentType(this.value);
static StockAdjustmentType? fromValue(String value) {
for (final type in StockAdjustmentType.values) {
if (type.value == value) return type;
}
return null;
}
}
/// A stock change made on the dashboard. The server resends it on every sync
/// until it has received the outcome, so it must be applied only once.
class StockAdjustment {
final String id;
final String productId;
final String rawType;
final int quantity;
final String? note;
final String requestedBy;
final DateTime createdAt;
const StockAdjustment({
required this.id,
required this.productId,
required this.rawType,
required this.quantity,
this.note,
required this.requestedBy,
required this.createdAt,
});
factory StockAdjustment.fromJson(Map<String, dynamic> json) {
return StockAdjustment(
id: json['id'] as String,
productId: json['productId'] as String,
rawType: json['type'] as String,
quantity: json['quantity'] as int,
note: json['note'] as String?,
requestedBy: json['requestedBy'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Null for a type this app version doesn't know; such an adjustment is
/// neither applied nor reported.
StockAdjustmentType? get type => StockAdjustmentType.fromValue(rawType);
}
/// The server's stock of one product, after applying the events it
/// acknowledged. The server owns the stock once it sends these.
class ServerStock {
final String productId;
/// Null when the stock of this product is not tracked. May be negative.
final int? stock;
/// When the stock was last counted (set). Line events that happened before
/// it are already in the count and don't change the stock.
final DateTime? countedAt;
const ServerStock({required this.productId, this.stock, this.countedAt});
factory ServerStock.fromJson(Map<String, dynamic> json) {
final countedAt = json['countedAt'] as String?;
return ServerStock(
productId: json['productId'] as String,
stock: json['stock'] as int?,
countedAt: countedAt == null ? null : DateTime.parse(countedAt),
);
}
}
class SyncResponse {
final List<RemoteCommand> commands;
final String? cursor;
@@ -81,12 +164,38 @@ class SyncResponse {
final List<String> acknowledgedEventIds;
final List<RejectedEvent> rejectedEvents;
/// Oldest first.
final List<StockAdjustment> stockAdjustments;
final List<String> acknowledgedStockAdjustmentResultIds;
final List<RejectedEvent> rejectedStockAdjustmentResults;
/// The venue's open tabs after applying the acknowledged events: the
/// source of truth. Null from a server that doesn't hold the tabs yet.
final List<ServerTab>? tabs;
/// Settlements made anywhere in the venue since [settlementsCursor] was
/// last sent, oldest first.
final List<ServerSettlement> settlements;
final String? settlementsCursor;
/// The stock of the venue's products: the source of truth. Null from a
/// server that doesn't own the stock yet; the tablet then counts its own
/// sales and applies [stockAdjustments] itself.
final List<ServerStock>? stock;
const SyncResponse({
required this.commands,
this.cursor,
this.plannedTabNames = const [],
this.acknowledgedEventIds = const [],
this.rejectedEvents = const [],
this.stockAdjustments = const [],
this.acknowledgedStockAdjustmentResultIds = const [],
this.rejectedStockAdjustmentResults = const [],
this.tabs,
this.settlements = const [],
this.settlementsCursor,
this.stock,
});
factory SyncResponse.fromJson(Map<String, dynamic> json) {
@@ -108,8 +217,61 @@ class SyncResponse {
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(event as Map<String, dynamic>),
],
stockAdjustments: [
for (final adjustment
in json['stockAdjustments'] as List<dynamic>? ?? [])
?_parseStockAdjustment(adjustment),
],
acknowledgedStockAdjustmentResultIds: [
for (final id
in json['acknowledgedStockAdjustmentResultIds'] as List<dynamic>? ??
[])
id as String,
],
rejectedStockAdjustmentResults: [
for (final result
in json['rejectedStockAdjustmentResults'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(result as Map<String, dynamic>),
],
tabs: switch (json['tabs']) {
final List<dynamic> tabs => [
for (final tab in tabs)
ServerTab.fromJson(tab as Map<String, dynamic>),
],
_ => null,
},
settlements: [
for (final settlement in json['settlements'] as List<dynamic>? ?? [])
ServerSettlement.fromJson(settlement as Map<String, dynamic>),
],
settlementsCursor: json['settlementsCursor'] as String?,
stock: switch (json['stock']) {
final List<dynamic> stock => [
for (final entry in stock) ?_parseServerStock(entry),
],
_ => null,
},
);
}
/// A malformed entry only leaves that product's local stock as it is.
static ServerStock? _parseServerStock(Object? json) {
try {
return ServerStock.fromJson(json! as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// A malformed adjustment is skipped instead of failing every sync from
/// now on, since the server keeps sending it.
static StockAdjustment? _parseStockAdjustment(Object? json) {
try {
return StockAdjustment.fromJson(json! as Map<String, dynamic>);
} catch (_) {
return null;
}
}
}
/// One line on a tab as shown in the dashboard, e.g. "10× Cola".
@@ -166,11 +328,24 @@ class SyncRequest {
/// Outbox tab events, oldest first, as their stored JSON objects.
final List<Map<String, dynamic>> events;
/// The complete product catalog with stock: a product left out is removed
/// from the dashboard.
final List<Map<String, dynamic>> products;
/// Unacknowledged stock adjustment outcomes, oldest first.
final List<Map<String, dynamic>> stockAdjustmentResults;
/// Where the settlement feed left off; null asks for the recent ones.
final String? settlementsCursor;
const SyncRequest({
required this.deviceId,
this.cursor,
required this.openTabs,
this.events = const [],
this.products = const [],
this.stockAdjustmentResults = const [],
this.settlementsCursor,
});
Map<String, dynamic> toJson() => {
@@ -178,6 +353,9 @@ class SyncRequest {
if (cursor != null) 'cursor': cursor,
'openTabs': openTabs.map((tab) => tab.toJson()).toList(),
'events': events,
'products': products,
'stockAdjustmentResults': stockAdjustmentResults,
if (settlementsCursor != null) 'settlementsCursor': settlementsCursor,
};
}