feat: add remote products and qr code for payconic
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user