Files
Dennis Nemec dd7fe9a8eb 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>
2026-09-25 14:04:36 +02:00

408 lines
13 KiB
Dart

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