From 857c92031a2408afc58dde36759ff86318359603 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 15:51:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20abweichende=20Lieferadresse=20und=20Emp?= =?UTF-8?q?f=C3=A4nger=20vor=20Ort=20anzeigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ". - 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 --- lib/domain/entity/contact_source.dart | 11 + lib/domain/entity/delivery_recipient.dart | 35 +++ lib/domain/entity/tour_details.dart | 48 ++++ .../presentation/delivery_detail_page.dart | 5 +- .../detail/presentation/delivery_sign.dart | 8 +- .../detail/presentation/steps/step_info.dart | 226 ++++++++++++++++-- .../presentation/delivery_overview.dart | 26 +- .../presentation/delivery_selection_page.dart | 21 +- .../presentation/delivery_sort_page.dart | 11 +- .../widget/sortable_delivery_list.dart | 11 +- .../presentation/loading_customer_page.dart | 25 +- .../presentation/loading_overview_page.dart | 10 +- lib/widget/orderer_hint.dart | 69 ++++++ test/delivery_recipient_test.dart | 155 ++++++++++++ 14 files changed, 600 insertions(+), 61 deletions(-) create mode 100644 lib/domain/entity/delivery_recipient.dart create mode 100644 lib/widget/orderer_hint.dart create mode 100644 test/delivery_recipient_test.dart diff --git a/lib/domain/entity/contact_source.dart b/lib/domain/entity/contact_source.dart index aaaa312..a4e260e 100644 --- a/lib/domain/entity/contact_source.dart +++ b/lib/domain/entity/contact_source.dart @@ -125,6 +125,17 @@ class ContactSource { return parts.join(' '); } + /// Nur der Namensblock `Name1..3` ohne Anrede/Titel, so wie der + /// Kundenname im Listen-Kopf erscheint. `null` ohne Namen. + String? get fullName { + final parts = [ + for (final n in [name1, name2, name3]) + if (n != null && n.trim().isNotEmpty) n.trim(), + ]; + if (parts.isEmpty) return null; + return parts.join(' '); + } + /// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere /// Komponenten werden ausgeblendet. String? get subtitle { diff --git a/lib/domain/entity/delivery_recipient.dart b/lib/domain/entity/delivery_recipient.dart new file mode 100644 index 0000000..8f7a58d --- /dev/null +++ b/lib/domain/entity/delivery_recipient.dart @@ -0,0 +1,35 @@ +import 'address.dart'; +import 'contact_source.dart'; + +/// Wer die Ware vor Ort annimmt und wohin geliefert wird. +/// +/// Im ERP kann ein Beleg neben der Belegadresse (= Besteller, Kunde) eine +/// eigene Lieferadresse (`Belegkopf.LieferAdressId`) tragen, z. B. wenn ein +/// Kunde für Angehörige bestellt. Dann unterscheiden sich Empfänger und +/// Besteller in Person und/oder Anschrift. Fahrer navigieren und klingeln +/// beim Empfänger; Zahlung und Beleg laufen weiter über den Besteller. +/// +/// Gebaut über `TourDetails.recipientOf`. +class DeliveryRecipient { + const DeliveryRecipient({ + required this.name, + required this.address, + required this.differsFromOrderer, + this.contact, + }); + + /// Name der Person/Firma vor Ort. Ohne eigenen Namen an der Lieferadresse + /// der Name des Bestellers. + final String name; + + /// Anschrift, an die geliefert wird (Lieferadress-Snapshot des Belegs). + final Address address; + + /// Kontaktquelle der Lieferadresse (Rolle `delivery`), falls das ERP + /// dort Namen oder Kontaktkanäle führt. + final ContactSource? contact; + + /// `true`, wenn Person oder Anschrift vom Besteller abweichen. Die UI + /// weist dann auf den Besteller hin. + final bool differsFromOrderer; +} diff --git a/lib/domain/entity/tour_details.dart b/lib/domain/entity/tour_details.dart index ba7c783..ada6d46 100644 --- a/lib/domain/entity/tour_details.dart +++ b/lib/domain/entity/tour_details.dart @@ -1,9 +1,11 @@ +import 'address.dart'; import 'article.dart'; import 'contact_source.dart'; import 'customer.dart'; import 'delivery.dart'; import 'delivery_credit.dart'; import 'delivery_payment.dart'; +import 'delivery_recipient.dart'; import 'delivery_item.dart'; import 'delivery_note.dart'; import 'delivery_service_value.dart'; @@ -97,6 +99,52 @@ class TourDetails { Customer? customerOf(Delivery delivery) => customers[delivery.customerId]; + /// Empfänger vor Ort: Name und Kontakt aus der ERP-Lieferadresse, sonst + /// der Besteller. [DeliveryRecipient.differsFromOrderer] ist gesetzt, + /// sobald Anschrift oder Name vom Besteller abweichen. + /// + /// Der Namensvergleich läuft ERP-Adresse gegen ERP-Adresse (Lieferadresse + /// vs. Belegadresse, jeweils `Name1..3`), damit ein nur in `Name2` + /// geführter Vorname keinen Fehlalarm auslöst. Groß-/Kleinschreibung und + /// Leerzeichen zählen nicht als Abweichung. + DeliveryRecipient recipientOf(Delivery delivery) { + final customer = customerOf(delivery); + final deliverySource = contactSourceWithRole(delivery, ContactRole.delivery); + final ordererName = contactSourceWithRole(delivery, ContactRole.header)?.fullName ?? + customer?.name ?? + ''; + final recipientName = deliverySource?.fullName; + + final addressDiffers = customer != null && + !_sameAddress(delivery.deliveryAddressSnapshot, customer.address); + final nameDiffers = recipientName != null && + _normalized(recipientName) != _normalized(ordererName); + + return DeliveryRecipient( + name: recipientName ?? customer?.name ?? '⟨Unbekannter Kunde⟩', + address: delivery.deliveryAddressSnapshot, + contact: deliverySource, + differsFromOrderer: addressDiffers || nameDiffers, + ); + } + + /// Erste Adress-Quelle einer Lieferung mit der Rolle [role], oder `null`. + ContactSource? contactSourceWithRole(Delivery delivery, ContactRole role) { + for (final s in contactSourcesOf(delivery)) { + if (s.role == role) return s; + } + return null; + } + + static String _normalized(String value) => + value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + + static bool _sameAddress(Address a, Address b) => + _normalized(a.street) == _normalized(b.street) && + _normalized(a.houseNumber) == _normalized(b.houseNumber) && + _normalized(a.postalCode) == _normalized(b.postalCode) && + _normalized(a.city) == _normalized(b.city); + Iterable contactsOf(Delivery delivery) sync* { for (final id in delivery.contactPersonIds) { final c = contacts[id]; diff --git a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart index f92dc53..a08b097 100644 --- a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart +++ b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart @@ -145,12 +145,13 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> { ), ); } - final customer = details.customerOf(delivery); + // 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(customer?.name ?? 'Lieferung'), + title: Text(recipient.name), ), body: Column( children: [ diff --git a/lib/feature/delivery/detail/presentation/delivery_sign.dart b/lib/feature/delivery/detail/presentation/delivery_sign.dart index d414a3f..c67b211 100644 --- a/lib/feature/delivery/detail/presentation/delivery_sign.dart +++ b/lib/feature/delivery/detail/presentation/delivery_sign.dart @@ -162,7 +162,9 @@ class _SignatureViewState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final customer = widget.details.customerOf(widget.delivery); + // Unterschrieben wird vor Ort — Name und Ort also vom Empfänger, der + // vom Besteller abweichen kann. + final recipient = widget.details.recipientOf(widget.delivery); final date = DateFormat('dd.MM.yyyy').format(DateTime.now()); final isDriver = _stage == _SignStage.driver; @@ -198,7 +200,7 @@ class _SignatureViewState extends State { const SizedBox(height: 16), ], Text( - 'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}', + 'Lieferung an: ${recipient.name}', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), @@ -213,7 +215,7 @@ class _SignatureViewState extends State { ), const SizedBox(height: 4), Text( - '${customer?.address.city ?? ''}, den $date', + '${recipient.address.city}, den $date', style: theme.textTheme.bodySmall, ), const SizedBox(height: 24), diff --git a/lib/feature/delivery/detail/presentation/steps/step_info.dart b/lib/feature/delivery/detail/presentation/steps/step_info.dart index 03e6e47..2479d98 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_info.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_info.dart @@ -6,6 +6,7 @@ import 'package:hl_lieferservice/domain/entity/contact_source.dart'; import 'package:hl_lieferservice/domain/entity/customer.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_recipient.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'; @@ -35,10 +36,26 @@ class StepInfo extends StatelessWidget { // damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND // „Kundenstamm") aufpoppt. final mergedSources = details.mergedContactSourcesOf(delivery); + final recipient = details.recipientOf(delivery); + final differs = recipient.differsFromOrderer; + final recipientContact = recipient.contact; + final ordererSource = + details.contactSourceWithRole(delivery, ContactRole.header); return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), children: [ + // Empfänger ≠ Besteller: ganz oben, damit es vor dem Klingeln + // auffällt. Die Besteller-Daten folgen unten im Kunden-Block. + if (differs) ...[ + _DifferentRecipientCard( + recipient: recipient, + channels: recipientContact == null + ? const [] + : details.channelsOf(recipientContact), + ), + const SizedBox(height: 24), + ], _SectionHeader(text: 'Beleg'), const SizedBox(height: 8), _BelegCard(delivery: delivery), @@ -47,7 +64,7 @@ class StepInfo extends StatelessWidget { const SizedBox(height: 8), _QuickActions( delivery: delivery, - customer: customer, + recipient: recipient, mergedSources: mergedSources, ), const SizedBox(height: 24), @@ -55,9 +72,19 @@ class StepInfo extends StatelessWidget { const SizedBox(height: 8), _AgreementsCard(delivery: delivery), const SizedBox(height: 24), - _SectionHeader(text: 'Kundeninformationen'), + _SectionHeader( + text: differs ? 'Besteller (Kunde)' : 'Kundeninformationen', + ), const SizedBox(height: 8), - _CustomerCard(customer: customer, contacts: contacts), + _CustomerCard( + customer: customer, + contacts: contacts, + // Nur bei abweichendem Empfänger explizit: Kontaktdaten des + // Bestellers, damit der Fahrer ihn bei Rückfragen erreicht. + ordererChannels: differs && ordererSource != null + ? details.channelsOf(ordererSource) + : null, + ), if (mergedSources.isNotEmpty) ...[ const SizedBox(height: 24), _SectionHeader(text: 'Alle Kontaktinfos'), @@ -89,6 +116,117 @@ class _SectionHeader extends StatelessWidget { } } +// ─── Abweichender Empfänger ───────────────────────────────────────────── + +/// Hervorgehobene Karte, wenn Lieferadresse oder Empfänger vom Besteller +/// abweichen: wer die Ware vor Ort annimmt, wo, und wie er erreichbar ist. +class _DifferentRecipientCard extends StatelessWidget { + const _DifferentRecipientCard({ + required this.recipient, + required this.channels, + }); + + final DeliveryRecipient recipient; + final List channels; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = Colors.deepOrange.shade800; + final address = recipient.address; + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: Colors.deepOrange.withValues(alpha: 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.deepOrange.withValues(alpha: 0.6), + width: 1.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.alt_route, color: accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Abweichende Lieferadresse', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: accent, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Die Ware geht nicht an den Besteller. Bitte an folgende ' + 'Person bzw. Adresse liefern:', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'Empfänger vor Ort', + style: theme.textTheme.labelLarge?.copyWith( + color: accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + recipient.name, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.location_on_outlined, color: accent), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${address.street} ${address.houseNumber}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${address.postalCode} ${address.city}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + if (channels.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final ch in channels) + _ChannelRow(channel: ch, onLaunch: _launchContact), + ], + ], + ), + ), + ); + } +} + // ─── Beleg ────────────────────────────────────────────────────────────── class _BelegCard extends StatelessWidget { @@ -125,12 +263,12 @@ enum _StatusAction { hold, cancel, resume } class _QuickActions extends StatelessWidget { const _QuickActions({ required this.delivery, - required this.customer, + required this.recipient, required this.mergedSources, }); final Delivery delivery; - final Customer? customer; + final DeliveryRecipient recipient; final List mergedSources; /// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in @@ -172,11 +310,9 @@ class _QuickActions extends StatelessWidget { } Future _launchMaps(BuildContext context) async { - final address = customer != null - ? '${customer!.address.street} ${customer!.address.houseNumber}, ' - '${customer!.address.postalCode} ${customer!.address.city}' - : delivery.deliveryAddressSnapshot.oneLine; - final encoded = Uri.encodeComponent(address); + // Navigiert wird zur Lieferadresse — nicht zur Anschrift des + // Bestellers, die abweichen kann. + final encoded = Uri.encodeComponent(recipient.address.oneLine); // Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS. final uri = Uri.parse( 'https://www.google.com/maps/search/?api=1&query=$encoded', @@ -495,11 +631,19 @@ class _AgreementsCard extends StatelessWidget { // ─── Kunde + Kontakte ─────────────────────────────────────────────────── class _CustomerCard extends StatelessWidget { - const _CustomerCard({required this.customer, required this.contacts}); + const _CustomerCard({ + required this.customer, + required this.contacts, + this.ordererChannels, + }); final Customer? customer; final List contacts; + /// Gesetzt, wenn der Kunde nur Besteller ist (Empfänger weicht ab): dann + /// zeigt die Karte Kundennummer und alle Kontaktkanäle des Bestellers. + final List? ordererChannels; + Future _call(BuildContext context, String phone) async { final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); if (!ok && context.mounted) { @@ -563,6 +707,44 @@ class _CustomerCard extends StatelessWidget { ], ), ], + if (ordererChannels case final channels?) ...[ + if (c != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.badge_outlined, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Text( + 'Kundennummer ${c.erpCustomerId}', + style: const TextStyle(fontSize: 14), + ), + ], + ), + ], + const Divider(height: 24), + Text( + 'Kontaktdaten Besteller', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + if (channels.isEmpty) + Text( + 'Keine Telefonnummer oder E-Mail hinterlegt.', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ) + else + for (final ch in channels) + _ChannelRow(channel: ch, onLaunch: _launchContact), + ], if (contacts.isNotEmpty) ...[ const Divider(height: 24), Text( @@ -868,20 +1050,22 @@ class _ArticleRow extends StatelessWidget { // Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen. // Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab. +/// Öffnet einen Kontaktkanal (tel:/mailto:/https) — geteilt von allen +/// Karten, die [_ChannelRow]s zeigen. +Future _launchContact(BuildContext context, Uri uri, String label) async { + final ok = await launchUrl(uri); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$label konnte nicht geöffnet werden')), + ); + } +} + class _AllContactsCard extends StatelessWidget { const _AllContactsCard({required this.sources}); final List sources; - Future _launch(BuildContext context, Uri uri, String label) async { - final ok = await launchUrl(uri); - if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$label konnte nicht geöffnet werden')), - ); - } - } - @override Widget build(BuildContext context) { return Card( @@ -895,7 +1079,7 @@ class _AllContactsCard extends StatelessWidget { if (i > 0) const Divider(height: 1), _ContactSourceTile( source: sources[i], - onLaunch: _launch, + onLaunch: _launchContact, ), ], ], diff --git a/lib/feature/delivery/overview/presentation/delivery_overview.dart b/lib/feature/delivery/overview/presentation/delivery_overview.dart index c60e197..3fca515 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/domain/entity/article.dart'; -import 'package:hl_lieferservice/domain/entity/customer.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; import 'package:hl_lieferservice/domain/entity/tour_details.dart'; @@ -13,6 +12,7 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart'; import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart'; +import 'package:hl_lieferservice/widget/orderer_hint.dart'; /// Entscheidet beim Tap auf eine Lieferung, wohin navigiert wird: /// @@ -408,6 +408,7 @@ class _NextDeliveryCard extends StatelessWidget { final theme = Theme.of(context); final delivery = entry.delivery; final customer = details.customerOf(delivery); + final recipient = details.recipientOf(delivery); final pendingExternal = details.pendingExternalWarehouseGroups(delivery); final hasPendingExternal = pendingExternal.isNotEmpty; @@ -432,15 +433,19 @@ class _NextDeliveryCard extends StatelessWidget { children: [ _HeaderRow( position: entry.position, - customer: customer, + name: recipient.name, desiredTime: delivery.desiredTime, ), const SizedBox(height: 10), _InfoRow( icon: Icons.location_on_outlined, - text: delivery.deliveryAddressSnapshot.oneLine, + text: recipient.address.oneLine, emphasized: true, ), + if (recipient.differsFromOrderer) + OrdererHint( + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + ), if (delivery.specialAgreements != null && delivery.specialAgreements!.isNotEmpty) ...[ const SizedBox(height: 6), @@ -492,12 +497,12 @@ class _NextDeliveryCard extends StatelessWidget { class _HeaderRow extends StatelessWidget { const _HeaderRow({ required this.position, - required this.customer, + required this.name, required this.desiredTime, }); final int position; - final Customer? customer; + final String name; final String? desiredTime; @override @@ -524,7 +529,7 @@ class _HeaderRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - customer?.name ?? '⟨Unbekannter Kunde⟩', + name, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), @@ -791,6 +796,7 @@ class _DeliveryTile extends StatelessWidget { final theme = Theme.of(context); final delivery = entry.delivery; final customer = details.customerOf(delivery); + final recipient = details.recipientOf(delivery); final pendingExternal = details.pendingExternalWarehouseGroups(delivery); final hasPendingExternal = pendingExternal.isNotEmpty; @@ -828,7 +834,7 @@ class _DeliveryTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - customer?.name ?? '⟨Unbekannter Kunde⟩', + recipient.name, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -840,12 +846,16 @@ class _DeliveryTile extends StatelessWidget { ), const SizedBox(height: 2), Text( - delivery.deliveryAddressSnapshot.oneLine, + recipient.address.oneLine, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), + if (recipient.differsFromOrderer) + OrdererHint( + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + ), const SizedBox(height: 4), Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart index 6f5f0a8..7a16869 100644 --- a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart @@ -13,6 +13,7 @@ import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; +import 'package:hl_lieferservice/widget/orderer_hint.dart'; /// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen /// Lieferungen aus dem gemeinsamen Tour-Pool. @@ -241,6 +242,7 @@ class _DeliverySelectionPageState extends State { itemBuilder: (context, index) { final delivery = available[index]; final customer = details.customerOf(delivery); + final recipient = details.recipientOf(delivery); final isSelected = _selectedIds.contains(delivery.id); return CheckboxListTile( key: ValueKey('available-${delivery.id}'), @@ -258,12 +260,13 @@ class _DeliverySelectionPageState extends State { }); }, title: Text( - customer?.name ?? '⟨Unbekannter Kunde⟩', + recipient.name, style: const TextStyle(fontWeight: FontWeight.w600), ), - subtitle: Text( - delivery.deliveryAddressSnapshot.oneLine, - style: const TextStyle(fontSize: 12), + subtitle: RecipientSubtitle( + recipient: recipient, + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + addressStyle: const TextStyle(fontSize: 12), ), controlAffinity: ListTileControlAffinity.leading, ); @@ -289,6 +292,7 @@ class _DeliverySelectionPageState extends State { final isOwn = delivery.assignedCarId == widget.selectedCarId; final plate = _plateFor(delivery.assignedCarId); final customer = details.customerOf(delivery); + final recipient = details.recipientOf(delivery); return Material( color: isOwn @@ -310,15 +314,16 @@ class _DeliverySelectionPageState extends State { : theme.colorScheme.onSurfaceVariant, ), title: Text( - customer?.name ?? '⟨Unbekannter Kunde⟩', + recipient.name, style: TextStyle( fontWeight: FontWeight.w600, color: isOwn ? theme.colorScheme.primary : null, ), ), - subtitle: Text( - delivery.deliveryAddressSnapshot.oneLine, - style: const TextStyle(fontSize: 12), + subtitle: RecipientSubtitle( + recipient: recipient, + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + addressStyle: const TextStyle(fontSize: 12), ), trailing: _plateBadge(context, plate, own: isOwn), ), diff --git a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart index 887af24..f89ba76 100644 --- a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart @@ -14,6 +14,7 @@ import 'package:hl_lieferservice/feature/delivery/overview/widget/sortable_deliv import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; +import 'package:hl_lieferservice/widget/orderer_hint.dart'; /// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer /// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte @@ -173,6 +174,7 @@ class _DeliverySortPageState extends State { Widget _singleDeliveryHint(Delivery single, TourDetails details) { final customer = details.customerOf(single); + final recipient = details.recipientOf(single); return Column( children: [ _hintCard( @@ -191,10 +193,11 @@ class _DeliverySortPageState extends State { ), ), ), - title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'), - subtitle: Text( - single.deliveryAddressSnapshot.oneLine, - style: const TextStyle(fontSize: 11), + title: Text(recipient.name), + subtitle: RecipientSubtitle( + recipient: recipient, + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + addressStyle: const TextStyle(fontSize: 11), ), ), ], diff --git a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart index 824984a..41e4ef8 100644 --- a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart +++ b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/widget/orderer_hint.dart'; /// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs. /// @@ -100,6 +101,7 @@ class _SortableDeliveryListState extends State { ); } final customer = widget.details.customerOf(delivery); + final recipient = widget.details.recipientOf(delivery); return ListTile( key: Key('reorder-item-${delivery.id}'), leading: CircleAvatar( @@ -111,10 +113,11 @@ class _SortableDeliveryListState extends State { ), ), ), - title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'), - subtitle: Text( - delivery.deliveryAddressSnapshot.oneLine, - style: const TextStyle(fontSize: 11), + title: Text(recipient.name), + subtitle: RecipientSubtitle( + recipient: recipient, + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + addressStyle: const TextStyle(fontSize: 11), ), trailing: const Icon(Icons.drag_handle), ); diff --git a/lib/feature/loading/presentation/loading_customer_page.dart b/lib/feature/loading/presentation/loading_customer_page.dart index e76e533..04a0ca8 100644 --- a/lib/feature/loading/presentation/loading_customer_page.dart +++ b/lib/feature/loading/presentation/loading_customer_page.dart @@ -23,6 +23,7 @@ import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart'; import 'package:hl_lieferservice/widget/scanner/item_matcher.dart'; import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart'; import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart'; +import 'package:hl_lieferservice/widget/orderer_hint.dart'; /// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner. /// @@ -523,6 +524,7 @@ class _CustomerBody extends StatelessWidget { @override Widget build(BuildContext context) { final customer = details.customerOf(delivery); + final recipient = details.recipientOf(delivery); // Items werden vom Aggregat-Helper schon nach Lager gruppiert // geliefert: Standardlager zuerst, danach Filiale alphabetisch. // Nicht-scanbare Positionen und `removed`-Items sind dabei schon @@ -611,7 +613,7 @@ class _CustomerBody extends StatelessWidget { // Kunden" wird dadurch räumlich klar. child: Row( children: [ - _CustomerAvatar(customer: customer), + _CustomerAvatar(customer: customer, name: recipient.name), const SizedBox(width: 12), Expanded( child: Column( @@ -630,14 +632,18 @@ class _CustomerBody extends StatelessWidget { ), const SizedBox(height: 2), Text( - customer?.name ?? '⟨Unbekannter Kunde⟩', + recipient.name, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 4), Text( - delivery.deliveryAddressSnapshot.oneLine, + recipient.address.oneLine, style: Theme.of(context).textTheme.bodyMedium, ), + if (recipient.differsFromOrderer) + OrdererHint( + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + ), if (delivery.state != DeliveryState.active) ...[ const SizedBox(height: 8), _DeliveryStateBadge(delivery: delivery), @@ -987,12 +993,13 @@ class _NothingToLoadHint extends StatelessWidget { /// /// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit /// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen. -/// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert, -/// einzelne Worte mit einem Buchstaben. +/// Initialen kommen aus dem angezeigten Namen (Empfänger vor Ort) — Vor- +/// und Zunamen kombiniert, einzelne Worte mit einem Buchstaben. class _CustomerAvatar extends StatelessWidget { - const _CustomerAvatar({required this.customer}); + const _CustomerAvatar({required this.customer, required this.name}); final Customer? customer; + final String name; /// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht /// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am @@ -1009,9 +1016,9 @@ class _CustomerAvatar extends StatelessWidget { ]; String get _initials { - final name = customer?.name.trim() ?? ''; - if (name.isEmpty) return '?'; - final parts = name + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + final parts = trimmed .split(RegExp(r'\s+')) .where((p) => p.isNotEmpty) .toList(growable: false); diff --git a/lib/feature/loading/presentation/loading_overview_page.dart b/lib/feature/loading/presentation/loading_overview_page.dart index 38c3f1d..0c2a61f 100644 --- a/lib/feature/loading/presentation/loading_overview_page.dart +++ b/lib/feature/loading/presentation/loading_overview_page.dart @@ -17,6 +17,7 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; +import 'package:hl_lieferservice/widget/orderer_hint.dart'; /// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren /// Artikeln und Soll/Ist-Mengen. @@ -406,6 +407,7 @@ class _OverviewTile extends StatelessWidget { final scannedAnyStandard = standardItems.any((it) => it.scanProgress.scannedQuantity > 0); final customer = details.customerOf(delivery); + final recipient = details.recipientOf(delivery); Color cardColor; Color borderColor; @@ -542,7 +544,7 @@ class _OverviewTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - customer?.name ?? '⟨Unbekannter Kunde⟩', + recipient.name, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -554,12 +556,16 @@ class _OverviewTile extends StatelessWidget { ), const SizedBox(height: 2), Text( - delivery.deliveryAddressSnapshot.oneLine, + recipient.address.oneLine, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), + if (recipient.differsFromOrderer) + OrdererHint( + ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩', + ), const SizedBox(height: 4), Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widget/orderer_hint.dart b/lib/widget/orderer_hint.dart new file mode 100644 index 0000000..c0998ce --- /dev/null +++ b/lib/widget/orderer_hint.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_recipient.dart'; + +/// Kompakter Hinweis in Listen-Kacheln, wenn an eine andere Person oder +/// Anschrift geliefert wird als an den Besteller: „Bestellt von: ". +/// +/// Geteilt zwischen Beladen-, Sortier- und Auslieferungs-Listen, damit der +/// Fahrer überall gleich erkennt, dass Empfänger ≠ Kunde ist. +class OrdererHint extends StatelessWidget { + const OrdererHint({super.key, required this.ordererName}); + + final String ordererName; + + @override + Widget build(BuildContext context) { + final color = Colors.deepOrange.shade800; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon(Icons.alt_route, size: 14, color: color), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Bestellt von: $ordererName', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + ); + } +} + +/// `ListTile.subtitle` für Lieferungen: Lieferanschrift und, falls der +/// Empfänger vom Besteller abweicht, darunter der [OrdererHint]. +class RecipientSubtitle extends StatelessWidget { + const RecipientSubtitle({ + super.key, + required this.recipient, + required this.ordererName, + this.addressStyle, + }); + + final DeliveryRecipient recipient; + final String ordererName; + final TextStyle? addressStyle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(recipient.address.oneLine, style: addressStyle), + if (recipient.differsFromOrderer) + OrdererHint(ordererName: ordererName), + ], + ); + } +} diff --git a/test/delivery_recipient_test.dart b/test/delivery_recipient_test.dart new file mode 100644 index 0000000..d46d204 --- /dev/null +++ b/test/delivery_recipient_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:hl_lieferservice/domain/entity/address.dart'; +import 'package:hl_lieferservice/domain/entity/contact_source.dart'; +import 'package:hl_lieferservice/domain/entity/customer.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; + +const _ordererAddress = Address( + street: 'Neukirchener Str.', + houseNumber: '73', + postalCode: '41470', + city: 'Neuss', + country: 'DE', +); + +const _otherAddress = Address( + street: 'Marienstr.', + houseNumber: '3', + postalCode: '41564', + city: 'Kaarst', + country: 'DE', +); + +ContactSource _source(ContactRole role, String name1, {String? name2}) => + ContactSource( + id: 'src-${role.name}', + deliveryId: 'd1', + role: role, + anrede: 'Frau', + name1: name1, + name2: name2, + ); + +TourDetails _details({ + required Address deliveryAddress, + List sources = const [], +}) => TourDetails( + tour: Tour( + id: 't1', + accountId: 414, + date: DateTime(2026, 9, 25), + syncedAt: DateTime(2026), + ), + deliveries: [ + Delivery( + id: 'd1', + tourId: 't1', + customerId: 'c1', + contactPersonIds: const [], + deliveryAddressSnapshot: deliveryAddress, + erpBelegartId: 24, + erpBelegnummer: 'V-1', + state: DeliveryState.active, + sortOrder: 1, + prepaidAmount: 0, + paymentMethodId: 'm-cash', + items: const [], + ), + ], + customers: const { + 'c1': Customer( + id: 'c1', + name: 'Kraus Heike', + erpCustomerId: 4711, + address: _ordererAddress, + ), + }, + contacts: const {}, + articles: const {}, + warehouses: const {}, + notesByDeliveryId: const {}, + creditsByDeliveryId: const {}, + paymentsByDeliveryId: const {}, + services: const [], + serviceValuesByDeliveryId: const {}, + contactSourcesByDeliveryId: {'d1': sources}, + contactChannelsBySourceId: const {}, +); + +void main() { + test('ohne eigene Lieferadresse ist der Besteller der Empfänger', () { + final details = _details( + deliveryAddress: _ordererAddress, + sources: [_source(ContactRole.header, 'Kraus Heike')], + ); + final r = details.recipientOf(details.deliveries.single); + + expect(r.name, 'Kraus Heike'); + expect(r.differsFromOrderer, isFalse); + }); + + test('andere Person an anderer Adresse weicht ab', () { + final details = _details( + deliveryAddress: _otherAddress, + sources: [ + _source(ContactRole.header, 'Kraus Heike'), + _source(ContactRole.delivery, 'Stülp', name2: 'Hans-Dieter'), + ], + ); + final r = details.recipientOf(details.deliveries.single); + + // Name ohne Anrede, wie der Kundenname in den Listen. + expect(r.name, 'Stülp Hans-Dieter'); + expect(r.address, _otherAddress); + expect(r.contact?.role, ContactRole.delivery); + expect(r.differsFromOrderer, isTrue); + }); + + test('andere Person an derselben Adresse weicht ab', () { + final details = _details( + deliveryAddress: _ordererAddress, + sources: [ + _source(ContactRole.header, 'Kraus Heike'), + _source(ContactRole.delivery, 'Kraus Peter'), + ], + ); + + expect( + details.recipientOf(details.deliveries.single).differsFromOrderer, + isTrue, + ); + }); + + test('gleiche Person, nur andere Schreibweise, weicht nicht ab', () { + final details = _details( + deliveryAddress: const Address( + street: 'neukirchener str.', + houseNumber: '73', + postalCode: '41470', + city: 'NEUSS', + country: 'DE', + ), + sources: [ + _source(ContactRole.header, 'Kraus Heike'), + _source(ContactRole.delivery, 'Kraus heike'), + ], + ); + + expect( + details.recipientOf(details.deliveries.single).differsFromOrderer, + isFalse, + ); + }); + + test('abweichende Adresse ohne eigenen Namen: Besteller-Name, Hinweis an', + () { + final details = _details(deliveryAddress: _otherAddress); + final r = details.recipientOf(details.deliveries.single); + + expect(r.name, 'Kraus Heike'); + expect(r.differsFromOrderer, isTrue); + }); +}