Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_info.dart
Dennis Nemec 4c6bef6897 feat(delivery): Abschluss-Navigation, Mengen-Hinweis, Set-Handling
A) Nach erfolgreichem Abschluss (aktiv→completed) poppt die Detail-Page
   automatisch zurück zur Übersicht. Scaffold ist jetzt StatefulWidget
   mit BlocListener<TourBloc>; nur „gearmt", wenn die Lieferung beim
   Öffnen aktiv war → erneutes Öffnen einer fertigen Lieferung poppt nicht.

B) Step „Info": Artikelliste zeigt weiter die Ursprungsmenge
   (requiredQuantity). Bei entfernten/teilweise gutgeschriebenen Positionen
   erscheint pro Zeile ein „Menge geändert"-Hinweis + ein tappbares Banner,
   das zu Step 3 „Artikel" springt.

C) Beladen: nicht-scanbare Set-Köpfe (Parent-Komponenten) werden jetzt
   IMMER mit ihrem Set gezeigt — als Kopf in der Lagergruppe ihrer
   Komponenten statt isoliert unter „Dienstleistungen". _ItemRow leitet
   scanNotRequired aus der Artikel-Scanbarkeit ab.

D) Step „Übersicht": Wording der Zahlungsweise-Sperre bei offen==0
   präzisiert („Keine Zahlung mehr offen (bereits bezahlt)").

E) Step „Artikel": Komponenten eines Sets sind einzeln nicht mehr
   entfernbar (kein Button + Hinweis). Das Entfernen/Wiederherstellen läuft
   nur über den Oberartikel und kaskadiert auf das ganze Set (ganz oder
   gar nix). Set-Entfernen ist blockiert, solange eine Komponente noch
   nicht verladen ist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:57:52 +02:00

1107 lines
37 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';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.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,
),
),
),
);
}
// Hat sich bei mindestens einem Artikel die tatsächliche Menge geändert
// (entfernt oder teilweise gutgeschrieben)? Dann ein Banner mit Verweis
// auf Step 3 „Artikel", wo die Änderungen verwaltet werden.
final anyChanged = items.any(_quantityChanged);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (anyChanged) ...[
const _QuantityChangedBanner(),
const SizedBox(height: 8),
],
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),
],
],
),
),
],
);
}
}
/// `true`, wenn die tatsächlich auszuliefernde Menge von der ursprünglich
/// bestellten abweicht — also die Position ganz entfernt oder teilweise
/// gutgeschrieben wurde.
bool _quantityChanged(DeliveryItem item) =>
item.isRemoved || item.scanProgress.creditedQuantity > 0;
/// Tappbares Banner über der Artikelliste: weist darauf hin, dass sich die
/// Menge mindestens eines Artikels geändert hat, und springt zu Step 3
/// („Artikel"), wo die Änderungen sichtbar/verwaltbar sind.
class _QuantityChangedBanner extends StatelessWidget {
const _QuantityChangedBanner();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amber = Colors.amber.shade800;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowGoToStep(WorkflowStep.articles)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: amber.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: amber.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.edit_note, color: amber),
const SizedBox(width: 10),
Expanded(
child: Text(
'Bei mindestens einem Artikel hat sich die Menge geändert. '
'Details unter Schritt 3 „Artikel".',
style: theme.textTheme.bodySmall?.copyWith(color: amber),
),
),
Icon(Icons.chevron_right, color: amber, size: 20),
],
),
),
);
}
}
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;
// Menge tatsächlich verändert (entfernt oder teilweise gutgeschrieben)?
final changed = _quantityChanged(item);
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,
// Bewusst die URSPRÜNGLICH bestellte Menge (requiredQuantity), nicht
// die reduzierte — Änderungen werden separat als Hinweis ausgewiesen.
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
),
if (changed)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(Icons.edit_note, size: 14, color: Colors.amber.shade800),
const SizedBox(width: 4),
Flexible(
child: Text(
removed
? 'Menge geändert: entfernt (Ursprung ${item.requiredQuantity}×)'
: 'Menge geändert: jetzt ${item.deliveredQuantity}× '
'(Ursprung ${item.requiredQuantity}×)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.amber.shade800,
),
),
),
],
),
),
],
),
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,
);
}
}