import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/domain/entity/article.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/domain/entity/warehouse.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_state.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/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart'; import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart'; import 'package:hl_lieferservice/widget/scanner/item_matcher.dart'; import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart'; import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart'; /// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner. /// /// Aufbau: /// 1. **Scanner-Stripe** oben — *außerhalb* des PageView. Damit gibt es /// nur eine einzige Kamera-Instanz im Page-Lebenszyklus; das Wischen /// zwischen Kunden lässt den Stream nicht abreißen. (Würde der Stripe /// pro PageView-Page neu instanziiert, käme er sich mit den von /// PageView vorgeladenen Nachbarseiten ins Gehege und der Viewport /// würde weiß.) /// 2. **Zoom-Bar** direkt unter dem Viewport — `-` / Slider / `+` für /// Daumenbedienung. Gebunden an `MobileScannerController.setZoomScale`. /// 3. **PageView** mit Kundenkopf + Item-Liste pro Lieferung. /// /// Scan-Pipeline: /// - Barcode = Artikelnummer. /// - UI sucht in der **aktuell sichtbaren** Lieferung das erste nicht /// fertig gescannte Item mit dieser Article-Nr und feuert `ScanItem`. /// - Bloc inkrementiert lokal sofort, ruft das Backend, rollt bei /// `rejected` zurück. class LoadingCustomerPage extends StatefulWidget { const LoadingCustomerPage({super.key, this.initialIndex = 0}); final int initialIndex; @override State createState() => _LoadingCustomerPageState(); } class _LoadingCustomerPageState extends State { late final PageController _pageController = PageController(initialPage: widget.initialIndex); /// Index der gerade sichtbaren Lieferung. Wird vom PageView gesetzt und /// vom (lebenslang einen) Scanner für die Barcode-Auflösung benutzt. int _currentIndex = 0; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; } @override void dispose() { _pageController.dispose(); super.dispose(); } bool _multiCarTeam(BuildContext context) { final state = context.read().state; return state is CarsLoaded && state.cars.length >= 2; } List _ownInLoadingOrder( BuildContext context, TourDetails details, String carId, ) { final relevant = _multiCarTeam(context) ? details.deliveriesSorted .where((d) => d.assignedCarId == carId) .toList() : details.deliveriesSorted; return relevant.reversed.toList(); } /// Verarbeitet einen Scan-Code (Kamera **oder** Manual-Entry). /// /// Validierung: das geparste Tripel **Artikelnummer × Kundennummer × /// Belegnummer** muss *exakt* zur aktuell sichtbaren Lieferung passen. /// Schon ein Mismatch in einer der drei Dimensionen → Snackbar /// „nicht vorgesehen". Das schützt den Fahrer davor, versehentlich /// einen QR-Code für eine **andere** Lieferung anzuwenden — die alte /// Logik („alles, was die Artikelnummer kennt, wird gescannt") wäre /// gegen Verwechslungen wehrlos. /// /// `customer.erpCustomerId` = `Kunden.Kundennummer` aus dem ERP-Sync /// (entspricht der Kundennummer im QR-Mittelfeld). void _onBarcode({ required String code, required List deliveries, required TourDetails details, required String carId, }) { if (deliveries.isEmpty) return; final safeIndex = _currentIndex.clamp(0, deliveries.length - 1); final delivery = deliveries[safeIndex]; final customer = details.customerOf(delivery); final parsed = parseScanCode(code); // Format-Fehler oder Konstellation passt nicht zur aktuell sichtbaren // Lieferung → eindeutig „nicht vorgesehen". if (parsed == null || customer?.erpCustomerId != parsed.customerErpId || delivery.erpBelegnummer != parsed.beleg) { _showScanSnackbar(_notIntendedMessage); return; } final match = matchItem( delivery: delivery, details: details, articleNumber: parsed.articleNumber, ); switch (match) { case ItemMatchOk(:final item): context .read() .add(ScanItem(deliveryItemId: item.id, actorCarId: carId)); case ItemMatchNotInDelivery(): _showScanSnackbar(_notIntendedMessage); case ItemMatchNotScannable(): _showScanSnackbar( 'Diese Position ist nicht zum Scannen vorgesehen ' '(Dienstleistung / Pauschale).', ); case ItemMatchAllDone(): _showScanSnackbar('Diese Position ist bereits vollständig gescannt.'); case ItemMatchAllRemoved(): _showScanSnackbar('Diese Position wurde aus der Lieferung entfernt.'); case ItemMatchNotOpen(): _showScanSnackbar('Diese Position ist nicht (mehr) offen.'); } } static const String _notIntendedMessage = 'Dieser Artikel ist für diese Lieferung nicht vorgesehen'; void _showScanSnackbar(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), duration: const Duration(seconds: 2), ), ); } Future _openManualEntry({ required List deliveries, required TourDetails details, required String carId, }) async { final code = await showManualEntryDialog(context); if (code == null || code.isEmpty) return; if (!mounted) return; _onBarcode( code: code, deliveries: deliveries, details: details, carId: carId, ); } // ─── Delivery-Lifecycle-Aktionen ────────────────────────────────────── Future _onHoldDelivery(Delivery delivery) async { final result = await showReasonPickerSheet( context: context, title: 'Lieferung pausieren', presets: ReasonCatalog.deliveryHold, confirmLabel: 'Pausieren', ); if (result == null || !mounted) return; context .read() .add(HoldDelivery(deliveryId: delivery.id, reason: result.reason)); } Future _onCancelDelivery(Delivery delivery) async { // Cancel ist endgültig — Bestätigungsschritt vor dem Reason-Picker. final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Lieferung abbrechen?'), content: const Text( 'Eine abgebrochene Lieferung kann nicht wieder aktiviert werden.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Zurück'), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Weiter'), ), ], ), ); if (confirmed != true || !mounted) return; final result = await showReasonPickerSheet( context: context, title: 'Grund für Abbruch', presets: ReasonCatalog.deliveryCancel, confirmLabel: 'Lieferung abbrechen', ); if (result == null || !mounted) return; context .read() .add(CancelDelivery(deliveryId: delivery.id, reason: result.reason)); } Future _onResumeDelivery(Delivery delivery) async { // Bei einer abgebrochenen Lieferung ist die Wiederherstellung // semantisch riskanter als ein normales Hold-Resume — der Cancel // wurde vom Fahrer aktiv bestätigt. Wir holen deshalb eine zweite // Zustimmung ein, bevor wir das Resume feuern. Beim Hold-Resume // sparen wir den Schritt: alltäglich und reversibel. if (delivery.state == DeliveryState.canceled) { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Lieferung wiederherstellen?'), content: Text( 'Die Lieferung wurde abgebrochen' '${delivery.stateReason != null ? ' (Grund: ${delivery.stateReason})' : ''}.' '\n\nWiederhergestellte Lieferungen erscheinen wieder in der ' 'Beladen-Phase. Die ursprüngliche Cancel-Begründung wird dabei ' 'gelöscht.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Wiederherstellen'), ), ], ), ); if (confirmed != true || !mounted) return; } context.read().add(ResumeDelivery(deliveryId: delivery.id)); } // ─── Item-Aktionen ──────────────────────────────────────────────────── /// Wird vom `PopupMenuButton` der jeweiligen Item-Row angeruft. Welche /// Optionen verfügbar sind, entscheidet die Row selbst über den /// aktuellen `ScanStatus` — hier landet nur die schon ausgewählte /// Aktion zur Verarbeitung. Future _onItemAction({ required DeliveryItem item, required _ItemAction action, required String carId, }) async { switch (action) { case _ItemAction.remove: // Restmenge, die noch gutgeschrieben/entfernt werden kann. final remaining = item.requiredQuantity - item.scanProgress.creditedQuantity; final result = await showReasonPickerSheet( context: context, title: 'Grund für das Entfernen', presets: ReasonCatalog.itemRemove, confirmLabel: 'Entfernen', maxQuantity: remaining, ); if (result == null || !mounted) return; context.read().add(RemoveItem( deliveryItemId: item.id, actorCarId: carId, reason: result.reason, quantity: result.quantity, )); case _ItemAction.unremove: context.read().add(UnremoveItem( deliveryItemId: item.id, actorCarId: carId, )); case _ItemAction.manualConfirm: // Fallback ohne Barcode: die ganze Restmenge manuell als geladen // bestätigen. Bewusste Aussage → kurzer Bestätigungs-Dialog; das // Backend protokolliert den Scan als `manual`. final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Manuell bestätigen'), content: const Text( 'Diese Position ohne Scan als vollständig geladen markieren? ' 'Das wird als manuelle Bestätigung protokolliert.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Als geladen bestätigen'), ), ], ), ); if (confirmed != true || !mounted) return; context.read().add(ScanItem( deliveryItemId: item.id, actorCarId: carId, manual: true, )); } } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, carState) { final carId = carState is CarSelectComplete ? carState.selectedCar.id : ''; return BlocBuilder( builder: (context, tourState) { if (tourState is! TourLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } final deliveries = _ownInLoadingOrder(context, tourState.details, carId); if (deliveries.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Beladung')), body: const Center( child: Text('Keine Lieferungen zugewiesen.'), ), ); } return Scaffold( // AppBar bewusst schlicht — die Lieferungs-Lifecycle-Aktionen // (Pausieren / Abbrechen / Wiederherstellen) sind in den // Kunden-Header gewandert, vertikal mittig rechts. So // hat der Fahrer den Bezug „Aktion betrifft DIESEN Kunden" // direkt vor Augen. // // Farbe primary statt M3-Default `surface`: die Loading- // Customer-Page ist eine Sub-Page der LoadingOverviewPage, // deren `PhaseStepper`-AppBar primary ist. Eine weiße // AppBar hier bricht den Flow visuell — primary stellt die // Konsistenz wieder her. appBar: AppBar( title: const Text('Beladung'), backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.onPrimary, ), body: Column( children: [ ArticleScannerStripe( onBarcode: (code) => _onBarcode( code: code, deliveries: deliveries, details: tourState.details, carId: carId, ), onManualEntry: () => _openManualEntry( deliveries: deliveries, details: tourState.details, carId: carId, ), ), Expanded( child: PageView.builder( controller: _pageController, onPageChanged: (i) => setState(() => _currentIndex = i), itemCount: deliveries.length, itemBuilder: (context, index) { final delivery = deliveries[index]; return _CustomerBody( delivery: delivery, details: tourState.details, position: index + 1, totalCount: deliveries.length, onItemAction: (item, action) => _onItemAction( item: item, action: action, carId: carId, ), onHoldDelivery: () => _onHoldDelivery(delivery), onCancelDelivery: () => _onCancelDelivery(delivery), onResumeDelivery: () => _onResumeDelivery(delivery), ); }, ), ), ], ), ); }, ); }, ); } } class _CustomerBody extends StatelessWidget { const _CustomerBody({ required this.delivery, required this.details, required this.position, required this.totalCount, required this.onItemAction, required this.onHoldDelivery, required this.onCancelDelivery, required this.onResumeDelivery, }); final Delivery delivery; final TourDetails details; /// 1-basierte Position dieser Lieferung in der Belade-Reihenfolge. final int position; /// Gesamtanzahl der Lieferungen — für „X von Y". final int totalCount; final void Function(DeliveryItem item, _ItemAction action) onItemAction; final VoidCallback onHoldDelivery; final VoidCallback onCancelDelivery; final VoidCallback onResumeDelivery; @override Widget build(BuildContext context) { final customer = details.customerOf(delivery); // Items werden vom Aggregat-Helper schon nach Lager gruppiert // geliefert: Standardlager zuerst, danach Filiale alphabetisch. // Nicht-scanbare Positionen und `removed`-Items sind dabei schon // ausgefiltert. final groups = details.itemsGroupedByWarehouse(delivery); // Nicht-scanbare Positionen (Dienstleistung / Pauschale / Fracht) — die // werden NICHT beladen/gescannt, sollen aber sichtbar sein, damit der // Fahrer auch eine reine Dienstleistungs-Lieferung als „echte Anfahrt" // erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen). final serviceItems = details.nonScannableItems(delivery).toList(); return Column( children: [ // Header bekommt einen eigenen, leicht abgehobenen Hintergrund // aus dem Material-3-Surface-Stack (`surfaceContainerHigh`). // Das hebt den Kunden-/Lieferungs-Block visuell vom Item-Body // ab — Dark-Mode-tauglich, ohne dass eine Hardcodierte Farbe // bei einem späteren Theme-Wechsel verkehrt aussieht. Container( width: double.infinity, color: Theme.of(context).colorScheme.surfaceContainerHigh, padding: const EdgeInsets.fromLTRB(16, 16, 4, 16), // Vertikal zentriert: Avatar links, Kunden-/Lieferungs-Info in // der Mitte, Aktions-Menü ganz rechts (statt wie früher im // AppBar oben). Der Bezug „diese Aktion betrifft genau DIESEN // Kunden" wird dadurch räumlich klar. child: Row( children: [ _CustomerAvatar(customer: customer), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Lieferung $position von $totalCount', style: Theme.of(context) .textTheme .labelMedium ?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600, letterSpacing: 0.3, ), ), const SizedBox(height: 2), Text( customer?.name ?? '⟨Unbekannter Kunde⟩', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 4), Text( delivery.deliveryAddressSnapshot.oneLine, style: Theme.of(context).textTheme.bodyMedium, ), if (delivery.state != DeliveryState.active) ...[ const SizedBox(height: 8), _DeliveryStateBadge(delivery: delivery), ], ], ), ), _DeliveryActionsMenu( delivery: delivery, onHold: onHoldDelivery, onCancel: onCancelDelivery, onResume: onResumeDelivery, ), ], ), ), // Kein expliziter Divider mehr — der farbige Header-Block trennt // sich schon visuell vom Item-Body. Expanded( // Eine einzige Scroll-Liste: zuerst die scanbaren Items je Lager // (Standardlager, dann Filiale), darunter — sofern vorhanden — die // gebuchten Dienstleistungen (nicht-scanbare Positionen). Die // Dienstleistungen werden GENAU SO wie Standardlager-Artikel // dargestellt, nur mit dem Hinweis „kein Scanvorgang notwendig". So // wirkt auch eine reine Dienstleistungs-Lieferung nicht leer. // // Wenig Items pro Lieferung → `ListView` mit children-Liste reicht // performant aus und ist lesbarer als ein Index-Builder. // // Bottom-Inset: kein Bottom-Bar in dieser Page → die System- // Navigationsleiste selbst freihalten. child: ListView( padding: EdgeInsets.fromLTRB( 0, 4, 0, 4 + MediaQuery.viewPaddingOf(context).bottom, ), children: [ // Nur wenn es WEDER scanbare Ware NOCH eine Dienstleistung gibt, // ist wirklich nichts zu tun. if (groups.isEmpty && serviceItems.isEmpty) const _NothingToLoadHint(), for (final group in groups) ...[ _WarehouseSectionHeader( warehouse: group.warehouse, items: group.items, ), for (final item in _parentFirst(group.items)) _ItemRow( item: item, details: details, onAction: (action) => onItemAction(item, action), ), ], // Gebuchte Dienstleistungen (nicht-scanbare Positionen): eigene // Zwischenüberschrift „Dienstleistungen" (optisch wie // Standardlager) und dieselbe Item-Card wie scanbare Artikel — // einziger Unterschied ist der Hinweis, dass kein Scanvorgang // nötig ist (`scanNotRequired`). if (serviceItems.isNotEmpty) ...[ const _ServiceSectionHeader(), for (final item in _parentFirst(serviceItems)) _ItemRow( item: item, details: details, onAction: (action) => onItemAction(item, action), scanNotRequired: true, ), ], ], ), ), ], ); } } /// Sektions-Kopf vor den gebuchten Dienstleistungen (nicht-scanbare /// Positionen) in der Beladen-Ansicht. Bewusst optisch identisch zum /// `_WarehouseSectionHeader` („Standardlager"): gleiche Maße, gleiche /// neutrale Farbe — die Dienstleistungen reihen sich damit nahtlos in die /// Lager-Gliederung ein. /// /// Anders als der Lager-Kopf trägt dieser KEINEN „fertig/gesamt"-Zähler /// rechts: Dienstleistungen werden nicht gescannt, ein Fortschrittszähler /// wäre irreführend. Stattdessen sagt der Subtitle direkt, dass hier kein /// Scanvorgang nötig ist. class _ServiceSectionHeader extends StatelessWidget { const _ServiceSectionHeader(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = theme.colorScheme.onSurfaceVariant; return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.handyman_outlined, size: 18, color: color), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Dienstleistungen', style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, color: color, letterSpacing: 0.4, ), ), Text( 'Kein Scanvorgang notwendig', style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ], ), ); } } /// Vollbreiter Hinweis am unteren Rand einer Dienstleistungs-Card: diese /// Position wird NICHT gescannt/beladen. Sitzt an genau der Stelle, an der /// bei scanbaren Artikeln der „Manuell als geladen bestätigen"-Button steht /// (der bei nicht-scanbaren Positionen entfällt) — so bleibt die Card-Geometrie /// identisch zu den Standardlager-Artikeln, nur die Aussage ist eine andere. class _ScanNotRequiredHint extends StatelessWidget { const _ScanNotRequiredHint(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = theme.colorScheme.onSurfaceVariant; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.info_outline, size: 16, color: color), const SizedBox(width: 6), Flexible( child: Text( 'Kein Scanvorgang notwendig', style: TextStyle( fontSize: 13, color: color, fontWeight: FontWeight.w500, ), ), ), ], ), ); } } /// Sektions-Kopf vor den Items eines Lagers. Visuell klar getrennt /// (Standardlager vs. Filiale): Standardlager ist der primäre /// Arbeitsplatz und damit neutral koloriert; Filial-Sections /// bekommen den Orange-Akzent und einen Hinweis-Text, dass sie nicht /// am aktuellen Standort geladen werden. /// /// Counter rechts zeigt „fertige Items / Items in dieser Sektion". class _WarehouseSectionHeader extends StatelessWidget { const _WarehouseSectionHeader({ required this.warehouse, required this.items, }); final Warehouse warehouse; final List items; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isStandard = warehouse.isStandard; // Counter zählt nur **aktive** (nicht entfernte) Positionen — sonst // wäre eine Lieferung mit allen Items entfernt nie „fertig" (0 / N), // obwohl es nichts mehr zu beladen gibt. Entfernte Zeilen bleiben in // der Liste sichtbar (durchgestrichen), zählen aber nicht. final activeItems = items.where((it) => !it.isRemoved).toList(); final doneCount = activeItems.where((it) => it.isDone).length; final color = isStandard ? theme.colorScheme.onSurfaceVariant : Colors.amber.shade800; return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( isStandard ? Icons.inventory_outlined : Icons.warehouse_outlined, size: 18, color: color, ), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isStandard ? 'Standardlager' : 'Filiale: ${warehouse.name}', style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, color: color, letterSpacing: 0.4, ), ), Text( isStandard ? 'Hier wird jetzt beladen' : 'Wird in der Filiale separat geholt', style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), Padding( padding: const EdgeInsets.only(top: 1), child: Text( '$doneCount / ${activeItems.length}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: color, ), ), ), ], ), ); } } /// Wird angezeigt, wenn eine Lieferung in der Beladen-Phase WEDER scanbare /// Ware NOCH eine gebuchte Dienstleistung hat — also wirklich nichts zu tun /// ist. Macht dem Fahrer klar, dass das ein normaler Zustand ist und kein /// Datenfehler. (Gibt es eine Dienstleistung, erscheint stattdessen der /// Abschnitt „Dienstleistungen" mit der echten Position.) class _NothingToLoadHint extends StatelessWidget { const _NothingToLoadHint(); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(32, 32, 32, 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inbox_outlined, size: 56, color: theme.colorScheme.outline), const SizedBox(height: 12), Text( 'Für diesen Kunden ist nichts zu beladen.', style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), const SizedBox(height: 6), Text( 'Die Lieferung enthält keine zu ladende Ware.', style: theme.textTheme.bodySmall, textAlign: TextAlign.center, ), ], ), ); } } /// Runder Initialen-Avatar links neben dem Kundennamen. Macht den /// sonst sehr text-lastigen Header griffiger und liefert dem Fahrer /// einen visuellen Anker: gleicher Kunde = gleiche Farbe. /// /// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit /// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen. /// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert, /// einzelne Worte mit einem Buchstaben. class _CustomerAvatar extends StatelessWidget { const _CustomerAvatar({required this.customer}); final Customer? customer; /// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht /// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am /// stärksten auf und sind damit für die häufigsten Kunden „zuerst /// dran" (Hash-Modulo verteilt — über die Zeit ausgeglichen). static const List _palette = [ Color(0xFF1976D2), // blue 700 Color(0xFF388E3C), // green 700 Color(0xFFEF6C00), // orange 800 Color(0xFF7B1FA2), // purple 700 Color(0xFF00838F), // cyan 800 Color(0xFF5D4037), // brown 700 Color(0xFF455A64), // blueGrey 700 ]; String get _initials { final name = customer?.name.trim() ?? ''; if (name.isEmpty) return '?'; final parts = name .split(RegExp(r'\s+')) .where((p) => p.isNotEmpty) .toList(growable: false); if (parts.isEmpty) return '?'; if (parts.length == 1) { return parts.first.characters.first.toUpperCase(); } final first = parts.first.characters.first; final last = parts.last.characters.first; return '$first$last'.toUpperCase(); } Color get _backgroundColor { final seed = customer?.id.hashCode ?? 0; return _palette[seed.abs() % _palette.length]; } @override Widget build(BuildContext context) { if (customer == null) { // Fallback: graues Person-Icon, weil kein Name → keine Initialen. return const CircleAvatar( radius: 26, backgroundColor: Colors.grey, child: Icon(Icons.person_outline, color: Colors.white), ); } return CircleAvatar( radius: 26, backgroundColor: _backgroundColor, child: Text( _initials, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), ); } } /// Farbiger Status-Badge unterhalb der Adresse — sichtbar bei `held`, /// `canceled`, `completed`. Bei `active` blendet der Aufrufer den Badge /// ganz aus, damit der Default-Fall die UI nicht zumüllt. class _DeliveryStateBadge extends StatelessWidget { const _DeliveryStateBadge({required this.delivery}); final Delivery delivery; @override Widget build(BuildContext context) { final (color, label, icon) = switch (delivery.state) { DeliveryState.active => (Colors.blue, 'Aktiv', Icons.local_shipping), DeliveryState.held => (Colors.orange, 'Pausiert', Icons.pause_circle), DeliveryState.canceled => (Colors.red, 'Abgebrochen', Icons.cancel), DeliveryState.completed => (Colors.green, 'Abgeschlossen', Icons.check_circle), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), border: Border.all(color: color), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 6), Text( label, style: TextStyle(color: color, fontWeight: FontWeight.w600), ), if (delivery.stateReason != null && delivery.stateReason!.isNotEmpty) ...[ const SizedBox(width: 8), Flexible( child: Text( '· ${delivery.stateReason}', overflow: TextOverflow.ellipsis, ), ), ], ], ), ); } } /// AppBar-3-Punkte-Menü mit Delivery-Lifecycle-Aktionen. Optionen sind /// kontextabhängig vom aktuellen `state` — endgültige Zustände /// (canceled, completed) bieten keine Aktion mehr; das Icon verschwindet /// dann ganz, damit der Fahrer keine Sackgasse antippt. class _DeliveryActionsMenu extends StatelessWidget { const _DeliveryActionsMenu({ required this.delivery, required this.onHold, required this.onCancel, required this.onResume, }); final Delivery delivery; final VoidCallback onHold; final VoidCallback onCancel; final VoidCallback onResume; @override Widget build(BuildContext context) { final items = >[]; switch (delivery.state) { case DeliveryState.active: items.add(const PopupMenuItem( value: _DeliveryAction.hold, child: ListTile( leading: Icon(Icons.pause_circle_outline), title: Text('Lieferung pausieren'), contentPadding: EdgeInsets.zero, ), )); items.add(const PopupMenuItem( value: _DeliveryAction.cancel, child: ListTile( leading: Icon(Icons.cancel_outlined, color: Colors.red), title: Text('Lieferung abbrechen'), contentPadding: EdgeInsets.zero, ), )); case DeliveryState.held: items.add(const PopupMenuItem( value: _DeliveryAction.resume, child: ListTile( leading: Icon(Icons.play_circle_outline), title: Text('Lieferung fortsetzen'), contentPadding: EdgeInsets.zero, ), )); items.add(const PopupMenuItem( value: _DeliveryAction.cancel, child: ListTile( leading: Icon(Icons.cancel_outlined, color: Colors.red), title: Text('Lieferung abbrechen'), contentPadding: EdgeInsets.zero, ), )); case DeliveryState.canceled: items.add(const PopupMenuItem( value: _DeliveryAction.resume, child: ListTile( leading: Icon(Icons.restore, color: Colors.green), title: Text('Lieferung wiederherstellen'), contentPadding: EdgeInsets.zero, ), )); case DeliveryState.completed: return const SizedBox.shrink(); } return PopupMenuButton<_DeliveryAction>( tooltip: 'Lieferungs-Aktionen', onSelected: (action) { switch (action) { case _DeliveryAction.hold: onHold(); case _DeliveryAction.cancel: onCancel(); case _DeliveryAction.resume: onResume(); } }, itemBuilder: (_) => items, ); } } enum _DeliveryAction { hold, cancel, resume } enum _ItemAction { remove, unremove, manualConfirm } /// Karten-Darstellung einer Item-Position in der Beladen-Phase. /// /// Visueller Stil orientiert sich an `_OverviewTile` (Lieferungs-Karten /// in der Beladen-Übersicht): semi-transparente Hintergrundfarbe + Border /// codieren den Status, links ein Status-Icon, rechts die Mengen-Anzeige. /// Bei `isDone` ein zusätzliches grünes Häkchen, damit „fertig" ohne /// Mengenrechnung auf einen Blick erkennbar ist. /// Sortiert Items so, dass innerhalb einer Belegzeile der Oberartikel VOR /// seinen Komponenten steht (für die eingerückte Darstellung). List _parentFirst(List items) { final sorted = List.of(items); sorted.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 ?? ''); }); return sorted; } class _ItemRow extends StatelessWidget { const _ItemRow({ required this.item, required this.details, required this.onAction, this.scanNotRequired = false, }); final DeliveryItem item; final TourDetails details; final void Function(_ItemAction action) onAction; /// `true` für gebuchte Dienstleistungen (nicht-scanbare Positionen): die /// Card wird GENAU SO wie ein Standardlager-Artikel gerendert, bekommt aber /// unten den Hinweis „Kein Scanvorgang notwendig". Der Manuell-Button /// entfällt bei nicht-scanbaren Positionen ohnehin (`canManualConfirm`). final bool scanNotRequired; @override Widget build(BuildContext context) { final theme = Theme.of(context); final article = details.articleOf(item.articleId); final warehouse = details.warehouseOf(item.warehouseId); final isExternalWarehouse = warehouse != null && !warehouse.isStandard; // Manueller Fallback-Button: nur für scanbare, noch offene Positionen // (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan // dieser Zeile tun würde. final canManualConfirm = article != null && article.scannable && !item.isDone && !item.isRemoved && !item.isHeld; // Status-abhängiger Style — gleiches Farbschema wie `_OverviewTile`, // damit Übersicht und Detail visuell zusammenpassen. final Color cardColor; final Color borderColor; final Color titleColor; final Color quantityColor; final IconData leadingIcon; final Color leadingIconColor; final String? statusBadgeLabel; if (item.isRemoved) { cardColor = Colors.grey.withValues(alpha: 0.08); borderColor = Colors.grey.withValues(alpha: 0.35); titleColor = Colors.grey.shade700; quantityColor = Colors.grey; leadingIcon = Icons.delete_outline; leadingIconColor = Colors.grey.shade700; statusBadgeLabel = 'Entfernt'; } else if (item.isHeld) { cardColor = Colors.orange.withValues(alpha: 0.07); borderColor = Colors.orange.withValues(alpha: 0.35); titleColor = Colors.orange.shade800; quantityColor = Colors.orange.shade800; leadingIcon = Icons.pause_circle_outline; leadingIconColor = Colors.orange.shade800; statusBadgeLabel = 'Pausiert'; } else if (item.isDone) { cardColor = Colors.green.withValues(alpha: 0.07); borderColor = Colors.green.withValues(alpha: 0.35); titleColor = Colors.green.shade700; quantityColor = Colors.green.shade700; leadingIcon = Icons.inventory_2_outlined; leadingIconColor = Colors.green.shade700; statusBadgeLabel = null; } else if (item.scanProgress.scannedQuantity > 0) { cardColor = Colors.orange.withValues(alpha: 0.07); borderColor = Colors.orange.withValues(alpha: 0.35); titleColor = Colors.orange.shade800; quantityColor = Colors.orange.shade800; leadingIcon = Icons.inventory_2_outlined; leadingIconColor = Colors.orange.shade800; statusBadgeLabel = null; } else { cardColor = theme.colorScheme.surfaceContainerLow; borderColor = Colors.transparent; titleColor = theme.colorScheme.onSurface; quantityColor = theme.colorScheme.onSurface; leadingIcon = Icons.inventory_2_outlined; leadingIconColor = theme.colorScheme.onSurfaceVariant; statusBadgeLabel = null; } return Opacity( opacity: item.isRemoved ? 0.55 : 1.0, child: Card( // Komponenten weiter links eingerückt → gehören zum Oberartikel darüber. margin: EdgeInsets.only( left: item.isComponent ? 32 : 12, right: 12, top: 4, bottom: 4, ), elevation: 0, color: cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: borderColor), ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 4, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Row( // Default `crossAxisAlignment: center` — Icon links, Mengen- // Anzeige rechts und der Aktions-Menü-Button sitzen damit // vertikal mittig zum Inhalts-Block in der Mitte. Wirkt // ausgeglichener, wenn der Subtitle mehrere Zeilen hat // (Art-Nr, Lager, Komponente, Reason). children: [ Icon(leadingIcon, color: leadingIconColor, size: 28), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Text( '${item.isComponent ? '↳ ' : ''}${_articleTitle(article)}', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: titleColor, // Entfernte Positionen werden im Detail-Screen // **nicht** rausgefiltert, sondern bleiben // sichtbar — durchgestrichen, damit der Fahrer // erkennt „hatten wir, ist raus" und über das // Aktions-Menü ggf. wiederherstellen kann. decoration: item.isRemoved ? TextDecoration.lineThrough : null, ), ), ), if (statusBadgeLabel != null) Container( margin: const EdgeInsets.only(left: 8), padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: titleColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), border: Border.all(color: titleColor), ), child: Text( statusBadgeLabel, style: TextStyle( color: titleColor, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 2), Text( 'Art.-Nr.: ${article?.articleNumber ?? '⟨unbekannt⟩'}', style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), // Lager-Angabe nur für scanbare Artikel. Dienstleistungen // (`scanNotRequired`) werden nicht aus einem Lager beladen // → die Lager-Zeile wäre irrelevant und entfällt. if (warehouse != null && !scanNotRequired) Text( 'Lager: ${warehouse.name}' '${isExternalWarehouse ? ' (Filiale)' : ''}', style: TextStyle( fontSize: 12, color: isExternalWarehouse ? Colors.amber.shade800 : theme.colorScheme.onSurfaceVariant, fontWeight: isExternalWarehouse ? FontWeight.w600 : FontWeight.normal, ), ), if (item.isHeld && item.scanProgress.heldReason != null && item.scanProgress.heldReason!.isNotEmpty) Text( 'Grund: ${item.scanProgress.heldReason}', style: TextStyle( fontSize: 12, color: Colors.orange.shade800, ), ), ], ), ), // Mengen-Zähler nur für scanbare Artikel. Dienstleistungen // (`scanNotRequired`) werden nicht gescannt → eine „X / Y"- // Anzeige wäre dort sinnlos und wird weggelassen. if (!scanNotRequired) ...[ const SizedBox(width: 8), Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Text( '${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: quantityColor, ), ), // Grünes Häkchen sobald die Soll-Menge erreicht ist — // schneller visueller Anker als die Zahlen-Differenz. if (item.isDone) ...[ const SizedBox(width: 4), Icon( Icons.check_circle, size: 20, color: Colors.green.shade700, ), ], ], ), ], ), ], _ItemActionMenu(item: item, onSelected: onAction), ], ), if (canManualConfirm) ...[ const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => onAction(_ItemAction.manualConfirm), icon: const Icon(Icons.check_circle_outline, size: 18), label: const Text('Manuell als geladen bestätigen'), ), ), ], // Dienstleistung (nicht-scanbar): Hinweis statt Scan/Manuell- // Aktion. Steht an derselben Stelle wie der Manuell-Button. if (scanNotRequired) ...[ const SizedBox(height: 8), const _ScanNotRequiredHint(), ], ], ), ), ), ); } String _articleTitle(Article? article) { if (article == null) return item.articleId; return article.name; } } /// 3-Punkte-Menü am Ende einer Item-Row. Welche Aktion sichtbar ist, /// hängt vom aktuellen ScanStatus ab: /// * `removed` → „Wiederherstellen" /// * sonst → „Entfernen" /// /// Item-Pausieren wird vom UI bewusst **nicht** angeboten — das Backend /// kennt die Hold-Action zwar weiterhin, in der Fahrer-App ist sie aber /// nicht mehr Teil des Workflows. class _ItemActionMenu extends StatelessWidget { const _ItemActionMenu({required this.item, required this.onSelected}); final DeliveryItem item; final void Function(_ItemAction) onSelected; @override Widget build(BuildContext context) { return PopupMenuButton<_ItemAction>( tooltip: 'Artikel-Aktionen', icon: const Icon(Icons.more_vert), onSelected: onSelected, itemBuilder: (_) { if (item.isRemoved) { return const [ PopupMenuItem( value: _ItemAction.unremove, child: ListTile( leading: Icon(Icons.restore, color: Colors.green), title: Text('Wiederherstellen'), contentPadding: EdgeInsets.zero, ), ), ]; } return const [ PopupMenuItem( value: _ItemAction.remove, child: ListTile( leading: Icon(Icons.delete_outline, color: Colors.red), title: Text('Entfernen'), contentPadding: EdgeInsets.zero, ), ), ]; }, ); } }