feat: make server own products

This commit is contained in:
2026-10-21 12:10:42 +02:00
parent 93acfe15a9
commit b15d94a805
21 changed files with 1717 additions and 106 deletions
+73
View File
@@ -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)),
);
}
});
}
}
+5
View File
@@ -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}",
+30
View File
@@ -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:
+18
View File
@@ -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';
+18
View File
@@ -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';
+5
View File
@@ -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}",
+51
View File
@@ -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,
});
}
+3 -1
View File
@@ -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,
+97 -66
View File
@@ -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)));
+73 -7
View File
@@ -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;
+43
View File
@@ -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,
+14 -1
View File
@@ -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(),
+17 -8
View File
@@ -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),
),
),
);
}
}