Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_payment.dart
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

186 lines
6.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/delivered_items_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_breakdown_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_record_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_sheet.dart';
/// Step „Zahlung" — Zahlungsabwicklung („Abkassieren") vor der Übersicht.
///
/// Zeigt die ausgelieferten Artikel mit Menge und Preis, die Betrags-
/// aufstellung und den Zahlungsstatus. Über „Zahlung abwickeln" öffnet sich
/// das Zahlungs-Modal; die Bestätigung wird am Server protokolliert.
///
/// Ist nichts offen (bereits bezahlt), kann der Fahrer den Step
/// überspringen. Sonst gibt die Detail-Page „Weiter" erst frei, wenn eine
/// gültige Zahlung protokolliert ist (siehe `DeliveryPaymentStatus`).
class StepPayment extends StatelessWidget {
const StepPayment({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
final status = DeliveryPaymentStatus.of(delivery, details);
final canCollect =
delivery.state == DeliveryState.active && status.hasOpenAmount;
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
const _SectionHeader(text: 'Ausgelieferte Artikel'),
const SizedBox(height: 8),
DeliveredItemsCard(delivery: delivery, details: details),
const SizedBox(height: 24),
const _SectionHeader(text: 'Betrag'),
const SizedBox(height: 8),
PaymentBreakdownCard(
status: status,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 16),
PaymentRecordCard(status: status),
],
),
),
if (canCollect)
_CollectPanel(
status: status,
onCollect: () => _openPaymentSheet(context, status),
),
],
);
}
Future<void> _openPaymentSheet(
BuildContext context,
DeliveryPaymentStatus status,
) async {
final tourBloc = context.read<TourBloc>();
final actorCarId = _actorCarId(context);
final customer = details.customerOf(delivery);
final result = await showPaymentSheet(
context,
openCents: status.openCents,
customerNumber: customer?.erpCustomerId ?? 0,
// Vorauswahl: zuletzt abgewickelte Methode, sonst die vom Beleg.
preselectedMethodId:
status.latestPayment?.paymentMethodId ?? delivery.paymentMethodId,
);
switch (result) {
case PaymentConfirmed(:final method):
tourBloc.add(
RecordDeliveryPayment(
deliveryId: delivery.id,
paymentMethodId: method.id,
expectedAmountCents: status.openCents,
actorCarId: actorCarId,
),
);
case DeliveryCancelRequested(:final reason):
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
case null:
break;
}
}
String? _actorCarId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
return state is CarSelectComplete ? state.selectedCar.id : null;
}
}
/// Fester Bereich unten: offener Betrag + großer „Zahlung abwickeln"-Button.
/// Bleibt sichtbar, auch wenn die Artikelliste lang ist.
class _CollectPanel extends StatelessWidget {
const _CollectPanel({required this.status, required this.onCollect});
final DeliveryPaymentStatus status;
final VoidCallback onCollect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settled = status.validPayment != null;
return Material(
elevation: 6,
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('Offener Betrag', style: theme.textTheme.titleMedium),
const Spacer(),
Text(
formatEuroCents(status.openCents),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 10),
if (settled)
// Bereits abgewickelt: Korrektur möglich, aber nicht mehr im
// Vordergrund — „Weiter" ist jetzt der nächste Schritt.
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
onPressed: onCollect,
icon: const Icon(Icons.edit_outlined),
label: const Text('Zahlung korrigieren'),
)
else
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(68),
textStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
onPressed: onCollect,
icon: const Icon(Icons.point_of_sale, size: 28),
label: const Text('Zahlung abwickeln'),
),
],
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
);
}
}