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

@ -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,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<void> _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<MergedContactSource> sources;
Future<void> _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,
),
],
],