feat(zahlung): eigener Step "Zahlung" vor der Uebersicht
- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button "Zahlung abwickeln" (fest unten) - Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund) - Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung als veraltet markiert und muss erneut abgewickelt werden - Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl; Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode und Inkasso-Flag der gueltigen Zahlung - DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte Widgets; Begruendungs-Dialog geteilt mit Info-Step - API-Client aus aktueller Backend-Spec neu generiert - Tests fuer Zahlungsstatus und Modal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
|
||||
|
||||
/// Ausgelieferte Artikel einer Lieferung mit Menge, Stückpreis und
|
||||
/// Zeilensumme. Gemeinsam genutzt von Zahlungs-Step und Übersicht.
|
||||
///
|
||||
/// Menge = tatsächlich ausgeliefert (Soll − Gutschrift). Komplett
|
||||
/// gutgeschriebene Positionen erscheinen durchgestrichen.
|
||||
class DeliveredItemsCard extends StatelessWidget {
|
||||
const DeliveredItemsCard({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
|
||||
// Komponenten direkt darunter eingerückt erscheinen.
|
||||
final items = List<DeliveryItem>.of(delivery.items)..sort((a, b) {
|
||||
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
|
||||
if (byLine != 0) return byLine;
|
||||
final byParent = (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
|
||||
if (byParent != 0) return byParent;
|
||||
return (a.komponentenArtikelNr ?? '').compareTo(
|
||||
b.komponentenArtikelNr ?? '',
|
||||
);
|
||||
});
|
||||
if (items.isEmpty) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Keine Artikel hinterlegt.',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
_DeliveredRow(item: items[i], details: details),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeliveredRow extends StatelessWidget {
|
||||
const _DeliveredRow({required this.item, required this.details});
|
||||
|
||||
final DeliveryItem item;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final article = details.articleOf(item.articleId);
|
||||
// Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben
|
||||
// (status removed) ⇒ credited == required ⇒ delivered 0.
|
||||
final credited = item.scanProgress.creditedQuantity;
|
||||
final delivered = (item.requiredQuantity - credited).clamp(
|
||||
0,
|
||||
item.requiredQuantity,
|
||||
);
|
||||
|
||||
final Color avatarColor;
|
||||
if (delivered == 0) {
|
||||
avatarColor = Colors.red.shade400;
|
||||
} else if (delivered < item.requiredQuantity) {
|
||||
avatarColor = Colors.amber.shade700;
|
||||
} else {
|
||||
avatarColor = Colors.green.shade600;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: item.isComponent ? 40 : 16,
|
||||
right: 16,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: avatarColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
child: Text(
|
||||
'$delivered×',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (delivered < item.requiredQuantity)
|
||||
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
|
||||
else
|
||||
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
|
||||
'${formatEuro(item.unitPrice)} / Stück',
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
formatEuro(item.lineTotal),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
|
||||
|
||||
/// Betragsaufstellung einer Lieferung: Warenwert − Anzahlung − Gutschrift =
|
||||
/// offener Betrag. Gemeinsam genutzt von Zahlungs-Step und Übersicht; die
|
||||
/// Zahlen kommen aus [DeliveryPaymentStatus] (gleiche Formel wie Backend).
|
||||
class PaymentBreakdownCard extends StatelessWidget {
|
||||
const PaymentBreakdownCard({
|
||||
super.key,
|
||||
required this.status,
|
||||
required this.credit,
|
||||
});
|
||||
|
||||
final DeliveryPaymentStatus status;
|
||||
final DeliveryCredit? credit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_AmountRow(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
label: 'Warenwert',
|
||||
valueText: formatEuro(status.goodsValue),
|
||||
valueColor: theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AmountRow(
|
||||
icon: Icons.savings_outlined,
|
||||
label: 'Bei Bestellung bezahlt',
|
||||
valueText: '− ${formatEuro(status.prepaidAmount)}',
|
||||
valueColor:
|
||||
status.prepaidAmount > 0
|
||||
? Colors.green.shade700
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
if (credit != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_AmountRow(
|
||||
icon: Icons.card_giftcard_outlined,
|
||||
label: 'Gutschrift',
|
||||
valueText: '− ${formatEuroCents(credit!.amountCents)}',
|
||||
valueColor: Colors.amber.shade800,
|
||||
subtitle: credit!.reason,
|
||||
),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
_AmountRow(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: 'Offener Betrag',
|
||||
valueText: formatEuroCents(status.openCents),
|
||||
valueColor:
|
||||
status.hasOpenAmount
|
||||
? theme.colorScheme.primary
|
||||
: Colors.green.shade700,
|
||||
emphasize: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountRow extends StatelessWidget {
|
||||
const _AmountRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.valueText,
|
||||
required this.valueColor,
|
||||
this.subtitle,
|
||||
this.emphasize = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String valueText;
|
||||
final Color valueColor;
|
||||
final String? subtitle;
|
||||
|
||||
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
|
||||
final bool emphasize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(icon, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style:
|
||||
emphasize
|
||||
? const TextStyle(fontWeight: FontWeight.w700)
|
||||
: null,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
valueText,
|
||||
style: (emphasize
|
||||
? theme.textTheme.titleLarge
|
||||
: theme.textTheme.titleMedium)
|
||||
?.copyWith(fontWeight: FontWeight.w700, color: valueColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Icon je Zahlungsmethode anhand des stabilen Backend-Codes. Unbekannte
|
||||
/// Codes (neue Methoden aus den Stammdaten) bekommen ein neutrales Icon.
|
||||
IconData paymentMethodIcon(String code) => switch (code) {
|
||||
'cash' => Icons.payments_outlined,
|
||||
'ec_card' => Icons.credit_card,
|
||||
'invoice' => Icons.receipt_long_outlined,
|
||||
_ => Icons.account_balance_wallet_outlined,
|
||||
};
|
||||
@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_method_icon.dart';
|
||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||
|
||||
/// Status-Karte der Zahlungsabwicklung: erledigt, veraltet, noch offen oder
|
||||
/// nicht nötig. Gemeinsam genutzt von Zahlungs-Step und Übersicht.
|
||||
class PaymentRecordCard extends StatelessWidget {
|
||||
const PaymentRecordCard({super.key, required this.status});
|
||||
|
||||
final DeliveryPaymentStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final valid = status.validPayment;
|
||||
if (!status.hasOpenAmount) {
|
||||
return const _StatusCard(
|
||||
tone: _Tone.success,
|
||||
icon: Icons.verified_outlined,
|
||||
title: 'Bereits bezahlt',
|
||||
text:
|
||||
'Es ist kein Betrag mehr offen. Dieser Schritt kann '
|
||||
'übersprungen werden.',
|
||||
);
|
||||
}
|
||||
if (valid != null) {
|
||||
return _StatusCard(
|
||||
tone: _Tone.success,
|
||||
icon: paymentMethodIcon(valid.paymentMethodCode),
|
||||
title:
|
||||
valid.isCollectedOnSite
|
||||
? 'Zahlung erhalten'
|
||||
: 'Zahlung auf Rechnung bestätigt',
|
||||
text:
|
||||
'${formatEuroCents(valid.amountCents)} per '
|
||||
'${_methodName(context, valid)} · protokolliert um '
|
||||
'${DateFormat('HH:mm').format(valid.recordedAt)} Uhr',
|
||||
);
|
||||
}
|
||||
if (status.isStale) {
|
||||
return const _StatusCard(
|
||||
tone: _Tone.warning,
|
||||
icon: Icons.sync_problem_outlined,
|
||||
title: 'Betrag hat sich geändert',
|
||||
text:
|
||||
'Seit der letzten Zahlungsabwicklung hat sich der offene Betrag '
|
||||
'geändert (z. B. durch eine Gutschrift). Bitte die Zahlung erneut '
|
||||
'abwickeln.',
|
||||
);
|
||||
}
|
||||
return const _StatusCard(
|
||||
tone: _Tone.open,
|
||||
icon: Icons.pending_outlined,
|
||||
title: 'Zahlung noch nicht abgewickelt',
|
||||
text:
|
||||
'Bitte die Zahlung abwickeln, bevor es mit der Übersicht '
|
||||
'weitergeht.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Anzeigename aus den Stammdaten; Fallback auf den Code-Snapshot, falls
|
||||
/// die Methode inzwischen deaktiviert/umbenannt wurde.
|
||||
String _methodName(BuildContext context, DeliveryPayment payment) {
|
||||
final state = context.watch<PaymentMethodsCubit>().state;
|
||||
if (state is PaymentMethodsLoaded) {
|
||||
for (final m in state.methods) {
|
||||
if (m.id == payment.paymentMethodId) return m.name;
|
||||
}
|
||||
}
|
||||
return switch (payment.paymentMethodCode) {
|
||||
'cash' => 'Bar',
|
||||
'ec_card' => 'EC-Karte',
|
||||
'invoice' => 'Rechnung',
|
||||
final code => code,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum _Tone { success, warning, open }
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
const _StatusCard({
|
||||
required this.tone,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
final _Tone tone;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final Color color = switch (tone) {
|
||||
_Tone.success => Colors.green.shade700,
|
||||
_Tone.warning => Colors.orange.shade800,
|
||||
_Tone.open => theme.colorScheme.primary,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(text, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,407 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_method_icon.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.dart';
|
||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||
|
||||
/// Ergebnis des Zahlungs-Modals. `null` = abgebrochen, nichts passiert.
|
||||
sealed class PaymentSheetResult {
|
||||
const PaymentSheetResult();
|
||||
}
|
||||
|
||||
/// Der Fahrer hat bestätigt, dass die Zahlung per [method] erhalten wurde
|
||||
/// (bzw. bei „Rechnung": dass auf Rechnung abgewickelt wird).
|
||||
class PaymentConfirmed extends PaymentSheetResult {
|
||||
const PaymentConfirmed(this.method);
|
||||
final PaymentMethod method;
|
||||
}
|
||||
|
||||
/// Zahlung ist nicht möglich — die ganze Lieferung soll abgebrochen werden.
|
||||
class DeliveryCancelRequested extends PaymentSheetResult {
|
||||
const DeliveryCancelRequested(this.reason);
|
||||
final String reason;
|
||||
}
|
||||
|
||||
/// Öffnet das Zahlungs-Modal.
|
||||
///
|
||||
/// Zeigt groß den offenen Betrag, die Zahlungsmethoden als Cards (dynamisch
|
||||
/// aus den Backend-Stammdaten) und bei EC-Karte groß die Kundennummer, die
|
||||
/// der Fahrer am EC-Gerät eingibt. Schließt sich nur über „Abbrechen",
|
||||
/// „Zahlung erhalten" oder „Lieferung abbrechen".
|
||||
Future<PaymentSheetResult?> showPaymentSheet(
|
||||
BuildContext context, {
|
||||
required int openCents,
|
||||
required int customerNumber,
|
||||
required String? preselectedMethodId,
|
||||
}) {
|
||||
return showModalBottomSheet<PaymentSheetResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
// Kein versehentliches Wegwischen: Das Modal endet nur über die Buttons.
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
showDragHandle: false,
|
||||
builder:
|
||||
(_) => _PaymentSheet(
|
||||
openCents: openCents,
|
||||
customerNumber: customerNumber,
|
||||
preselectedMethodId: preselectedMethodId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PaymentSheet extends StatefulWidget {
|
||||
const _PaymentSheet({
|
||||
required this.openCents,
|
||||
required this.customerNumber,
|
||||
required this.preselectedMethodId,
|
||||
});
|
||||
|
||||
final int openCents;
|
||||
final int customerNumber;
|
||||
final String? preselectedMethodId;
|
||||
|
||||
@override
|
||||
State<_PaymentSheet> createState() => _PaymentSheetState();
|
||||
}
|
||||
|
||||
class _PaymentSheetState extends State<_PaymentSheet> {
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Vorauswahl = am Beleg hinterlegte Methode. Der Fahrer kann wechseln.
|
||||
_selectedId = widget.preselectedMethodId;
|
||||
}
|
||||
|
||||
Future<void> _cancelDelivery() async {
|
||||
final navigator = Navigator.of(context);
|
||||
final reason = await showReasonDialog(context, 'Lieferung abbrechen');
|
||||
if (reason == null || !mounted) return;
|
||||
navigator.pop(DeliveryCancelRequested(reason));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
|
||||
builder: (context, pmState) {
|
||||
final methods =
|
||||
pmState is PaymentMethodsLoaded
|
||||
? pmState.methods.where((m) => m.active).toList()
|
||||
: const <PaymentMethod>[];
|
||||
PaymentMethod? selected;
|
||||
for (final m in methods) {
|
||||
if (m.id == _selectedId) selected = m;
|
||||
}
|
||||
final isInvoice = selected?.code == 'invoice';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
children: [
|
||||
Text(
|
||||
'Zahlung abwickeln',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AmountBanner(openCents: widget.openCents),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Zahlungsart',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._methodSection(context, pmState, methods),
|
||||
if (selected != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_MethodHint(
|
||||
method: selected,
|
||||
customerNumber: widget.customerNumber,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(60),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
onPressed:
|
||||
selected == null
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
).pop(PaymentConfirmed(selected!)),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: Text(
|
||||
isInvoice
|
||||
? 'Auf Rechnung bestätigen'
|
||||
: 'Zahlung erhalten',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(color: theme.colorScheme.error),
|
||||
),
|
||||
onPressed: _cancelDelivery,
|
||||
icon: const Icon(Icons.block),
|
||||
label: const Text('Lieferung abbrechen'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _methodSection(
|
||||
BuildContext context,
|
||||
PaymentMethodsState state,
|
||||
List<PaymentMethod> methods,
|
||||
) {
|
||||
if (state is PaymentMethodsFailed) {
|
||||
return [
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: ListTile(
|
||||
title: Text(state.message),
|
||||
trailing: TextButton.icon(
|
||||
onPressed: () => context.read<PaymentMethodsCubit>().load(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut laden'),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (state is! PaymentMethodsLoaded) {
|
||||
return const [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (methods.isEmpty) {
|
||||
return const [Text('Keine Zahlungsmethoden hinterlegt.')];
|
||||
}
|
||||
return [
|
||||
for (final m in methods)
|
||||
_MethodCard(
|
||||
method: m,
|
||||
selected: m.id == _selectedId,
|
||||
onTap: () => setState(() => _selectedId = m.id),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Der zu zahlende Betrag, bewusst sehr groß.
|
||||
class _AmountBanner extends StatelessWidget {
|
||||
const _AmountBanner({required this.openCents});
|
||||
|
||||
final int openCents;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primary = theme.colorScheme.primary;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: primary.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Noch zu zahlen', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
formatEuroCents(openCents),
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MethodCard extends StatelessWidget {
|
||||
const _MethodCard({
|
||||
required this.method,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PaymentMethod method;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: selected ? 2 : 0,
|
||||
color: selected ? scheme.primaryContainer : scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(
|
||||
color: selected ? scheme.primary : scheme.outlineVariant,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
paymentMethodIcon(method.code),
|
||||
size: 30,
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
method.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
color: selected ? scheme.primary : scheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hinweis zur gewählten Methode. Bei EC-Karte groß die Kundennummer, die
|
||||
/// der Fahrer am EC-Gerät eingeben muss.
|
||||
class _MethodHint extends StatelessWidget {
|
||||
const _MethodHint({required this.method, required this.customerNumber});
|
||||
|
||||
final PaymentMethod method;
|
||||
final int customerNumber;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
if (method.code == 'ec_card') {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Kundennummer am EC-Gerät eingeben',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: SelectableText(
|
||||
'$customerNumber',
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 4,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final text = switch (method.code) {
|
||||
'cash' => 'Betrag bar entgegennehmen und danach bestätigen.',
|
||||
'invoice' =>
|
||||
'Der Betrag wird per Rechnung beglichen. Es wird vor Ort '
|
||||
'nichts kassiert.',
|
||||
_ => 'Zahlung abwickeln und danach bestätigen.',
|
||||
};
|
||||
return Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Fragt eine Pflicht-Begründung ab (z. B. für Pausieren/Abbrechen einer
|
||||
/// Lieferung). Liefert den getrimmten Text oder `null` bei Abbruch.
|
||||
Future<String?> showReasonDialog(BuildContext context, String title) async {
|
||||
// Controller bewusst nicht disposen: Der Dialog nutzt ihn noch während
|
||||
// der Schließ-Animation. Ohne Listener räumt der GC ihn ab.
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Grund',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(null),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
Navigator.of(dialogContext).pop(text);
|
||||
},
|
||||
child: const Text('Bestätigen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user