Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_summary.dart
Dennis Nemec 4c6bef6897 feat(delivery): Abschluss-Navigation, Mengen-Hinweis, Set-Handling
A) Nach erfolgreichem Abschluss (aktiv→completed) poppt die Detail-Page
   automatisch zurück zur Übersicht. Scaffold ist jetzt StatefulWidget
   mit BlocListener<TourBloc>; nur „gearmt", wenn die Lieferung beim
   Öffnen aktiv war → erneutes Öffnen einer fertigen Lieferung poppt nicht.

B) Step „Info": Artikelliste zeigt weiter die Ursprungsmenge
   (requiredQuantity). Bei entfernten/teilweise gutgeschriebenen Positionen
   erscheint pro Zeile ein „Menge geändert"-Hinweis + ein tappbares Banner,
   das zu Step 3 „Artikel" springt.

C) Beladen: nicht-scanbare Set-Köpfe (Parent-Komponenten) werden jetzt
   IMMER mit ihrem Set gezeigt — als Kopf in der Lagergruppe ihrer
   Komponenten statt isoliert unter „Dienstleistungen". _ItemRow leitet
   scanNotRequired aus der Artikel-Scanbarkeit ab.

D) Step „Übersicht": Wording der Zahlungsweise-Sperre bei offen==0
   präzisiert („Keine Zahlung mehr offen (bereits bezahlt)").

E) Step „Artikel": Komponenten eines Sets sind einzeln nicht mehr
   entfernbar (kein Button + Hinweis). Das Entfernen/Wiederherstellen läuft
   nur über den Oberartikel und kaskadiert auf das ganze Set (ganz oder
   gar nix). Set-Entfernen ist blockiert, solange eine Komponente noch
   nicht verladen ist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:57:52 +02:00

545 lines
18 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) {
return Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
state.message,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
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,
),
),
),
],
),
);
}
}