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(' ');
}
/// 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
/// Komponenten werden ausgeblendet.
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 '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<CustomerContact> contactsOf(Delivery delivery) sync* {
for (final id in delivery.contactPersonIds) {
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(
appBar: AppBar(
backgroundColor: theme.primaryColor,
foregroundColor: theme.colorScheme.onPrimary,
title: Text(customer?.name ?? 'Lieferung'),
title: Text(recipient.name),
),
body: Column(
children: [

View File

@ -162,7 +162,9 @@ class _SignatureViewState extends State<SignatureView> {
@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<SignatureView> {
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<SignatureView> {
),
const SizedBox(height: 4),
Text(
'${customer?.address.city ?? ''}, den $date',
'${recipient.address.city}, den $date',
style: theme.textTheme.bodySmall,
),
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/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<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 ──────────────────────────────────────────────────────────────
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<MergedContactSource> mergedSources;
/// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in
@ -172,11 +310,9 @@ class _QuickActions extends StatelessWidget {
}
Future<void> _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<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 {
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,12 +1050,9 @@ 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.
class _AllContactsCard extends StatelessWidget {
const _AllContactsCard({required this.sources});
final List<MergedContactSource> sources;
Future<void> _launch(BuildContext context, Uri uri, String label) async {
/// Öffnet einen Kontaktkanal (tel:/mailto:/https) — geteilt von allen
/// Karten, die [_ChannelRow]s zeigen.
Future<void> _launchContact(BuildContext context, Uri uri, String label) async {
final ok = await launchUrl(uri);
if (!ok && context.mounted) {
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
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,
),
],
],

View File

@ -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,

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/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<DeliverySelectionPage> {
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<DeliverySelectionPage> {
});
},
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<DeliverySelectionPage> {
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<DeliverySelectionPage> {
: 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),
),

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/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<DeliverySortPage> {
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<DeliverySortPage> {
),
),
),
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),
),
),
],

View File

@ -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<SortableDeliveryList> {
);
}
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<SortableDeliveryList> {
),
),
),
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),
);

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/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);

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/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,

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