import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.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/delivery_item.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'; /// Step 1 — Informationen zur Lieferung. /// /// Schnellaktionen oben (Anrufen, Maps, Status-Menü), darunter /// Sondervereinbarungen + Wunschzeit, Kunden-Block (Name + Adresse + /// Kontakte), zu liefernde Artikel inkl. nicht-scanbarer /// Dienstleistungen (z. B. „Aufbauservice", „Anlieferung"). Reine /// Anzeige — Schreib-Aktionen wandern in die anderen Steps. class StepInfo extends StatelessWidget { const StepInfo({super.key, required this.delivery, required this.details}); final Delivery delivery; final TourDetails details; @override Widget build(BuildContext context) { final customer = details.customerOf(delivery); final contacts = details.contactsOf(delivery).toList(); // Vereint Quellen mit identischem Namen + identischer Channel-Liste, // damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND // „Kundenstamm") aufpoppt. final mergedSources = details.mergedContactSourcesOf(delivery); return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), children: [ _SectionHeader(text: 'Beleg'), const SizedBox(height: 8), _BelegCard(delivery: delivery), const SizedBox(height: 24), _SectionHeader(text: 'Schnellaktionen'), const SizedBox(height: 8), _QuickActions( delivery: delivery, customer: customer, mergedSources: mergedSources, ), const SizedBox(height: 24), _SectionHeader(text: 'Sondervereinbarungen'), const SizedBox(height: 8), _AgreementsCard(delivery: delivery), const SizedBox(height: 24), _SectionHeader(text: 'Kundeninformationen'), const SizedBox(height: 8), _CustomerCard(customer: customer, contacts: contacts), if (mergedSources.isNotEmpty) ...[ const SizedBox(height: 24), _SectionHeader(text: 'Alle Kontaktinfos'), const SizedBox(height: 8), _AllContactsCard(sources: mergedSources), ], const SizedBox(height: 24), _SectionHeader(text: 'Zu liefernde Artikel'), const SizedBox(height: 8), _ArticleList(delivery: delivery, details: details), ], ); } } class _SectionHeader extends StatelessWidget { const _SectionHeader({required this.text}); final String text; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Text( text, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ); } } // ─── Beleg ────────────────────────────────────────────────────────────── class _BelegCard extends StatelessWidget { const _BelegCard({required this.delivery}); final Delivery delivery; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Card( margin: EdgeInsets.zero, child: ListTile( leading: Icon( Icons.receipt_long_outlined, color: theme.colorScheme.primary, ), title: const Text('Belegnummer'), subtitle: Text( delivery.erpBelegnummer, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ), ); } } // ─── Schnellaktionen ──────────────────────────────────────────────────── enum _StatusAction { hold, cancel, resume } class _QuickActions extends StatelessWidget { const _QuickActions({ required this.delivery, required this.customer, required this.mergedSources, }); final Delivery delivery; final Customer? customer; final List mergedSources; /// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in /// Sheet-Anzeigeform aufbereitet: /// /// * Phone vor Mobile pro Quelle, Quelle-Reihenfolge wie vom Backend. /// * Duplikate gleicher Nummer (z. B. dieselbe Mobilnummer in Beleg- /// und Kunden­stammadresse) werden auf den ersten Eintrag gemerged, /// die zweite Rolle ergänzt das Label. /// * Whitespace wird zum Vergleich entfernt, damit „+49 89 123" und /// „+498 9123" als dieselbe Nummer durchgehen, falls der Anwender /// sie mal mit, mal ohne Leerzeichen erfasst hat. Angezeigt wird der /// Originalwert der ersten Quelle. List<_CallableNumber> _collectCallableNumbers() { final out = <_CallableNumber>[]; final byNormalized = {}; for (final src in mergedSources) { for (final ch in src.channels) { if (ch.kind != ContactKind.phone && ch.kind != ContactKind.mobile) { continue; } final normalized = ch.value.replaceAll(RegExp(r'\s+'), ''); if (normalized.isEmpty) continue; final existing = byNormalized[normalized]; if (existing != null) { out[existing] = out[existing].withAdditionalRole(src.rolesLabel); continue; } byNormalized[normalized] = out.length; out.add(_CallableNumber( value: ch.value, kind: ch.kind, roleLabel: src.rolesLabel, displayName: src.displayName, )); } } return out; } 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); // Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS. final uri = Uri.parse( 'https://www.google.com/maps/search/?api=1&query=$encoded', ); await launchUrl(uri, mode: LaunchMode.externalApplication); } Future _call(BuildContext context, String phone) async { final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Anruf konnte nicht gestartet werden: $phone')), ); } } /// Einstiegspunkt für den „Anrufen"-Button. /// * Genau eine Nummer ⇒ direkt anrufen. /// * Mehrere Nummern ⇒ Bottom-Sheet, danach anrufen. /// Aufgerufen wird die Methode nur, wenn `numbers.isNotEmpty` — /// die Validierung sitzt im `build` und schaltet den Button sonst aus. Future _onCallTapped( BuildContext context, List<_CallableNumber> numbers, ) async { if (numbers.length == 1) { await _call(context, numbers.first.value); return; } final picked = await showModalBottomSheet<_CallableNumber>( context: context, showDragHandle: true, builder: (sheetContext) { return SafeArea( child: ListView( shrinkWrap: true, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), child: Text( 'Nummer wählen', style: Theme.of(sheetContext).textTheme.titleMedium, ), ), for (final n in numbers) ListTile( leading: Icon( n.kind == ContactKind.mobile ? Icons.smartphone : Icons.call, color: Theme.of(sheetContext).colorScheme.primary, ), title: Text(n.value), subtitle: Text( n.displayName == null ? n.roleLabel : '${n.displayName} · ${n.roleLabel}', ), onTap: () => Navigator.of(sheetContext).pop(n), ), ], ), ); }, ); if (picked == null || !context.mounted) return; await _call(context, picked.value); } Future _askReason(BuildContext context, String title) async { final controller = TextEditingController(); final result = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(title), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( labelText: 'Grund', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(null), child: const Text('Abbrechen'), ), FilledButton( onPressed: () { final text = controller.text.trim(); if (text.isEmpty) return; Navigator.of(dialogContext).pop(text); }, child: const Text('Bestätigen'), ), ], ), ); return result; } Future _onStatusSelected( BuildContext context, _StatusAction action, ) async { final tourBloc = context.read(); switch (action) { case _StatusAction.hold: final reason = await _askReason(context, 'Lieferung pausieren'); if (reason == null) return; tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason)); case _StatusAction.cancel: final reason = await _askReason(context, 'Lieferung abbrechen'); if (reason == null) return; tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason)); case _StatusAction.resume: tourBloc.add(ResumeDelivery(deliveryId: delivery.id)); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final numbers = _collectCallableNumbers(); final hasPhone = numbers.isNotEmpty; final isActive = delivery.state == DeliveryState.active; final isHeld = delivery.state == DeliveryState.held; return Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Row( children: [ Expanded( child: _QuickButton( icon: Icons.phone, label: 'Anrufen', enabled: hasPhone, onTap: hasPhone ? () => _onCallTapped(context, numbers) : null, ), ), Expanded( child: _QuickButton( icon: Icons.map_outlined, label: 'Maps', enabled: true, onTap: () => _launchMaps(context), ), ), PopupMenuButton<_StatusAction>( icon: Icon( Icons.more_vert, color: theme.colorScheme.primary, ), tooltip: 'Status ändern', onSelected: (a) => _onStatusSelected(context, a), itemBuilder: (context) { if (isActive) { return const [ PopupMenuItem( value: _StatusAction.hold, child: Row( children: [ Icon(Icons.pause_circle_outline, color: Colors.orange), SizedBox(width: 12), Text('Pausieren'), ], ), ), PopupMenuItem( value: _StatusAction.cancel, child: Row( children: [ Icon(Icons.cancel_outlined, color: Colors.red), SizedBox(width: 12), Text('Abbrechen'), ], ), ), ]; } if (isHeld) { return const [ PopupMenuItem( value: _StatusAction.resume, child: Row( children: [ Icon(Icons.play_circle_outline, color: Colors.blueAccent), SizedBox(width: 12), Text('Fortsetzen'), ], ), ), ]; } // canceled / completed: keine Statusänderung mehr. return const [ PopupMenuItem( enabled: false, child: Text('Keine Aktionen verfügbar'), ), ]; }, ), ], ), ), ); } } class _QuickButton extends StatelessWidget { const _QuickButton({ required this.icon, required this.label, required this.enabled, required this.onTap, }); final IconData icon; final String label; final bool enabled; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: enabled ? onTap : null, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ IconButton.filled( onPressed: enabled ? onTap : null, icon: Icon(icon), ), Text( label, style: TextStyle( fontSize: 12, color: enabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } } // ─── Sondervereinbarungen + Wunschzeit ────────────────────────────────── class _AgreementsCard extends StatelessWidget { const _AgreementsCard({required this.delivery}); final Delivery delivery; @override Widget build(BuildContext context) { final theme = Theme.of(context); final desiredTime = delivery.desiredTime; final hasDesired = desiredTime != null && desiredTime.isNotEmpty; final agreements = delivery.specialAgreements; final hasAgreements = agreements != null && agreements.isNotEmpty; return Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (hasDesired) ...[ Row( children: [ Icon( Icons.schedule, color: theme.colorScheme.primary, size: 28, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Wunschtermin', style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), Text( desiredTime, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ), ], ), ), ], ), if (hasAgreements) const Divider(height: 24), ], if (hasAgreements) Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 28, ), const SizedBox(width: 12), Expanded( child: Text( agreements, style: const TextStyle(fontSize: 14), ), ), ], ), if (!hasDesired && !hasAgreements) Text( 'Keine Sondervereinbarungen.', style: TextStyle( color: theme.colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), ), ], ), ), ); } } // ─── Kunde + Kontakte ─────────────────────────────────────────────────── class _CustomerCard extends StatelessWidget { const _CustomerCard({required this.customer, required this.contacts}); final Customer? customer; final List contacts; Future _call(BuildContext context, String phone) async { final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Anruf konnte nicht gestartet werden: $phone')), ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final c = customer; return Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.person, color: theme.colorScheme.primary), const SizedBox(width: 10), Expanded( child: Text( c?.name ?? '⟨Unbekannter Kunde⟩', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), ), ], ), if (c != null) ...[ const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.home_outlined, color: theme.colorScheme.primary, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${c.address.street} ${c.address.houseNumber}', style: const TextStyle(fontSize: 14), ), Text( '${c.address.postalCode} ${c.address.city}', style: const TextStyle(fontSize: 14), ), ], ), ), ], ), ], if (contacts.isNotEmpty) ...[ const Divider(height: 24), Text( 'Ansprechpartner', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), for (final contact in contacts) ...[ _ContactRow(contact: contact, onCall: _call), ], ], ], ), ), ); } } class _ContactRow extends StatelessWidget { const _ContactRow({required this.contact, required this.onCall}); final CustomerContact contact; final Future Function(BuildContext, String) onCall; @override Widget build(BuildContext context) { final theme = Theme.of(context); final phone = contact.phone; final hasPhone = phone != null && phone.isNotEmpty; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon( Icons.person_outline, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( contact.name, style: const TextStyle(fontWeight: FontWeight.w600), ), if (hasPhone) Text( phone, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ) else if (contact.email != null) Text( contact.email!, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), if (hasPhone) IconButton( icon: Icon(Icons.call, color: theme.colorScheme.primary), tooltip: 'Anrufen', onPressed: () => onCall(context, phone), ), ], ), ); } } // ─── Artikelliste (inkl. nicht-scanbarer Dienstleistungen) ────────────── class _ArticleList extends StatelessWidget { const _ArticleList({required this.delivery, required this.details}); final Delivery delivery; final TourDetails details; @override Widget build(BuildContext context) { final theme = Theme.of(context); // ALLE Items, auch nicht-scanbare Dienstleistungen — Step 1 ist die // Übersicht „was geht heute raus". Entfernte werden durchgestrichen // mit angezeigt (Fahrer soll wissen, dass da was war). // Sortierung: nach Belegzeile, und innerhalb einer Belegzeile der // Oberartikel VOR seinen Komponenten — damit Komponenten direkt unter // ihrem Oberartikel (eingerückt) erscheinen. final items = List.of(delivery.items) ..sort((a, b) { final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); if (byLine != 0) return byLine; final byParent = (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); if (byParent != 0) return byParent; return (a.komponentenArtikelNr ?? '') .compareTo(b.komponentenArtikelNr ?? ''); }); if (items.isEmpty) { return Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: Text( 'Keine Artikel hinterlegt.', style: TextStyle( fontStyle: FontStyle.italic, color: theme.colorScheme.onSurfaceVariant, ), ), ), ); } return Card( margin: EdgeInsets.zero, child: Column( children: [ for (int i = 0; i < items.length; i++) ...[ _ArticleRow(item: items[i], details: details), if (i < items.length - 1) const Divider(height: 1, indent: 16, endIndent: 16), ], ], ), ); } } class _ArticleRow extends StatelessWidget { const _ArticleRow({required this.item, required this.details}); final DeliveryItem item; final TourDetails details; @override Widget build(BuildContext context) { final theme = Theme.of(context); final article = details.articleOf(item.articleId); final warehouse = details.warehouseOf(item.warehouseId); final isScannable = article?.scannable ?? false; final removed = item.isRemoved; return ListTile( // Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber). contentPadding: EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16), leading: CircleAvatar( backgroundColor: removed ? theme.colorScheme.surfaceContainerHighest : theme.colorScheme.primary, foregroundColor: removed ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onPrimary, child: Text( '${item.requiredQuantity}×', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), ), title: Text( '${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}', style: TextStyle( fontWeight: FontWeight.w600, decoration: removed ? TextDecoration.lineThrough : null, color: removed ? theme.colorScheme.onSurfaceVariant : null, ), ), subtitle: Text( [ article?.articleNumber ?? item.articleId, if (warehouse != null) warehouse.name, if (!isScannable) 'Dienstleistung', if (item.unitPrice > 0) '${item.unitPrice.toStringAsFixed(2)} € / Stück', ].join(' · '), style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, decoration: removed ? TextDecoration.lineThrough : null, ), ), trailing: isScannable ? Text( '${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}', style: const TextStyle( fontWeight: FontWeight.bold, ), ) : null, ); } } // ─── Alle Kontaktinfos aus dem ERP ────────────────────────────────────── // // Die obige `_CustomerCard` zeigt nur die klassischen Ansprechpartner // (Stammdaten-Liste pro Kunde). Diese Sektion zeigt die *belegspezifischen* // Adress-Quellen aus dem ERP: Belegadresse / Lieferadresse / Rechnungs- // adresse / Ansprechpartner / Kundenstamm — jede mit ihrem eigenen // 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 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( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < sources.length; i++) ...[ if (i > 0) const Divider(height: 1), _ContactSourceTile( source: sources[i], onLaunch: _launch, ), ], ], ), ), ); } } class _ContactSourceTile extends StatelessWidget { const _ContactSourceTile({ required this.source, required this.onLaunch, }); final MergedContactSource source; final Future Function(BuildContext, Uri, String) onLaunch; @override Widget build(BuildContext context) { final theme = Theme.of(context); final channels = source.channels; return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Rollen-Label: alle zusammengeführten Rollen mit `·` getrennt // (z. B. „Belegadresse · Kundenstamm"). Text( source.rolesLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w700, ), ), if (source.displayName != null) ...[ const SizedBox(height: 2), Text( source.displayName!, style: const TextStyle(fontWeight: FontWeight.w600), ), ], if (source.subtitle != null) Text( source.subtitle!, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), if (channels.isEmpty) ...[ const SizedBox(height: 6), Text( 'Keine Telefonnummer oder E-Mail hinterlegt.', style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), ), ] else ...[ const SizedBox(height: 8), for (final ch in channels) _ChannelRow(channel: ch, onLaunch: onLaunch), ], ], ), ); } } class _ChannelRow extends StatelessWidget { const _ChannelRow({required this.channel, required this.onLaunch}); final ContactChannel channel; final Future Function(BuildContext, Uri, String) onLaunch; // Symbol + Aktion ergeben sich aus dem `kind` — drei Tap-Targets: // phone/mobile → tel: | email → mailto: | web → https-URL // Web-URLs erkennt der Sync 1:1 aus `Adressen.InternetAdresse`; falls // dort kein Schema steht (häufiger Fall: „www.kunde.de"), prefixen wir // https://. Sonst öffnet `launchUrl` mit relativem URI auf Android nichts. ({IconData icon, String semantics, Uri? uri}) _action() { switch (channel.kind) { case ContactKind.phone: case ContactKind.mobile: return ( icon: channel.kind == ContactKind.mobile ? Icons.smartphone : Icons.call, semantics: 'Anrufen', uri: Uri(scheme: 'tel', path: channel.value), ); case ContactKind.email: return ( icon: Icons.mail_outline, semantics: 'E-Mail schreiben', uri: Uri(scheme: 'mailto', path: channel.value), ); case ContactKind.web: final raw = channel.value; final normalized = raw.startsWith(RegExp(r'https?://')) ? raw : 'https://$raw'; return ( icon: Icons.public, semantics: 'Webseite öffnen', uri: Uri.tryParse(normalized), ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final action = _action(); final hasUri = action.uri != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon(action.icon, size: 18, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( channel.value, style: const TextStyle(fontSize: 14), ), ), if (hasUri) IconButton( icon: Icon(action.icon, color: theme.colorScheme.primary), tooltip: action.semantics, onPressed: () => onLaunch(context, action.uri!, action.semantics), ), ], ), ); } } /// Auswahl-Eintrag für den „Anrufen"-Quick-Action — kapselt eine /// anrufbare Nummer mit dem nötigen Kontext (Rolle / Name / Icon), aus /// dem das Bottom-Sheet seine Zeilen baut. Bewusst nur View-lokal: /// die Domain kennt nur Sources und Channels, das Sheet braucht eine /// flache, vorgefilterte Liste. class _CallableNumber { const _CallableNumber({ required this.value, required this.kind, required this.roleLabel, required this.displayName, }); final String value; final ContactKind kind; final String roleLabel; final String? displayName; /// Bei einer Dublette (gleiche Nummer aus mehreren Quellen) hängen wir /// die zweite Rolle ans Label. Duplikate werden vor dem `withAdditional` /// schon im Normalisierungs-Check abgefangen, sodass dasselbe Label nie /// zweimal in [roleLabel] landet. _CallableNumber withAdditionalRole(String additionalRoleLabel) { if (roleLabel.contains(additionalRoleLabel)) return this; return _CallableNumber( value: value, kind: kind, roleLabel: '$roleLabel · $additionalRoleLabel', displayName: displayName, ); } }