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>
This commit is contained in:
Dennis Nemec
2026-09-25 15:51:31 +02:00
parent 99247b4b67
commit 857c92031a
14 changed files with 600 additions and 61 deletions

View File

@ -125,6 +125,17 @@ class ContactSource {
return parts.join(' '); 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 = <String>[
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 /// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere
/// Komponenten werden ausgeblendet. /// Komponenten werden ausgeblendet.
String? get subtitle { String? get subtitle {

View File

@ -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;
}

View File

@ -1,9 +1,11 @@
import 'address.dart';
import 'article.dart'; import 'article.dart';
import 'contact_source.dart'; import 'contact_source.dart';
import 'customer.dart'; import 'customer.dart';
import 'delivery.dart'; import 'delivery.dart';
import 'delivery_credit.dart'; import 'delivery_credit.dart';
import 'delivery_payment.dart'; import 'delivery_payment.dart';
import 'delivery_recipient.dart';
import 'delivery_item.dart'; import 'delivery_item.dart';
import 'delivery_note.dart'; import 'delivery_note.dart';
import 'delivery_service_value.dart'; import 'delivery_service_value.dart';
@ -97,6 +99,52 @@ class TourDetails {
Customer? customerOf(Delivery delivery) => customers[delivery.customerId]; 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<CustomerContact> contactsOf(Delivery delivery) sync* { Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
for (final id in delivery.contactPersonIds) { for (final id in delivery.contactPersonIds) {
final c = contacts[id]; final c = contacts[id];

View File

@ -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( return Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: theme.primaryColor, backgroundColor: theme.primaryColor,
foregroundColor: theme.colorScheme.onPrimary, foregroundColor: theme.colorScheme.onPrimary,
title: Text(customer?.name ?? 'Lieferung'), title: Text(recipient.name),
), ),
body: Column( body: Column(
children: [ children: [

View File

@ -162,7 +162,9 @@ class _SignatureViewState extends State<SignatureView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(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 date = DateFormat('dd.MM.yyyy').format(DateTime.now());
final isDriver = _stage == _SignStage.driver; final isDriver = _stage == _SignStage.driver;
@ -198,7 +200,7 @@ class _SignatureViewState extends State<SignatureView> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
Text( Text(
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}', 'Lieferung an: ${recipient.name}',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -213,7 +215,7 @@ class _SignatureViewState extends State<SignatureView> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${customer?.address.city ?? ''}, den $date', '${recipient.address.city}, den $date',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@ -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/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.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_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_recipient.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.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_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.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 // damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND
// „Kundenstamm") aufpoppt. // „Kundenstamm") aufpoppt.
final mergedSources = details.mergedContactSourcesOf(delivery); 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( return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
children: [ 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'), _SectionHeader(text: 'Beleg'),
const SizedBox(height: 8), const SizedBox(height: 8),
_BelegCard(delivery: delivery), _BelegCard(delivery: delivery),
@ -47,7 +64,7 @@ class StepInfo extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_QuickActions( _QuickActions(
delivery: delivery, delivery: delivery,
customer: customer, recipient: recipient,
mergedSources: mergedSources, mergedSources: mergedSources,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@ -55,9 +72,19 @@ class StepInfo extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_AgreementsCard(delivery: delivery), _AgreementsCard(delivery: delivery),
const SizedBox(height: 24), const SizedBox(height: 24),
_SectionHeader(text: 'Kundeninformationen'), _SectionHeader(
text: differs ? 'Besteller (Kunde)' : 'Kundeninformationen',
),
const SizedBox(height: 8), 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) ...[ if (mergedSources.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
_SectionHeader(text: 'Alle Kontaktinfos'), _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<ContactChannel> 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 ────────────────────────────────────────────────────────────── // ─── Beleg ──────────────────────────────────────────────────────────────
class _BelegCard extends StatelessWidget { class _BelegCard extends StatelessWidget {
@ -125,12 +263,12 @@ enum _StatusAction { hold, cancel, resume }
class _QuickActions extends StatelessWidget { class _QuickActions extends StatelessWidget {
const _QuickActions({ const _QuickActions({
required this.delivery, required this.delivery,
required this.customer, required this.recipient,
required this.mergedSources, required this.mergedSources,
}); });
final Delivery delivery; final Delivery delivery;
final Customer? customer; final DeliveryRecipient recipient;
final List<MergedContactSource> mergedSources; final List<MergedContactSource> mergedSources;
/// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in /// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in
@ -172,11 +310,9 @@ class _QuickActions extends StatelessWidget {
} }
Future<void> _launchMaps(BuildContext context) async { Future<void> _launchMaps(BuildContext context) async {
final address = customer != null // Navigiert wird zur Lieferadresse — nicht zur Anschrift des
? '${customer!.address.street} ${customer!.address.houseNumber}, ' // Bestellers, die abweichen kann.
'${customer!.address.postalCode} ${customer!.address.city}' final encoded = Uri.encodeComponent(recipient.address.oneLine);
: delivery.deliveryAddressSnapshot.oneLine;
final encoded = Uri.encodeComponent(address);
// Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS. // Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS.
final uri = Uri.parse( final uri = Uri.parse(
'https://www.google.com/maps/search/?api=1&query=$encoded', 'https://www.google.com/maps/search/?api=1&query=$encoded',
@ -495,11 +631,19 @@ class _AgreementsCard extends StatelessWidget {
// ─── Kunde + Kontakte ─────────────────────────────────────────────────── // ─── Kunde + Kontakte ───────────────────────────────────────────────────
class _CustomerCard extends StatelessWidget { 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 Customer? customer;
final List<CustomerContact> contacts; final List<CustomerContact> 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<ContactChannel>? ordererChannels;
Future<void> _call(BuildContext context, String phone) async { Future<void> _call(BuildContext context, String phone) async {
final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
if (!ok && context.mounted) { 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) ...[ if (contacts.isNotEmpty) ...[
const Divider(height: 24), const Divider(height: 24),
Text( Text(
@ -868,12 +1050,9 @@ class _ArticleRow extends StatelessWidget {
// Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen. // Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen.
// Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab. // Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab.
class _AllContactsCard extends StatelessWidget { /// Öffnet einen Kontaktkanal (tel:/mailto:/https) — geteilt von allen
const _AllContactsCard({required this.sources}); /// Karten, die [_ChannelRow]s zeigen.
Future<void> _launchContact(BuildContext context, Uri uri, String label) async {
final List<MergedContactSource> sources;
Future<void> _launch(BuildContext context, Uri uri, String label) async {
final ok = await launchUrl(uri); final ok = await launchUrl(uri);
if (!ok && context.mounted) { if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -882,6 +1061,11 @@ class _AllContactsCard extends StatelessWidget {
} }
} }
class _AllContactsCard extends StatelessWidget {
const _AllContactsCard({required this.sources});
final List<MergedContactSource> sources;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
@ -895,7 +1079,7 @@ class _AllContactsCard extends StatelessWidget {
if (i > 0) const Divider(height: 1), if (i > 0) const Divider(height: 1),
_ContactSourceTile( _ContactSourceTile(
source: sources[i], source: sources[i],
onLaunch: _launch, onLaunch: _launchContact,
), ),
], ],
], ],

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/article.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.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.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/detail/presentation/delivery_detail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.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/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: /// Entscheidet beim Tap auf eine Lieferung, wohin navigiert wird:
/// ///
@ -408,6 +408,7 @@ class _NextDeliveryCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final delivery = entry.delivery; final delivery = entry.delivery;
final customer = details.customerOf(delivery); final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final pendingExternal = details.pendingExternalWarehouseGroups(delivery); final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
final hasPendingExternal = pendingExternal.isNotEmpty; final hasPendingExternal = pendingExternal.isNotEmpty;
@ -432,15 +433,19 @@ class _NextDeliveryCard extends StatelessWidget {
children: [ children: [
_HeaderRow( _HeaderRow(
position: entry.position, position: entry.position,
customer: customer, name: recipient.name,
desiredTime: delivery.desiredTime, desiredTime: delivery.desiredTime,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_InfoRow( _InfoRow(
icon: Icons.location_on_outlined, icon: Icons.location_on_outlined,
text: delivery.deliveryAddressSnapshot.oneLine, text: recipient.address.oneLine,
emphasized: true, emphasized: true,
), ),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
if (delivery.specialAgreements != null && if (delivery.specialAgreements != null &&
delivery.specialAgreements!.isNotEmpty) ...[ delivery.specialAgreements!.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
@ -492,12 +497,12 @@ class _NextDeliveryCard extends StatelessWidget {
class _HeaderRow extends StatelessWidget { class _HeaderRow extends StatelessWidget {
const _HeaderRow({ const _HeaderRow({
required this.position, required this.position,
required this.customer, required this.name,
required this.desiredTime, required this.desiredTime,
}); });
final int position; final int position;
final Customer? customer; final String name;
final String? desiredTime; final String? desiredTime;
@override @override
@ -524,7 +529,7 @@ class _HeaderRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
customer?.name ?? '⟨Unbekannter Kunde⟩', name,
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -791,6 +796,7 @@ class _DeliveryTile extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final delivery = entry.delivery; final delivery = entry.delivery;
final customer = details.customerOf(delivery); final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final pendingExternal = details.pendingExternalWarehouseGroups(delivery); final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
final hasPendingExternal = pendingExternal.isNotEmpty; final hasPendingExternal = pendingExternal.isNotEmpty;
@ -828,7 +834,7 @@ class _DeliveryTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
customer?.name ?? '⟨Unbekannter Kunde⟩', recipient.name,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -840,12 +846,16 @@ class _DeliveryTile extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
delivery.deliveryAddressSnapshot.oneLine, recipient.address.oneLine,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -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/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.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/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 /// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen
/// Lieferungen aus dem gemeinsamen Tour-Pool. /// Lieferungen aus dem gemeinsamen Tour-Pool.
@ -241,6 +242,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final delivery = available[index]; final delivery = available[index];
final customer = details.customerOf(delivery); final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final isSelected = _selectedIds.contains(delivery.id); final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile( return CheckboxListTile(
key: ValueKey('available-${delivery.id}'), key: ValueKey('available-${delivery.id}'),
@ -258,12 +260,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
}); });
}, },
title: Text( title: Text(
customer?.name ?? '⟨Unbekannter Kunde⟩', recipient.name,
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
subtitle: Text( subtitle: RecipientSubtitle(
delivery.deliveryAddressSnapshot.oneLine, recipient: recipient,
style: const TextStyle(fontSize: 12), ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 12),
), ),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
); );
@ -289,6 +292,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
final isOwn = delivery.assignedCarId == widget.selectedCarId; final isOwn = delivery.assignedCarId == widget.selectedCarId;
final plate = _plateFor(delivery.assignedCarId); final plate = _plateFor(delivery.assignedCarId);
final customer = details.customerOf(delivery); final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
return Material( return Material(
color: isOwn color: isOwn
@ -310,15 +314,16 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
: theme.colorScheme.onSurfaceVariant, : theme.colorScheme.onSurfaceVariant,
), ),
title: Text( title: Text(
customer?.name ?? '⟨Unbekannter Kunde⟩', recipient.name,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null, color: isOwn ? theme.colorScheme.primary : null,
), ),
), ),
subtitle: Text( subtitle: RecipientSubtitle(
delivery.deliveryAddressSnapshot.oneLine, recipient: recipient,
style: const TextStyle(fontSize: 12), ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 12),
), ),
trailing: _plateBadge(context, plate, own: isOwn), trailing: _plateBadge(context, plate, own: isOwn),
), ),

View File

@ -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/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.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/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer /// 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 /// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte
@ -173,6 +174,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
Widget _singleDeliveryHint(Delivery single, TourDetails details) { Widget _singleDeliveryHint(Delivery single, TourDetails details) {
final customer = details.customerOf(single); final customer = details.customerOf(single);
final recipient = details.recipientOf(single);
return Column( return Column(
children: [ children: [
_hintCard( _hintCard(
@ -191,10 +193,11 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
), ),
), ),
), ),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'), title: Text(recipient.name),
subtitle: Text( subtitle: RecipientSubtitle(
single.deliveryAddressSnapshot.oneLine, recipient: recipient,
style: const TextStyle(fontSize: 11), ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 11),
), ),
), ),
], ],

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.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. /// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs.
/// ///
@ -100,6 +101,7 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
); );
} }
final customer = widget.details.customerOf(delivery); final customer = widget.details.customerOf(delivery);
final recipient = widget.details.recipientOf(delivery);
return ListTile( return ListTile(
key: Key('reorder-item-${delivery.id}'), key: Key('reorder-item-${delivery.id}'),
leading: CircleAvatar( leading: CircleAvatar(
@ -111,10 +113,11 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
), ),
), ),
), ),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'), title: Text(recipient.name),
subtitle: Text( subtitle: RecipientSubtitle(
delivery.deliveryAddressSnapshot.oneLine, recipient: recipient,
style: const TextStyle(fontSize: 11), ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 11),
), ),
trailing: const Icon(Icons.drag_handle), trailing: const Icon(Icons.drag_handle),
); );

View File

@ -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/item_matcher.dart';
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.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/scanner/scan_code_parser.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner. /// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner.
/// ///
@ -523,6 +524,7 @@ class _CustomerBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final customer = details.customerOf(delivery); final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
// Items werden vom Aggregat-Helper schon nach Lager gruppiert // Items werden vom Aggregat-Helper schon nach Lager gruppiert
// geliefert: Standardlager zuerst, danach Filiale alphabetisch. // geliefert: Standardlager zuerst, danach Filiale alphabetisch.
// Nicht-scanbare Positionen und `removed`-Items sind dabei schon // Nicht-scanbare Positionen und `removed`-Items sind dabei schon
@ -611,7 +613,7 @@ class _CustomerBody extends StatelessWidget {
// Kunden" wird dadurch räumlich klar. // Kunden" wird dadurch räumlich klar.
child: Row( child: Row(
children: [ children: [
_CustomerAvatar(customer: customer), _CustomerAvatar(customer: customer, name: recipient.name),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@ -630,14 +632,18 @@ class _CustomerBody extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
customer?.name ?? '⟨Unbekannter Kunde⟩', recipient.name,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
delivery.deliveryAddressSnapshot.oneLine, recipient.address.oneLine,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
if (delivery.state != DeliveryState.active) ...[ if (delivery.state != DeliveryState.active) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_DeliveryStateBadge(delivery: delivery), _DeliveryStateBadge(delivery: delivery),
@ -987,12 +993,13 @@ class _NothingToLoadHint extends StatelessWidget {
/// ///
/// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit /// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit
/// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen. /// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen.
/// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert, /// Initialen kommen aus dem angezeigten Namen (Empfänger vor Ort) — Vor-
/// einzelne Worte mit einem Buchstaben. /// und Zunamen kombiniert, einzelne Worte mit einem Buchstaben.
class _CustomerAvatar extends StatelessWidget { class _CustomerAvatar extends StatelessWidget {
const _CustomerAvatar({required this.customer}); const _CustomerAvatar({required this.customer, required this.name});
final Customer? customer; final Customer? customer;
final String name;
/// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht /// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht
/// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am /// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am
@ -1009,9 +1016,9 @@ class _CustomerAvatar extends StatelessWidget {
]; ];
String get _initials { String get _initials {
final name = customer?.name.trim() ?? ''; final trimmed = name.trim();
if (name.isEmpty) return '?'; if (trimmed.isEmpty) return '?';
final parts = name final parts = trimmed
.split(RegExp(r'\s+')) .split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty) .where((p) => p.isNotEmpty)
.toList(growable: false); .toList(growable: false);

View File

@ -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/feature/loading/presentation/loading_customer_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.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/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren /// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren
/// Artikeln und Soll/Ist-Mengen. /// Artikeln und Soll/Ist-Mengen.
@ -406,6 +407,7 @@ class _OverviewTile extends StatelessWidget {
final scannedAnyStandard = final scannedAnyStandard =
standardItems.any((it) => it.scanProgress.scannedQuantity > 0); standardItems.any((it) => it.scanProgress.scannedQuantity > 0);
final customer = details.customerOf(delivery); final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
Color cardColor; Color cardColor;
Color borderColor; Color borderColor;
@ -542,7 +544,7 @@ class _OverviewTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
customer?.name ?? '⟨Unbekannter Kunde⟩', recipient.name,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -554,12 +556,16 @@ class _OverviewTile extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
delivery.deliveryAddressSnapshot.oneLine, recipient.address.oneLine,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -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: <Name>".
///
/// 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),
],
);
}
}

View File

@ -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<ContactSource> 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);
});
}