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>
1107 lines
37 KiB
Dart
1107 lines
37 KiB
Dart
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 Kundenstammadresse) 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,
|
||
);
|
||
}
|
||
}
|