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>
505 lines
19 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|