Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_info.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

1011 lines
33 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<MergedContactSource> 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 = <String, int>{};
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<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);
// 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<void> _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<void> _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<String?> _askReason(BuildContext context, String title) async {
final controller = TextEditingController();
final result = await showDialog<String>(
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<void> _onStatusSelected(
BuildContext context,
_StatusAction action,
) async {
final tourBloc = context.read<TourBloc>();
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<CustomerContact> contacts;
Future<void> _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<void> 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<DeliveryItem>.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<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(
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<void> 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<void> 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,
);
}
}