- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button "Zahlung abwickeln" (fest unten) - Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund) - Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung als veraltet markiert und muss erneut abgewickelt werden - Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl; Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode und Inkasso-Flag der gueltigen Zahlung - DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte Widgets; Begruendungs-Dialog geteilt mit Info-Step - API-Client aus aktueller Backend-Spec neu generiert - Tests fuer Zahlungsstatus und Modal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1075 lines
36 KiB
Dart
1075 lines
36 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';
|
||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.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<void> _onStatusSelected(
|
||
BuildContext context,
|
||
_StatusAction action,
|
||
) async {
|
||
final tourBloc = context.read<TourBloc>();
|
||
switch (action) {
|
||
case _StatusAction.hold:
|
||
final reason = await showReasonDialog(context, 'Lieferung pausieren');
|
||
if (reason == null) return;
|
||
tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason));
|
||
case _StatusAction.cancel:
|
||
final reason = await showReasonDialog(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,
|
||
);
|
||
}
|
||
}
|