Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/delivery_detail_page.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

485 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/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/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_services.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
// ─────────────────────────────────────────────────────────────────────────
// Backend-TODOs (siehe Roadmap unten in diesem File)
// ─────────────────────────────────────────────────────────────────────────
//
// Die folgenden UI-Features sind aktuell als lokaler Stub im
// `DeliveryWorkflowBloc` gebaut. Sie persistieren nichts am Server und
// gehen verloren, sobald die Detail-Page geschlossen wird:
//
// * **B1 Bild-Notizen**: Foto-Upload-Endpoint fehlt. Domain-Feld
// `DeliveryNote.image_attachment` existiert bereits als
// Storage-Key — wir brauchen einen Endpoint, der Multipart-Upload
// 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).
//
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
// hochladen + auf der Lieferung speichern. Backend hat dafür weder
// Felder noch Endpoint. Explizit „nach der Session" verschoben.
//
// * **B6 Notiz-Templates**: Stammdaten (vordefinierte Notiz-Texte
// zum Auswählen). Im alten Stand schon UI-seitig im NoteAddDialog
// vorbereitet; aktuell zeigen wir nur das freie Textfeld.
//
// * **B7 `completeDelivery` im Frontend**: Repository-Methode +
// Bloc-Event fehlt. Backend-Endpoint existiert (parameterlos).
// Trigger: „Unterschreiben"-Button im Summary-Step — derzeit
// SnackBar-Stub.
// ─────────────────────────────────────────────────────────────────────────
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 5 Steps;
/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props,
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
///
/// Lifetime: die Page bringt ihren eigenen `DeliveryWorkflowBloc` mit
/// (siehe `BlocProvider` unten). Beim Pop wird der Bloc samt Drafts
/// disposed — gewollt, denn ein neuer Besuch der Detail-Page startet
/// frisch im Step „Info".
class DeliveryDetail extends StatelessWidget {
const DeliveryDetail({super.key, required this.deliveryId});
final String deliveryId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => DeliveryWorkflowBloc(deliveryId: deliveryId),
child: _DeliveryDetailScaffold(deliveryId: deliveryId),
);
}
}
class _DeliveryDetailScaffold extends StatefulWidget {
const _DeliveryDetailScaffold({required this.deliveryId});
final String deliveryId;
@override
State<_DeliveryDetailScaffold> createState() =>
_DeliveryDetailScaffoldState();
}
class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
/// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann
/// poppen wir bei `completed` automatisch zurück zur Übersicht. Öffnet der
/// Fahrer eine bereits abgeschlossene Lieferung, bleibt `_armed == false`
/// und die Page bleibt offen (kein ungewolltes Zurückspringen).
bool _armed = false;
bool _popped = false;
@override
void initState() {
super.initState();
final s = context.read<TourBloc>().state;
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
_armed = true;
}
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == widget.deliveryId) return d;
}
return null;
}
/// Nach erfolgreichem Abschluss (aktiv → completed) zurück zur Übersicht.
void _onTourState(BuildContext context, TourState state) {
if (_popped || state is! TourLoaded) return;
final d = _findDelivery(state.details);
if (d == null) return;
if (d.state == DeliveryState.active) {
_armed = true;
} else if (_armed && d.state == DeliveryState.completed) {
_popped = true;
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocListener<TourBloc, TourState>(
listener: _onTourState,
child: BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final details = tourState.details;
final delivery = _findDelivery(details);
if (delivery == null) {
return Scaffold(
appBar: AppBar(title: const Text('Lieferung')),
body: Center(
child: Text(
'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.',
),
),
);
}
final customer = details.customerOf(delivery);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.primaryColor,
foregroundColor: theme.colorScheme.onPrimary,
title: Text(customer?.name ?? 'Lieferung'),
),
body: Column(
children: [
const _StepHeader(),
const Divider(height: 1),
Expanded(
child: _StepBody(delivery: delivery, details: details),
),
const Divider(height: 1),
_BottomNav(delivery: delivery, details: details),
],
),
);
},
),
);
}
}
// ─── Step-Header (Pills) ────────────────────────────────────────────────
class _StepHeader extends StatelessWidget {
const _StepHeader();
@override
Widget build(BuildContext context) {
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 10),
child: Row(
children: [
for (int i = 0; i < WorkflowStep.values.length; i++) ...[
Expanded(
child: _StepPill(
index: i,
step: WorkflowStep.values[i],
isActive: state.step.index == i,
isPassed: state.step.index > i,
onTap: () => context
.read<DeliveryWorkflowBloc>()
.add(WorkflowGoToStep(WorkflowStep.values[i])),
),
),
if (i < WorkflowStep.values.length - 1)
_StepConnector(isPassed: state.step.index > i),
],
],
),
);
},
);
}
}
class _StepPill extends StatelessWidget {
const _StepPill({
required this.index,
required this.step,
required this.isActive,
required this.isPassed,
required this.onTap,
});
final int index;
final WorkflowStep step;
final bool isActive;
final bool isPassed;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
final Color circleColor;
final Color circleFg;
final Color labelColor;
final FontWeight labelWeight;
if (isActive) {
circleColor = primary;
circleFg = theme.colorScheme.onPrimary;
labelColor = primary;
labelWeight = FontWeight.w700;
} else if (isPassed) {
circleColor = primary.withValues(alpha: 0.85);
circleFg = theme.colorScheme.onPrimary;
labelColor = primary;
labelWeight = FontWeight.w600;
} else {
circleColor = theme.colorScheme.surfaceContainerHighest;
circleFg = theme.colorScheme.onSurfaceVariant;
labelColor = theme.colorScheme.onSurfaceVariant;
labelWeight = FontWeight.w500;
}
return InkWell(
onTap: onTap,
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: 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,
),
),
],
),
),
);
}
}
class _StepConnector extends StatelessWidget {
const _StepConnector({required this.isPassed});
final bool isPassed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Container(
width: 10,
height: 2,
color: isPassed
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
),
);
}
}
// ─── Step-Body Router ───────────────────────────────────────────────────
class _StepBody extends StatelessWidget {
const _StepBody({required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
buildWhen: (prev, curr) => prev.step != curr.step,
builder: (context, state) {
switch (state.step) {
case WorkflowStep.info:
return StepInfo(delivery: delivery, details: details);
case WorkflowStep.notes:
return StepNotes(delivery: delivery, details: details);
case WorkflowStep.articles:
return StepArticles(delivery: delivery, details: details);
case WorkflowStep.services:
return StepServices(delivery: delivery, details: details);
case WorkflowStep.summary:
return StepSummary(delivery: delivery, details: details);
}
},
);
}
}
// ─── Bottom-Navigation ──────────────────────────────────────────────────
class _BottomNav extends StatelessWidget {
const _BottomNav({required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
/// Öffnet den zweistufigen Unterschrift-Flow (Kunde → Fahrer). Erst nach
/// beiden Unterschriften triggert die View den Backend-Abschluss via
/// `CompleteDelivery`; danach poppt sie zurück auf die Detail-Page, die
/// 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');
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();
},
),
),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
builder: (context, state) {
final isFirst = state.step.index == 0;
final isLast = state.step.index == WorkflowStep.values.length - 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
OutlinedButton.icon(
onPressed: isFirst
? null
: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowPreviousStep()),
icon: const Icon(Icons.arrow_back),
label: const Text('Zurück'),
),
const Spacer(),
if (isLast)
// 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',
),
);
})
else
FilledButton.icon(
onPressed: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowNextStep()),
icon: const Icon(Icons.arrow_forward),
label: const Text('Weiter'),
),
],
),
);
},
),
);
}
}