Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_summary.dart
Dennis Nemec 7cf17466b3 fix(auth): Session ueberlebt Netz-Aussetzer; Zahlungsmethoden laden nach Re-Login neu
Kunde meldete haeufig 'Sitzung abgelaufen' und danach dauerhaft rotes
'Sitzung abgelaufen' bei den Zahlungsmethoden trotz erfolgreichem Login.

Token-Refresh:
- _performRefresh wertete JEDEN Fehler als tote Session (Logout + Refresh-
  Token geloescht) - auch Mobilfunk-/VPN-Aussetzer. Jetzt gilt nur noch eine
  echte Ablehnung durch Keycloak (OAuth invalid_grant o. ae.) als abgelaufen;
  voruebergehende Fehler behalten die Session, nutzen den noch gueltigen
  Access-Token weiter oder werfen AuthTemporarilyUnavailableException
- Interceptor schickt Requests nicht mehr tokenlos weiter (ergab 401 ->
  irrefuehrendes 'Sitzung abgelaufen'), sondern bricht als Verbindungsfehler ab
- restoreSession verwirft den Refresh-Token bei fehlendem Netz nicht mehr

Zahlungsmethoden:
- Cubit lud nur einmal beim App-Start (vor dem Login -> 401) und blieb danach
  fuer immer im Fehlerzustand. Jetzt Reload bei jedem Wechsel auf Authenticated
- Fehlerkarte in der Uebersicht hat einen 'Erneut laden'-Button

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 11:59:56 +03:00

561 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/// Step 5 — Ü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.
///
/// 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,
});
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(),
],
);
},
);
}
}
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,
),
);
}
}
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),
),
),
],
);
}
}
class _SignHint extends StatelessWidget {
const _SignHint();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(Icons.draw_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.primary,
),
),
),
],
),
);
}
}