Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_payment.dart
Dennis Nemec bc95fe9577 fix: Fehlerbehandlung beim Laden und Meldungen überarbeiten
Reklamationen der Fahrer: Meldungen verdeckten Buttons, und nach dem
Abschluss stand „Lieferung nicht in der Tour gefunden" ohne Ausweg da.

Meldungen:
- Erfolgs-SnackBars entfernt, bei Erfolg bleibt der Spinner
- einheitliche, schwebende Meldungen statt Stau über den Buttons
- Server-Rohtexte durch verständliche Texte ersetzt (api_failure_text)

Laden:
- fehlt eine geöffnete Lieferung, wird die Tour einmal neu geholt; die
  Meldung kommt nur nach erfolgreichem Abruf ohne die Lieferung
- Endlos-Spinner auf Lieferdetail, Filial-Abholung und Beladen ersetzt
  durch Kopfzeile, Fehlergrund und „Erneut versuchen"
- Tour wird nach einem Tageswechsel nachgeladen, offene Seiten bleiben
  dabei unberührt
- Abschluss und Zuweisung überschreiben kein zwischenzeitliches Neuladen,
  alte Antworten drehen einen Abschluss nicht mehr zurück
- leere Server-Antworten gelten als Fehler statt als „keine Daten"

Anmeldung:
- Token-Erneuerung wartet höchstens 12 s, der native Aufruf läuft weiter
- App-Start ohne Netz zeigt einen Hinweis mit „Erneut versuchen"
- Fehlerseite der Fahrzeugauswahl bietet „Abmelden"

Außerdem Podfile.lock an die Plugins angeglichen. Tests für die neuen
Fehlerpfade ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:28:24 +01:00

197 lines
6.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/delivered_items_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_breakdown_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_record_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_sheet.dart';
/// Step „Zahlung" — Zahlungsabwicklung („Abkassieren") vor der Übersicht.
///
/// Zeigt die ausgelieferten Artikel mit Menge und Preis, die Betrags-
/// aufstellung und den Zahlungsstatus. Über „Zahlung abwickeln" öffnet sich
/// das Zahlungs-Modal; die Bestätigung wird am Server protokolliert.
///
/// Ist nichts offen (bereits bezahlt), kann der Fahrer den Step
/// überspringen. Sonst gibt die Detail-Page „Weiter" erst frei, wenn eine
/// gültige Zahlung protokolliert ist (siehe `DeliveryPaymentStatus`).
class StepPayment extends StatelessWidget {
const StepPayment({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
final status = DeliveryPaymentStatus.of(delivery, details);
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: 'Betrag'),
const SizedBox(height: 8),
PaymentBreakdownCard(
status: status,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 16),
PaymentRecordCard(status: status),
],
);
}
}
/// Fester Bereich unten: offener Betrag + großer „Zahlung abwickeln"-Button.
/// Bleibt sichtbar, auch wenn die Artikelliste lang ist. Sitzt in der
/// Bottom-Bar der Detail-Page, damit SnackBars ihn nicht verdecken.
class StepPaymentCollectPanel extends StatelessWidget {
const StepPaymentCollectPanel({
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);
if (delivery.state != DeliveryState.active || !status.hasOpenAmount) {
return const SizedBox.shrink();
}
return _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;
}
}
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),
);
}
}