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:
Dennis Nemec
2026-09-25 14:04:36 +02:00
parent f832b69b9d
commit dd7fe9a8eb
78 changed files with 7466 additions and 874 deletions

View File

@ -6,6 +6,7 @@ import 'package:hl_lieferservice/domain/entity/contact_source.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
@ -335,6 +336,11 @@ extension ApiTourDetailsMapper on api.TourDetails {
for (final c in credits) c.deliveryId: c.toDomain(),
};
// Zahlungen: höchstens eine pro Lieferung (jüngster Eintrag).
final paymentsMap = <String, DeliveryPayment>{
for (final p in payments) p.deliveryId: p.toDomain(),
};
// Service-Definitionen (aktiv, sortiert) + Pro-Lieferung-Werte indizieren.
final servicesList =
services.map((s) => s.toDomain()).toList(growable: false);
@ -367,6 +373,7 @@ extension ApiTourDetailsMapper on api.TourDetails {
warehouses: warehousesMap,
notesByDeliveryId: notesGrouped,
creditsByDeliveryId: creditsMap,
paymentsByDeliveryId: paymentsMap,
services: servicesList,
serviceValuesByDeliveryId: serviceValues,
contactSourcesByDeliveryId: sourcesGrouped,
@ -406,3 +413,14 @@ extension ApiDeliveryCreditMapper on api.DeliveryCredit {
reason: reason,
);
}
extension ApiDeliveryPaymentMapper on api.DeliveryPayment {
DeliveryPayment toDomain() => DeliveryPayment(
id: id,
deliveryId: deliveryId,
paymentMethodId: paymentMethodId,
paymentMethodCode: paymentMethodCode,
amountCents: amountCents,
recordedAt: recordedAt.toLocal(),
);
}

View File

