feat: add remote products and qr code for payconic

This commit is contained in:
2026-10-22 00:10:26 +02:00
parent b15d94a805
commit 71af6f9ffe
31 changed files with 2053 additions and 179 deletions
+56 -6
View File
@@ -64,7 +64,12 @@ Future<void> confirmCloseTab(BuildContext context) async {
},
);
if (confirmed != true) return;
if (confirmed != true || !context.mounted) return;
if (paymentMethod == PaymentMethod.payconiq) {
final paid = await _showPayconiqQr(context, tab.totalInCents.asEuro);
if (paid != true || !context.mounted) return;
}
try {
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
@@ -76,17 +81,62 @@ Future<void> confirmCloseTab(BuildContext context) async {
}
}
const _payconiqQrAsset = 'assets/payconiq/qr.png';
/// Shows the Payconiq QR code for the customer to scan. Resolves to true
/// once the bartender taps Done; dismissing it leaves the tab open.
Future<bool?> _showPayconiqQr(BuildContext context, String amount) {
final l10n = AppLocalizations.of(context);
return showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(l10n.payconiq),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.scanToPay(amount), textAlign: TextAlign.center),
const SizedBox(height: 20),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
_payconiqQrAsset,
width: 280,
height: 280,
fit: BoxFit.contain,
errorBuilder: (context, _, _) => const SizedBox(
width: 280,
height: 280,
child: Icon(Icons.qr_code_2, size: 160),
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.done),
),
),
],
),
),
);
},
);
}
class _PaymentPicker extends StatelessWidget {
final PaymentMethod selected;
final ValueChanged<PaymentMethod> onChanged;
const _PaymentPicker({required this.selected, required this.onChanged});
static const _options = [
PaymentMethod.cash,
PaymentMethod.payconiq,
PaymentMethod.paymentPending,
];
static const _options = [PaymentMethod.cash, PaymentMethod.payconiq];
@override
Widget build(BuildContext context) {
+13 -14
View File
@@ -20,7 +20,8 @@ class PinEntryView extends StatefulWidget {
final Future<bool> Function(String)? onVerify;
final Future<bool> Function(String)? onSet;
final AppError? Function()? error;
final bool showBack;
/// Shows a close button that leaves the screen without entering a PIN.
final VoidCallback? onClose;
const PinEntryView({
super.key,
@@ -30,7 +31,7 @@ class PinEntryView extends StatefulWidget {
this.onVerify,
this.onSet,
this.error,
this.showBack = false,
this.onClose,
});
@override
@@ -185,18 +186,16 @@ class _PinEntryViewState extends State<PinEntryView> {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
if (widget.showBack) ...[
const Spacer(flex: 1),
Row(
children: [
IconButton(
onPressed: () => {},
icon: const Icon(Icons.close),
tooltip: 'Back',
),
],
),
],
Align(
alignment: Alignment.centerLeft,
child: widget.onClose != null
? IconButton(
onPressed: _isSubmitting ? null : widget.onClose,
icon: const Icon(Icons.close),
tooltip: l10n.close,
)
: const SizedBox(height: 48),
),
const Spacer(flex: 2),
Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary),
const SizedBox(height: 16),
+1 -14
View File
@@ -12,8 +12,6 @@ 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;
@@ -315,9 +313,6 @@ 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(
@@ -327,7 +322,7 @@ class _ProductFormViewState extends State<ProductFormView> {
onPressed: _leaveProductForm,
),
actions: [
if (widget.isEditing && !managedOnWebsite)
if (widget.isEditing)
IconButton(
onPressed: _delete,
icon: const Icon(Icons.delete_outline),
@@ -347,15 +342,10 @@ 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(),
@@ -376,7 +366,6 @@ class _ProductFormViewState extends State<ProductFormView> {
builder: (context, constraints) {
return DropdownMenu<String>(
width: constraints.maxWidth,
enabled: !managedOnWebsite,
initialSelection: _selectedCategory,
enableFilter: true,
enableSearch: true,
@@ -406,7 +395,6 @@ class _ProductFormViewState extends State<ProductFormView> {
const SizedBox(height: 16),
TextFormField(
controller: _priceController,
enabled: !managedOnWebsite,
decoration: InputDecoration(
labelText: l10n.price,
prefixText: '€ ',
@@ -467,7 +455,6 @@ class _ProductFormViewState extends State<ProductFormView> {
const SizedBox(height: 16),
TextFormField(
controller: _lowStockController,
enabled: !managedOnWebsite,
decoration: InputDecoration(
labelText: l10n.lowStockThreshold,
border: OutlineInputBorder(),
+8 -17
View File
@@ -9,8 +9,6 @@ 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});
@@ -33,8 +31,6 @@ 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(
@@ -45,13 +41,11 @@ class _ProductListViewState extends State<ProductListView> {
title: Text(l10n.products),
),
resizeToAvoidBottomInset: false,
floatingActionButton: managedOnWebsite
? null
: FloatingActionButton.extended(
onPressed: () => context.push('/products/new'),
icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/products/new'),
icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
),
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
@@ -62,19 +56,16 @@ class _ProductListViewState extends State<ProductListView> {
return Center(child: Text(l10n.errorText(viewModel.loadError)));
}
if (viewModel.products.isEmpty && !managedOnWebsite) {
if (viewModel.products.isEmpty) {
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 + offset,
itemCount: viewModel.products.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
if (index < offset) return const ManagedOnWebsiteNotice();
final product = viewModel.products[index - offset];
final product = viewModel.products[index];
return Card(
child: ListTile(
@@ -1,24 +0,0 @@
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),
),
),
);
}
}