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
+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),
),
),
);
}
}