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:
@ -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'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user