@ -7,6 +7,7 @@ import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
import 'package:hl_lieferservice/domain/entity/address.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
@ -435,6 +436,36 @@ class TourRepositoryImpl implements TourRepository {
}
}
@override
Future<DeliveryPayment> recordDeliveryPayment({
required String deliveryId,
required String clientEventId,
required String paymentMethodId,
required int expectedAmountCents,
String? actorCarId,
}) async {
try {
final request = api.RecordDeliveryPaymentRequest((b) {
b
..clientEventId = clientEventId
..paymentMethodId = paymentMethodId
..expectedAmountCents = expectedAmountCents;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().recordPayment(
deliveryId: deliveryId,
recordDeliveryPaymentRequest: request,
);
final payment = response.data?.payment;
if (payment == null) {
throw TourRepositoryException('Zahlung protokollieren: leere Antwort');
}
return payment.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Zahlung protokollieren'), e);
}
}
@override
Future<DeliveryServiceValue> setDeliveryService({
required String deliveryId,

View File

@ -0,0 +1,32 @@
/// Protokollierte Zahlungsabwicklung („Abkassieren") einer Lieferung —
/// spiegelt das Backend-Aggregat `DeliveryPayment`.
///
/// Das Backend liefert pro Lieferung nur den jüngsten Eintrag. Der Betrag ist
/// der offene Betrag zum Zeitpunkt der Bestätigung. Ändert sich der offene
/// Betrag danach (z. B. neue Gutschrift), ist der Eintrag veraltet — siehe
/// `DeliveryPaymentStatus`.
class DeliveryPayment {
const DeliveryPayment({
required this.id,
required this.deliveryId,
required this.paymentMethodId,
required this.paymentMethodCode,
required this.amountCents,
required this.recordedAt,
});
final String id;
final String deliveryId;
final String paymentMethodId;
/// Snapshot des Methoden-Codes (`cash`, `ec_card`, `invoice`, …).
final String paymentMethodCode;
/// Abgewickelter Betrag in Cent.
final int amountCents;
final DateTime recordedAt;
/// Bar/EC = vor Ort kassiert. „Auf Rechnung" bleibt offen.
bool get isCollectedOnSite =>
paymentMethodCode == 'cash' || paymentMethodCode == 'ec_card';
}

View File

@ -3,6 +3,7 @@ import 'contact_source.dart';
import 'customer.dart';
import 'delivery.dart';
import 'delivery_credit.dart';
import 'delivery_payment.dart';
import 'delivery_item.dart';
import 'delivery_note.dart';
import 'delivery_service_value.dart';
@ -28,6 +29,7 @@ class TourDetails {
required this.warehouses,
required this.notesByDeliveryId,
required this.creditsByDeliveryId,
required this.paymentsByDeliveryId,
required this.services,
required this.serviceValuesByDeliveryId,
required this.contactSourcesByDeliveryId,
@ -56,6 +58,10 @@ class TourDetails {
/// der Eintrag, gibt es aktuell keine Gutschrift.
final Map<String, DeliveryCredit> creditsByDeliveryId;
/// Pro Lieferung die jüngste protokollierte Zahlungsabwicklung. Fehlt der
/// Eintrag, wurde für diese Lieferung noch keine Zahlung abgewickelt.
final Map<String, DeliveryPayment> paymentsByDeliveryId;
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
/// rendert Phase 4 die Auswahl.
final List<Service> services;
@ -200,6 +206,10 @@ class TourDetails {
DeliveryCredit? creditOf(String deliveryId) =>
creditsByDeliveryId[deliveryId];
/// Jüngste protokollierte Zahlung dieser Lieferung, oder `null`.
DeliveryPayment? paymentOf(String deliveryId) =>
paymentsByDeliveryId[deliveryId];
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
serviceValuesByDeliveryId[deliveryId]?[serviceId];
@ -398,6 +408,7 @@ class TourDetails {
List<Delivery>? deliveries,
Map<String, List<DeliveryNote>>? notesByDeliveryId,
Map<String, DeliveryCredit>? creditsByDeliveryId,
Map<String, DeliveryPayment>? paymentsByDeliveryId,
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
}) {
return TourDetails(
@ -409,6 +420,7 @@ class TourDetails {
warehouses: warehouses,
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
paymentsByDeliveryId: paymentsByDeliveryId ?? this.paymentsByDeliveryId,
services: services,
serviceValuesByDeliveryId:
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,

View File

@ -1,5 +1,6 @@
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
@ -166,6 +167,18 @@ abstract interface class TourRepository {
String? actorCarId,
});
/// Protokolliert die Zahlungsabwicklung („Abkassieren") einer Lieferung.
/// Append-only + idempotent über [clientEventId]. [expectedAmountCents] ist
/// der Betrag, den der Fahrer gesehen hat; weicht der Server-Betrag ab,
/// lehnt das Backend ab. Rückgabe: der protokollierte Eintrag.
Future<DeliveryPayment> recordDeliveryPayment({
required String deliveryId,
required String clientEventId,
required String paymentMethodId,
required int expectedAmountCents,
String? actorCarId,
});
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
/// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert.
Future<DeliveryServiceValue> setDeliveryService({

View File

@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
@ -50,6 +51,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
on<UploadDeliveryNoteImage>(_onUploadDeliveryNoteImage);
on<SetDeliveryCredit>(_onSetDeliveryCredit);
on<RemoveDeliveryCredit>(_onRemoveDeliveryCredit);
on<RecordDeliveryPayment>(_onRecordDeliveryPayment);
on<SetDeliveryServiceValue>(_onSetDeliveryServiceValue);
on<RemoveDeliveryServiceValue>(_onRemoveDeliveryServiceValue);
}
@ -613,6 +615,45 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
// ─── Zahlungsabwicklung ──────────────────────────────────────────────
/// Protokolliert die Zahlung. Bewusst NICHT optimistisch: Der Server
/// berechnet den Betrag autoritativ und kann ablehnen (z. B. Betrag hat
/// sich zwischenzeitlich geändert). Erst die Server-Antwort gibt den
/// Zahlungs-Step frei.
Future<void> _onRecordDeliveryPayment(
RecordDeliveryPayment event,
Emitter<TourState> emit,
) async {
if (state is! TourLoaded) return;
opBloc.add(StartOperation(message: 'Zahlung wird protokolliert …'));
try {
final payment = await tourRepository.recordDeliveryPayment(
deliveryId: event.deliveryId,
clientEventId: _uuid.v4(),
paymentMethodId: event.paymentMethodId,
expectedAmountCents: event.expectedAmountCents,
actorCarId: event.actorCarId,
);
final latest = state;
if (latest is TourLoaded) {
final next = Map<String, DeliveryPayment>.from(
latest.details.paymentsByDeliveryId,
)..[event.deliveryId] = payment;
emit(latest.copyWith(
details: latest.details.copyWith(paymentsByDeliveryId: next),
));
}
opBloc.add(FinishOperation(message: 'Zahlung protokolliert'));
} catch (e, st) {
debugPrint('TourBloc.RecordDeliveryPayment fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Zahlung konnte nicht protokolliert werden'),
));
}
}
// ─── Betrags-Gutschrift ──────────────────────────────────────────────
/// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt).

View File

@ -274,6 +274,22 @@ class RemoveDeliveryCredit extends TourEvent {
final String actorCarId;
}
/// Protokolliert die Zahlungsabwicklung („Abkassieren") einer Lieferung.
/// [expectedAmountCents] ist der offene Betrag, den der Fahrer bestätigt hat.
class RecordDeliveryPayment extends TourEvent {
const RecordDeliveryPayment({
required this.deliveryId,
required this.paymentMethodId,
required this.expectedAmountCents,
required this.actorCarId,
});
final String deliveryId;
final String paymentMethodId;
final int expectedAmountCents;
final String? actorCarId;
}
/// Setzt/ändert den Wert eines Service für eine Lieferung (Checkbox/Zahl).
class SetDeliveryServiceValue extends TourEvent {
const SetDeliveryServiceValue({

View File

@ -39,8 +39,5 @@ class DeliveryWorkflowBloc
final next = [...state.pendingImageNotes]..removeAt(e.index);
emit(state.copyWith(pendingImageNotes: next));
});
on<WorkflowOverridePaymentMethod>((e, emit) => emit(
state.copyWith(paymentMethodOverrideId: e.paymentMethodId),
));
}
}

View File

@ -34,10 +34,3 @@ class WorkflowRemovePendingImage extends DeliveryWorkflowEvent {
const WorkflowRemovePendingImage(this.index);
final int index;
}
// ─── Payment-Auswahl ────────────────────────────────────────────────────
class WorkflowOverridePaymentMethod extends DeliveryWorkflowEvent {
const WorkflowOverridePaymentMethod({required this.paymentMethodId});
final String? paymentMethodId;
}

View File

@ -1,11 +1,12 @@
import 'package:image_picker/image_picker.dart';
/// Die 5 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index.
/// Die 6 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index.
enum WorkflowStep {
info,
notes,
articles,
services,
payment,
summary,
}
@ -15,6 +16,7 @@ extension WorkflowStepX on WorkflowStep {
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel & Gutschriften',
WorkflowStep.services => 'Checkliste',
WorkflowStep.payment => 'Zahlung',
WorkflowStep.summary => 'Übersicht',
};
@ -24,6 +26,7 @@ extension WorkflowStepX on WorkflowStep {
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel',
WorkflowStep.services => 'Checkliste',
WorkflowStep.payment => 'Zahlung',
WorkflowStep.summary => 'Übersicht',
};
}
@ -38,8 +41,9 @@ class PendingImageNote {
final DateTime pickedAt;
}
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel,
/// die Drafts und die Payment-Auswahl liegen alle hier. So sieht jede
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel und
/// die Drafts liegen hier. Die Zahlung ist KEIN lokaler Draft mehr: Sie wird
/// im Step „Zahlung" am Server protokolliert und kommt über die Tour zurück. So sieht jede
/// Step-Page denselben kohärenten Zustand und ein Step kann Daten aus
/// einem anderen lesen (z. B. Summary liest Article-Drafts).
class DeliveryWorkflowState {
@ -47,7 +51,6 @@ class DeliveryWorkflowState {
required this.deliveryId,
required this.step,
required this.pendingImageNotes,
required this.paymentMethodOverrideId,
});
factory DeliveryWorkflowState.initial(String deliveryId) =>
@ -55,7 +58,6 @@ class DeliveryWorkflowState {
deliveryId: deliveryId,
step: WorkflowStep.info,
pendingImageNotes: const [],
paymentMethodOverrideId: null,
);
final String deliveryId;
@ -64,24 +66,14 @@ class DeliveryWorkflowState {
/// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist.
final List<PendingImageNote> pendingImageNotes;
/// Wenn der Fahrer im Summary die Zahlungsmethode überschreibt, landet
/// die neue Id hier. `null` = Methode der Lieferung bleibt.
final String? paymentMethodOverrideId;
DeliveryWorkflowState copyWith({
WorkflowStep? step,
List<PendingImageNote>? pendingImageNotes,
Object? paymentMethodOverrideId = _sentinel,
}) {
return DeliveryWorkflowState(
deliveryId: deliveryId,
step: step ?? this.step,
pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes,
paymentMethodOverrideId: identical(paymentMethodOverrideId, _sentinel)
? this.paymentMethodOverrideId
: paymentMethodOverrideId as String?,
);
}
}
const Object _sentinel = Object();

View File

@ -0,0 +1,80 @@
import 'package:intl/intl.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Zahlungsstand einer Lieferung — einzige Quelle für offenen Betrag und
/// „ist die Zahlung erledigt?". Genutzt von Zahlungs-Step, Übersicht und
/// Abschluss, damit alle exakt dieselben Zahlen sehen.
///
/// Offener Betrag = Warenwert (Σ Stückpreis × ausgelieferte Menge) −
/// Anzahlung − Gutschrift, nie negativ. Identische Formel und Rundung wie
/// im Backend (`open_amount_cents`), das den Betrag beim Protokollieren
/// gegenprüft.
class DeliveryPaymentStatus {
const DeliveryPaymentStatus._({
required this.goodsValue,
required this.prepaidAmount,
required this.creditAmount,
required this.openCents,
required this.latestPayment,
});
factory DeliveryPaymentStatus.of(Delivery delivery, TourDetails details) {
final goods = delivery.items.fold<double>(
0,
(acc, item) => acc + item.lineTotal,
);
final credit = (details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0;
final open = goods - delivery.prepaidAmount - credit;
return DeliveryPaymentStatus._(
goodsValue: goods,
prepaidAmount: delivery.prepaidAmount,
creditAmount: credit,
openCents: open <= 0 ? 0 : (open * 100).round(),
latestPayment: details.paymentOf(delivery.id),
);
}
/// Warenwert in Euro.
final double goodsValue;
/// Bei Bestellung bezahlt (Anzahlung) in Euro.
final double prepaidAmount;
/// Aktuelle Geld-Gutschrift in Euro (0 = keine).
final double creditAmount;
/// Offener Betrag in Cent.
final int openCents;
/// Jüngste protokollierte Zahlung, egal ob noch gültig.
final DeliveryPayment? latestPayment;
double get openAmount => openCents / 100.0;
bool get hasOpenAmount => openCents > 0;
/// Die jüngste Zahlung, sofern sie zum aktuellen offenen Betrag passt.
DeliveryPayment? get validPayment {
final p = latestPayment;
if (p == null || !hasOpenAmount || p.amountCents != openCents) return null;
return p;
}
/// Es wurde abgewickelt, aber der offene Betrag hat sich seitdem geändert.
bool get isStale =>
latestPayment != null && hasOpenAmount && validPayment == null;
/// Zahlungs-Step erledigt: nichts offen oder gültig abgewickelt.
bool get isSettled => !hasOpenAmount || validPayment != null;
}
final NumberFormat _euro = NumberFormat.currency(locale: 'de_DE', symbol: '€');
/// Betrag in Euro im deutschen Format, z. B. `1.234,50 €`.
String formatEuro(double amount) => _euro.format(amount);
/// Cent-Betrag im deutschen Format, z. B. `1.234,50 €`.
String formatEuroCents(int cents) => _euro.format(cents / 100.0);

View File

@ -2,19 +2,19 @@ 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/payment_method.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_articles.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_notes.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_payment.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_services.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
@ -32,10 +32,10 @@ import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step
// entgegennimmt und den Key zurückgibt, dann hier
// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen.
//
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT: Die im
// Summary gewählte Methode (`paymentMethodOverrideId` im Workflow-State)
// reist beim Abschluss am `/complete`-Endpoint mit und wird atomar auf
// der Lieferung persistiert (Server prüft existiert + aktiv).
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT und abgelöst:
// Die Zahlung wird im eigenen Step „Zahlung" abgewickelt und am Server
// protokolliert (`POST /deliveries/{id}/payment`). Beim Abschluss reist
// die Methode der gültigen Zahlung mit.
//
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
// hochladen + auf der Lieferung speichern. Backend hat dafür weder
@ -51,7 +51,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step
// SnackBar-Stub.
// ─────────────────────────────────────────────────────────────────────────
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 5 Steps;
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 6 Steps;
/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props,
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
///
@ -95,7 +95,8 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
void initState() {
super.initState();
final s = context.read<TourBloc>().state;
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
if (s is TourLoaded &&
_findDelivery(s.details)?.state == DeliveryState.active) {
_armed = true;
}
}
@ -153,7 +154,7 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
),
body: Column(
children: [
const _StepHeader(),
_StepHeader(delivery: delivery, details: details),
const Divider(height: 1),
Expanded(
child: _StepBody(delivery: delivery, details: details),
@ -171,8 +172,23 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
// ─── Step-Header (Pills) ────────────────────────────────────────────────
/// Steps hinter „Zahlung" sind gesperrt, solange bei einer aktiven
/// Lieferung ein offener Betrag noch nicht gültig abgewickelt ist.
bool _lockedByPayment(
WorkflowStep step,
Delivery delivery,
TourDetails details,
) {
if (step.index <= WorkflowStep.payment.index) return false;
if (delivery.state != DeliveryState.active) return false;
return !DeliveryPaymentStatus.of(delivery, details).isSettled;
}
class _StepHeader extends StatelessWidget {
const _StepHeader();
const _StepHeader({required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
@ -189,9 +205,16 @@ class _StepHeader extends StatelessWidget {
step: WorkflowStep.values[i],
isActive: state.step.index == i,
isPassed: state.step.index > i,
onTap: () => context
.read<DeliveryWorkflowBloc>()
.add(WorkflowGoToStep(WorkflowStep.values[i])),
enabled:
!_lockedByPayment(
WorkflowStep.values[i],
delivery,
details,
),
onTap:
() => context.read<DeliveryWorkflowBloc>().add(
WorkflowGoToStep(WorkflowStep.values[i]),
),
),
),
if (i < WorkflowStep.values.length - 1)
@ -211,6 +234,7 @@ class _StepPill extends StatelessWidget {
required this.step,
required this.isActive,
required this.isPassed,
required this.enabled,
required this.onTap,
});
@ -218,6 +242,9 @@ class _StepPill extends StatelessWidget {
final WorkflowStep step;
final bool isActive;
final bool isPassed;
/// `false` = Step gesperrt (z. B. Übersicht vor abgewickelter Zahlung).
final bool enabled;
final VoidCallback onTap;
@override
@ -248,53 +275,58 @@ class _StepPill extends StatelessWidget {
}
return InkWell(
onTap: onTap,
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: circleColor,
shape: BoxShape.circle,
border: isActive
? Border.all(color: primary, width: 2)
: null,
child: Opacity(
opacity: enabled ? 1 : 0.4,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: circleColor,
shape: BoxShape.circle,
border:
isActive
? Border.all(color: primary, width: 2)
: null,
),
child: Center(
child:
isPassed && !isActive
? Icon(Icons.check, color: circleFg, size: 16)
: Text(
'${index + 1}',
style: TextStyle(
color: circleFg,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
),
child: Center(
child: isPassed && !isActive
? Icon(Icons.check, color: circleFg, size: 16)
: Text(
'${index + 1}',
style: TextStyle(
color: circleFg,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
),
],
),
const SizedBox(height: 4),
Text(
step.shortName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: labelColor,
fontSize: 11,
fontWeight: labelWeight,
],
),
),
],
const SizedBox(height: 4),
Text(
step.shortName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: labelColor,
fontSize: 11,
fontWeight: labelWeight,
),
),
],
),
),
),
);
@ -313,9 +345,10 @@ class _StepConnector extends StatelessWidget {
child: Container(
width: 10,
height: 2,
color: isPassed
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
color:
isPassed
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
),
);
}
@ -343,6 +376,8 @@ class _StepBody extends StatelessWidget {
return StepArticles(delivery: delivery, details: details);
case WorkflowStep.services:
return StepServices(delivery: delivery, details: details);
case WorkflowStep.payment:
return StepPayment(delivery: delivery, details: details);
case WorkflowStep.summary:
return StepSummary(delivery: delivery, details: details);
}
@ -365,62 +400,33 @@ class _BottomNav extends StatelessWidget {
/// dann den `completed`-Status zeigt.
void _onSign(BuildContext context) {
final tourBloc = context.read<TourBloc>();
// Die im Summary-Step gewählte Zahlungsmethode lebt im Workflow-State.
// Beim Abschluss reisen wir sie mit ans Backend (atomar mit der Signatur);
// `null` = die am Beleg hinterlegte Methode bleibt.
final paymentMethodOverrideId =
context.read<DeliveryWorkflowBloc>().state.paymentMethodOverrideId;
// Offener Betrag = Warenwert − Anzahlung − Gutschrift (≥ 0). EXAKT die
// Formel aus StepSummary und dem Backend-Inkasso-Gate.
final creditEuros =
(details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0;
final warenwert =
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
final open = (warenwert - delivery.prepaidAmount - creditEuros)
.clamp(0.0, double.infinity)
.toDouble();
// Effektive Methode (Override > Beleg) auflösen, um Vor-Ort-Inkasso
// (Bar/EC) von „Auf Rechnung" zu unterscheiden.
final effectiveMethodId =
paymentMethodOverrideId ?? delivery.paymentMethodId;
final pmState = context.read<PaymentMethodsCubit>().state;
PaymentMethod? method;
if (pmState is PaymentMethodsLoaded) {
for (final m in pmState.methods) {
if (m.id == effectiveMethodId) {
method = m;
break;
}
}
}
// Inkasso-Pflicht: offener Betrag > 0 UND Bar/EC. „Auf Rechnung" → nein.
final requiresCollection =
open > 0 && (method?.code == 'cash' || method?.code == 'ec_card');
// Kassiert wurde bereits im Step „Zahlung" (serverseitig protokolliert).
// Beim Abschluss reisen Methode und Inkasso-Flag der gültigen Zahlung
// mit; ohne offenen Betrag bleibt die Methode vom Beleg.
final payment = DeliveryPaymentStatus.of(delivery, details).validPayment;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (routeContext) => SignatureView(
delivery: delivery,
details: details,
requiresCollection: requiresCollection,
openAmount: open,
paymentMethodLabel: method?.name ?? '',
onSigned: (result) {
tourBloc.add(CompleteDelivery(
deliveryId: delivery.id,
customerSignaturePng: result.customerSignaturePng,
driverSignaturePng: result.driverSignaturePng,
receiptConfirmed: result.receiptConfirmed,
notesAcknowledged: result.notesAcknowledged,
acknowledgedNoteIds: result.acknowledgedNoteIds,
paymentMethodId: paymentMethodOverrideId,
paymentCollected: result.paymentCollected,
));
Navigator.of(routeContext).pop();
},
),
builder:
(routeContext) => SignatureView(
delivery: delivery,
details: details,
onSigned: (result) {
tourBloc.add(
CompleteDelivery(
deliveryId: delivery.id,
customerSignaturePng: result.customerSignaturePng,
driverSignaturePng: result.driverSignaturePng,
receiptConfirmed: result.receiptConfirmed,
notesAcknowledged: result.notesAcknowledged,
acknowledgedNoteIds: result.acknowledgedNoteIds,
paymentMethodId: payment?.paymentMethodId,
paymentCollected: payment?.isCollectedOnSite ?? false,
),
);
Navigator.of(routeContext).pop();
},
),
),
);
}
@ -438,11 +444,12 @@ class _BottomNav extends StatelessWidget {
child: Row(
children: [
OutlinedButton.icon(
onPressed: isFirst
? null
: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowPreviousStep()),
onPressed:
isFirst
? null
: () => context.read<DeliveryWorkflowBloc>().add(
const WorkflowPreviousStep(),
),
icon: const Icon(Icons.arrow_back),
label: const Text('Zurück'),
),
@ -451,26 +458,37 @@ class _BottomNav extends StatelessWidget {
// Unterschreiben/Abschließen nur bei aktiver Lieferung.
// Ist sie bereits abgeschlossen (oder pausiert/abgebrochen),
// bleibt der Button gesperrt.
Builder(builder: (context) {
final isActive =
delivery.state == DeliveryState.active;
final isCompleted =
delivery.state == DeliveryState.completed;
return FilledButton.icon(
onPressed: isActive ? () => _onSign(context) : null,
icon: Icon(isCompleted
? Icons.check_circle_outline
: Icons.draw_outlined),
label: Text(
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
),
);
})
Builder(
builder: (context) {
final isActive = delivery.state == DeliveryState.active;
final isCompleted =
delivery.state == DeliveryState.completed;
return FilledButton.icon(
onPressed: isActive ? () => _onSign(context) : null,
icon: Icon(
isCompleted
? Icons.check_circle_outline
: Icons.draw_outlined,
),
label: Text(
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
),
);
},
)
else
FilledButton.icon(
onPressed: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowNextStep()),
// Aus „Zahlung" erst weiter, wenn abgewickelt/nichts offen.
onPressed:
_lockedByPayment(
WorkflowStep.values[state.step.index + 1],
delivery,
details,
)
? null
: () => context.read<DeliveryWorkflowBloc>().add(
const WorkflowNextStep(),
),
icon: const Icon(Icons.arrow_forward),
label: const Text('Weiter'),
),

View File

@ -17,7 +17,6 @@ class SignatureResult {
required this.receiptConfirmed,
required this.notesAcknowledged,
required this.acknowledgedNoteIds,
required this.paymentCollected,
});
final Uint8List customerSignaturePng;
@ -25,18 +24,13 @@ class SignatureResult {
final bool receiptConfirmed;
final bool notesAcknowledged;
final List<String> acknowledgedNoteIds;
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
}
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
///
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
/// beiden Unterschriften.
/// Das Kassieren passiert vorher im eigenen Workflow-Step „Zahlung" und
/// wird dort protokolliert — dieser Flow enthält nur noch die Unterschriften.
///
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
@ -51,31 +45,18 @@ class SignatureView extends StatefulWidget {
required this.delivery,
required this.details,
required this.onSigned,
this.requiresCollection = false,
this.openAmount = 0,
this.paymentMethodLabel = '',
});
final Delivery delivery;
final TourDetails details;
final void Function(SignatureResult result) onSigned;
/// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC).
/// Schaltet Stufe 0 (Inkasso-Bestätigung) frei.
final bool requiresCollection;
/// Offener Betrag in Euro (nur für die Anzeige in Stufe 0).
final double openAmount;
/// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte").
final String paymentMethodLabel;
@override
State<SignatureView> createState() => _SignatureViewState();
}
/// Stufen des Abschluss-Flows.
enum _SignStage { payment, customer, driver }
enum _SignStage { customer, driver }
class _SignatureViewState extends State<SignatureView> {
static const String _receiptText =
@ -97,8 +78,7 @@ class _SignatureViewState extends State<SignatureView> {
);
late final List<DeliveryNote> _notes;
late _SignStage _stage;
bool _paymentConfirmed = false;
_SignStage _stage = _SignStage.customer;
bool _receiptAccepted = false;
bool _notesAccepted = false;
bool _customerEmpty = true;
@ -109,10 +89,8 @@ class _SignatureViewState extends State<SignatureView> {
@override
void initState() {
super.initState();
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
_stage =
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
_notes =
widget.details.notesByDeliveryId[widget.delivery.id] ??
const <DeliveryNote>[];
_customerController.addListener(() {
if (_customerEmpty != _customerController.isEmpty) {
@ -139,8 +117,6 @@ class _SignatureViewState extends State<SignatureView> {
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
bool get _stageValid {
switch (_stage) {
case _SignStage.payment:
return _paymentConfirmed;
case _SignStage.customer:
return _customerStepValid;
case _SignStage.driver:
@ -150,9 +126,6 @@ class _SignatureViewState extends State<SignatureView> {
Future<void> _onPrimaryPressed() async {
switch (_stage) {
case _SignStage.payment:
setState(() => _stage = _SignStage.customer);
return;
case _SignStage.customer:
setState(() => _stage = _SignStage.driver);
return;
@ -168,33 +141,20 @@ class _SignatureViewState extends State<SignatureView> {
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
acknowledgedNoteIds:
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
paymentCollected: widget.requiresCollection && _paymentConfirmed,
),
);
}
}
String get _paymentText {
final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ',');
final via = widget.paymentMethodLabel.isEmpty
? ''
: ' per ${widget.paymentMethodLabel}';
return 'Ich bestätige, dass der offene Betrag von $amount €$via '
'erhalten bzw. abgerechnet wurde.';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final customer = widget.details.customerOf(widget.delivery);
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
final isPayment = _stage == _SignStage.payment;
final isDriver = _stage == _SignStage.driver;
final String title;
switch (_stage) {
case _SignStage.payment:
title = 'Zahlung bestätigen';
case _SignStage.customer:
title = 'Unterschrift des Kunden';
case _SignStage.driver:
@ -207,56 +167,42 @@ class _SignatureViewState extends State<SignatureView> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (isPayment) ...[
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
// Unterschriften.
_PaymentDueCard(
openAmount: widget.openAmount,
methodLabel: widget.paymentMethodLabel,
),
if (!isDriver) ...[
_NotesSection(notes: _notes),
const SizedBox(height: 16),
_ConfirmTile(
value: _paymentConfirmed,
value: _notesAccepted,
enabled: !_notesEmpty,
label: _notesText,
onChanged: (v) => setState(() => _notesAccepted = v),
),
_ConfirmTile(
value: _receiptAccepted,
enabled: true,
label: _paymentText,
onChanged: (v) => setState(() => _paymentConfirmed = v),
),
] else ...[
if (!isDriver) ...[
_NotesSection(notes: _notes),
const SizedBox(height: 16),
_ConfirmTile(
value: _notesAccepted,
enabled: !_notesEmpty,
label: _notesText,
onChanged: (v) => setState(() => _notesAccepted = v),
),
_ConfirmTile(
value: _receiptAccepted,
enabled: true,
label: _receiptText,
onChanged: (v) => setState(() => _receiptAccepted = v),
),
const SizedBox(height: 16),
],
Text(
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_SignaturePad(
controller: isDriver ? _driverController : _customerController,
onClear: () =>
(isDriver ? _driverController : _customerController)
.clear(),
),
const SizedBox(height: 4),
Text(
'${customer?.address.city ?? ''}, den $date',
style: theme.textTheme.bodySmall,
label: _receiptText,
onChanged: (v) => setState(() => _receiptAccepted = v),
),
const SizedBox(height: 16),
],
Text(
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
_SignaturePad(
controller: isDriver ? _driverController : _customerController,
onClear:
() =>
(isDriver ? _driverController : _customerController)
.clear(),
),
const SizedBox(height: 4),
Text(
'${customer?.address.city ?? ''}, den $date',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _stageValid ? _onPrimaryPressed : null,
@ -270,66 +216,6 @@ class _SignatureViewState extends State<SignatureView> {
}
}
// ─── Inkasso-Hinweis (Stufe 0) ──────────────────────────────────────────────
class _PaymentDueCard extends StatelessWidget {
const _PaymentDueCard({required this.openAmount, required this.methodLabel});
final double openAmount;
final String methodLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amount = openAmount.toStringAsFixed(2).replaceAll('.', ',');
return Card(
margin: EdgeInsets.zero,
color: theme.colorScheme.primary.withValues(alpha: 0.07),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offener Betrag',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.payments_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text(
'$amount €',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
const Spacer(),
if (methodLabel.isNotEmpty)
Chip(
label: Text(methodLabel),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Text(
'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät '
'abrechnen und anschließend bestätigen.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
// ─── Notizen-Block ────────────────────────────────────────────────────────
class _NotesSection extends StatelessWidget {
@ -345,8 +231,9 @@ class _NotesSection extends StatelessWidget {
children: [
Text(
'Anmerkungen zur Lieferung',
style:
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
if (notes.isEmpty)

View File

@ -12,6 +12,7 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.dart';
/// Step 1 — Informationen zur Lieferung.
///
@ -245,39 +246,6 @@ class _QuickActions extends StatelessWidget {
await _call(context, picked.value);
}
Future<String?> _askReason(BuildContext context, String title) async {
final controller = TextEditingController();
final result = await 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'),
),
],
),
);
return result;
}
Future<void> _onStatusSelected(
BuildContext context,
_StatusAction action,
@ -285,11 +253,11 @@ class _QuickActions extends StatelessWidget {
final tourBloc = context.read<TourBloc>();
switch (action) {
case _StatusAction.hold:
final reason = await _askReason(context, 'Lieferung pausieren');
final reason = await showReasonDialog(context, 'Lieferung pausieren');
if (reason == null) return;
tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason));
case _StatusAction.cancel:
final reason = await _askReason(context, 'Lieferung abbrechen');
final reason = await showReasonDialog(context, 'Lieferung abbrechen');
if (reason == null) return;
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
case _StatusAction.resume:

View File

@ -0,0 +1,185 @@
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),
);
}
}

View File

@ -1,68 +1,49 @@
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/delivery_credit.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/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.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';
/// Step 5 — Übersicht & Abschluss.
/// Step 6 — Übersicht & Abschluss.
///
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
/// Zahlungsmethoden-Dropdown.
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf,
/// dazu die Betragsaufstellung und den Stand der Zahlung. Die Zahlungsart
/// wird nicht mehr hier gewählt, sondern im Step „Zahlung" abgewickelt und
/// protokolliert — die Übersicht zeigt das Ergebnis nur an.
///
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
/// Unterschrift steht.
class StepSummary extends StatelessWidget {
const StepSummary({
super.key,
required this.delivery,
required this.details,
});
const StepSummary({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
builder: (context, wfState) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SectionHeader(text: 'Ausgelieferte Artikel'),
const SizedBox(height: 8),
_DeliveredItems(
delivery: delivery,
details: details,
),
const SizedBox(height: 24),
_SectionHeader(text: 'Zahlung'),
const SizedBox(height: 8),
_PaymentSummary(
delivery: delivery,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 24),
_SectionHeader(text: 'Zahlungsmethode'),
const SizedBox(height: 8),
_PaymentMethodPicker(
delivery: delivery,
overrideId: wfState.paymentMethodOverrideId,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 16),
const _SignHint(),
],
);
},
final status = DeliveryPaymentStatus.of(delivery, details);
return 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: 'Zahlung'),
const SizedBox(height: 8),
PaymentBreakdownCard(
status: status,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 12),
PaymentRecordCard(status: status),
const SizedBox(height: 16),
const _SignHint(),
],
);
}
}
@ -75,452 +56,9 @@ class _SectionHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
);
}
}
class _DeliveredItems extends StatelessWidget {
const _DeliveredItems({
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}',
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${item.lineTotal.toStringAsFixed(2)} €',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
);
}
}
/// Offener Betrag der Lieferung in Euro: Warenwert (Σ Stückpreis × gelieferte
/// Menge) − Anzahlung − Gutschrift, nie negativ. Einzige Quelle dieser Formel —
/// genutzt von der Zahlungs-Übersicht UND der Zahlungsmethoden-Auswahl.
double _openAmount(Delivery delivery, DeliveryCredit? credit) {
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
final warenwert =
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
return (warenwert - delivery.prepaidAmount - creditEuros)
.clamp(0.0, double.infinity);
}
class _PaymentSummary extends StatelessWidget {
const _PaymentSummary({required this.delivery, required this.credit});
final Delivery delivery;
final DeliveryCredit? credit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
// Positionen fallen automatisch raus).
final warenwert = delivery.items
.fold<double>(0, (acc, item) => acc + item.lineTotal);
// Offener Betrag über den gemeinsamen Helper (gleiche Formel wie die
// Zahlungsmethoden-Auswahl).
final open = _openAmount(delivery, credit);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_SummaryRow(
icon: Icons.receipt_long_outlined,
label: 'Warenwert',
valueText: '${warenwert.toStringAsFixed(2)} €',
valueColor: theme.colorScheme.onSurface,
),
const SizedBox(height: 12),
_SummaryRow(
icon: Icons.savings_outlined,
label: 'Bei Bestellung bezahlt',
valueText: '− ${delivery.prepaidAmount.toStringAsFixed(2)} €',
valueColor: delivery.prepaidAmount > 0
? Colors.green.shade700
: theme.colorScheme.onSurfaceVariant,
),
if (credit != null) ...[
const SizedBox(height: 12),
_SummaryRow(
icon: Icons.card_giftcard_outlined,
label: 'Gutschrift',
valueText: '− ${(credit!.amountCents / 100).toStringAsFixed(2)} €',
valueColor: Colors.amber.shade800,
subtitle: credit!.reason,
),
],
const Divider(height: 24),
_SummaryRow(
icon: Icons.account_balance_wallet_outlined,
label: 'Offener Betrag',
valueText: '${open.toStringAsFixed(2)} €',
valueColor: open > 0
? theme.colorScheme.primary
: Colors.green.shade700,
emphasize: true,
),
],
),
),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({
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,
),
),
],
);
}
}
class _PaymentMethodPicker extends StatelessWidget {
const _PaymentMethodPicker({
required this.delivery,
required this.overrideId,
required this.credit,
});
final Delivery delivery;
final String? overrideId;
final DeliveryCredit? credit;
@override
Widget build(BuildContext context) {
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
builder: (context, state) {
if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) {
return const Card(
margin: EdgeInsets.zero,
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Zahlungsmethoden laden …'),
],
),
),
);
}
if (state is PaymentMethodsFailed) {
final onError = Theme.of(context).colorScheme.onErrorContainer;
return Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
state.message,
style: TextStyle(color: onError),
),
),
// Fehler ist kein Endzustand: der Fahrer kann das Laden
// direkt hier erneut anstoßen (z. B. nach Netz-Aussetzer).
TextButton.icon(
onPressed: () =>
context.read<PaymentMethodsCubit>().load(),
icon: Icon(Icons.refresh, color: onError),
label: Text(
'Erneut laden',
style: TextStyle(color: onError),
),
),
],
),
),
);
}
final loaded = state as PaymentMethodsLoaded;
// Ausschließlich die Backend-Methoden — keine frontend-seitige
// Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im
// Backend (Postgres `payment_methods`, aktiv) hinterlegt sind.
final methods = loaded.methods;
final selectedId = overrideId ?? delivery.paymentMethodId;
// Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der
// Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene
// Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer.
final selectedValue =
methods.any((m) => m.id == selectedId) ? selectedId : null;
// Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
// Dropdown den gewählten Stand, ist aber gesperrt.
final active = delivery.state == DeliveryState.active;
// Steht kein offener Betrag mehr aus (vollständig vorab bezahlt
// oder per Gutschrift ausgeglichen), ist keine Zahlungsweise zu
// wählen → Auswahl deaktivieren.
final hasOpenAmount = _openAmount(delivery, credit) > 0;
final editable = active && hasOpenAmount;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedValue,
decoration: const InputDecoration(
labelText: 'Zahlungsmethode',
border: OutlineInputBorder(),
),
items: [
for (final m in methods)
DropdownMenuItem(
value: m.id,
child: Text(m.name),
),
],
// `null` deaktiviert das Dropdown (Flutter-Konvention).
onChanged: editable
? (newId) {
if (newId == null) return;
context.read<DeliveryWorkflowBloc>().add(
WorkflowOverridePaymentMethod(
// Zurück auf die Original-Methode → Override
// löschen, damit das Domain-Modell "no
// override" kennt.
paymentMethodId:
newId == delivery.paymentMethodId
? null
: newId,
),
);
}
: null,
),
if (!active) ...[
const SizedBox(height: 8),
const _PickerHint(
text: 'Lieferung abgeschlossen — Zahlungsmethode nicht '
'mehr änderbar.',
),
] else if (!hasOpenAmount) ...[
const SizedBox(height: 8),
const _PickerHint(
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
'Auswahl der Zahlungsweise nicht erforderlich.',
),
],
],
),
),
);
},
);
}
}
/// Dezenter Sperr-/Info-Hinweis unter dem Zahlungsmethoden-Dropdown
/// (Schloss-Icon + Text in gedämpfter Farbe).
class _PickerHint extends StatelessWidget {
const _PickerHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Row(
children: [
Icon(Icons.lock_outline, size: 16, color: muted),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: muted),
),
),
],
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
);
}
}
@ -547,10 +85,7 @@ class _SignHint extends StatelessWidget {
Expanded(
child: Text(
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.primary,
),
style: TextStyle(fontSize: 13, color: theme.colorScheme.primary),
),
),
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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