Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/delivery_detail_page.dart
Dennis Nemec 857c92031a feat: abweichende Lieferadresse und Empfänger vor Ort anzeigen
Belege können im ERP eine eigene Lieferadresse (LieferAdressId) mit
anderer Person tragen als der Besteller. Die App zeigt jetzt überall den
Empfänger vor Ort:

- TourDetails.recipientOf liefert Empfänger (Name aus der Lieferadresse,
  sonst Besteller), Lieferanschrift und differsFromOrderer.
- Sortieren, Auswählen, Beladen und Liefer-Übersicht: Empfängername +
  Lieferadresse, bei Abweichung „Bestellt von: <Kunde>".
- Lieferdetails: hervorgehobene Karte „Abweichende Lieferadresse" mit
  Empfänger, Anschrift und Kontakt; Kunden-Block als „Besteller (Kunde)"
  mit Adresse, Kundennummer und Kontaktdaten.
- Maps navigiert zur Lieferadresse statt zur Kundenadresse; AppBar,
  Unterschrift und Beladen-Avatar nutzen den Empfänger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:51:31 +02:00

505 lines
19 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/delivery/bloc/tour_bloc.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';
// ─────────────────────────────────────────────────────────────────────────
// 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 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
// 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 6 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.',
),
),
);
}
// Titel = Empfänger vor Ort, wie in den Listen-Kacheln.
final recipient = details.recipientOf(delivery);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.primaryColor,
foregroundColor: theme.colorScheme.onPrimary,
title: Text(recipient.name),
),
body: Column(
children: [
_StepHeader(delivery: delivery, details: details),
const Divider(height: 1),
Expanded(
child: _StepBody(delivery: delivery, details: details),
),
const Divider(height: 1),
_BottomNav(delivery: delivery, details: details),
],
),
);
},
),
);
}
}
// ─── 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({required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@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,
enabled:
!_lockedByPayment(
WorkflowStep.values[i],
delivery,
details,
),
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.enabled,
required this.onTap,
});
final int index;
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
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: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
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,
),
),
),
),
],
),
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.payment:
return StepPayment(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>();
// 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,
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,
internalNote: result.internalNote,
),
);
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(
// 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'),
),
],
),
);
},
),
);
}
}