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/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.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/delivery/model/delivery_phase.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; /// Verhindert, dass der „Alles gescannt"-Abschluss-Dialog mehrfach erscheint. /// Wird zurückgesetzt, sobald wieder etwas zu beladen offen ist. bool _completionPromptShown = false; @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, )); } } /// Reagiert auf jedes TourBloc-Update: Sind ALLE eigenen, **aktiven** /// Lieferungen im Standardlager fertig beladen (dieselbe Bedingung wie der /// „Auslieferungs-Phase starten"-Gate der Beladen-Übersicht)? Genau beim /// Übergang „noch offen → alles fertig" (also nach dem letzten Pflicht-Scan) /// erscheint einmalig der Abschluss-Dialog. void _maybePromptLoadingComplete( BuildContext context, TourState state, String carId, ) { if (state is! TourLoaded) return; final deliveries = _ownInLoadingOrder(context, state.details, carId); final active = deliveries.where((d) => d.state == DeliveryState.active).toList(); final allDone = active.isNotEmpty && active.every(state.details.standardWarehouseLoadingDone); if (!allDone) { // Wieder etwas offen (Item entfernt/zurückgesetzt) → Dialog darf erneut. _completionPromptShown = false; return; } if (_completionPromptShown) return; _completionPromptShown = true; _showLoadingCompleteDialog(carId); } /// Abschluss-Dialog der Beladung: weist darauf hin, dass alles gescannt ist, /// und fragt, ob die Auslieferung gestartet werden soll. Bei „Ja" wird die /// Phase auf `ausliefern` gesetzt (PhaseBloc, app-weit) und der Scanner /// geschlossen — der Phasen-Router (`home`) zeigt dann die Auslieferungs- /// Übersicht. „Später" schließt nur den Dialog (Start wie bisher über die /// Übersicht möglich). Future _showLoadingCompleteDialog(String carId) async { final start = await showDialog( context: context, builder: (ctx) => AlertDialog( icon: const Icon(Icons.check_circle, color: Colors.green, size: 40), title: const Text('Alles gescannt'), content: const Text( 'Alle zu beladenden Artikel sind gescannt. ' 'Möchten Sie die Auslieferung starten?', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Später'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Auslieferung starten'), ), ], ), ); if (start != true || !mounted) return; context .read() .add(PhaseSet(carId: carId, phase: DeliveryPhase.ausliefern)); // Scanner schließen → zurück zum Phasen-Router, der nun 'ausliefern' zeigt. Navigator.of(context).pop(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, carState) { final carId = carState is CarSelectComplete ? carState.selectedCar.id : ''; return BlocConsumer( listener: (context, tourState) => _maybePromptLoadingComplete(context, tourState, carId), 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(); // ── Set-Köpfe (Parent-Komponenten) immer mit ihrem Set anzeigen ────── // Ein nicht-scanbarer Set-Kopf (Stücklisten-Oberartikel, z. B. als // Pauschale geführt) soll NICHT isoliert unter „Dienstleistungen" // erscheinen, sondern als Kopf über seinen (scanbaren) Komponenten in der // jeweiligen Lagergruppe. Ein Item ist Set-Kopf, wenn seine Artikelnummer // von einer Komponente als parentArtikelNr referenziert wird. String? artNrOf(DeliveryItem it) => details.articleOf(it.articleId)?.articleNumber; // Alle Set-Köpfe (Parent-Artikel): ein Item, dessen Artikelnummer von einer // Komponente als parentArtikelNr referenziert wird. Für nicht-scanbare // Set-Köpfe wird der „kein Scanvorgang notwendig"-Hinweis unterdrückt. final componentParentNrs = delivery.items .where((it) => it.isComponent) .map((it) => it.parentArtikelNr) .whereType() .toSet(); final setParentIds = delivery.items .where((it) { final nr = artNrOf(it); return nr != null && componentParentNrs.contains(nr); }) .map((it) => it.id) .toSet(); // Set-Kopf „komplett": alle scanbaren, nicht entfernten Komponenten fertig. // Dann wird auch der (selbst nicht scanbare) Kopf grün dargestellt. bool setParentComplete(DeliveryItem parent) { final nr = artNrOf(parent); if (nr == null) return false; final comps = delivery.items.where((c) => c.parentArtikelNr == nr && !c.isRemoved && (details.articleOf(c.articleId)?.scannable ?? false)); return comps.isNotEmpty && comps.every((c) => c.isDone); } // Set-Köpfe je Lagergruppe (warehouseId → einzuhängende Köpfe) + // gesammelte IDs, um sie aus der Dienstleistungs-Sektion zu entfernen. final injectedParentsByWarehouseId = >{}; final injectedParentIds = {}; for (final group in groups) { final groupParentNrs = group.items .where((it) => it.isComponent) .map((it) => it.parentArtikelNr) .whereType() .toSet(); if (groupParentNrs.isEmpty) continue; final parents = serviceItems .where((s) => groupParentNrs.contains(artNrOf(s))) .toList(); if (parents.isEmpty) continue; injectedParentsByWarehouseId[group.warehouse.id] = parents; injectedParentIds.addAll(parents.map((p) => p.id)); } // Set-Köpfe, die in eine Gruppe eingehängt wurden, nicht doppelt unter // „Dienstleistungen" zeigen. Set-Köpfe ohne scanbare Komponenten (kommen // hier nicht vor) blieben weiterhin in der Liste. final serviceItemsView = serviceItems .where((s) => !injectedParentIds.contains(s.id)) .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, ), // Nicht-scanbare Set-Köpfe dieser Gruppe vor ihre Komponenten // einhängen; `_parentFirst` ordnet Kopf vor Komponenten. for (final item in _parentFirst([ ...?injectedParentsByWarehouseId[group.warehouse.id], ...group.items, ])) _ItemRow( item: item, details: details, onAction: (action) => onItemAction(item, action), suppressScanHint: setParentIds.contains(item.id), setParentComplete: setParentIds.contains(item.id) && setParentComplete(item), ), ], // Gebuchte Dienstleistungen (nicht-scanbare Positionen ohne // eigene Set-Komponenten): 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. if (serviceItemsView.isNotEmpty) ...[ const _ServiceSectionHeader(), for (final item in _parentFirst(serviceItemsView)) _ItemRow( item: item, details: details, onAction: (action) => onItemAction(item, action), suppressScanHint: setParentIds.contains(item.id), setParentComplete: setParentIds.contains(item.id) && setParentComplete(item), ), ], ], ), ), ], ); } } /// 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, ), ), ), ], ), ); } } /// Grünes „Komplett geladen" für einen Set-Kopf (Parent-Artikel), dessen /// (scanbare) Komponenten alle geladen sind. Ersetzt an dieser Stelle den /// „Kein Scanvorgang notwendig"-Hinweis. class _SetCompleteHint extends StatelessWidget { const _SetCompleteHint(); @override Widget build(BuildContext context) { final color = Colors.green.shade700; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.check_circle, size: 16, color: color), const SizedBox(width: 6), Flexible( child: Text( 'Komplett geladen', style: TextStyle( fontSize: 13, color: color, fontWeight: FontWeight.w600, ), ), ), ], ), ); } } /// 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.suppressScanHint = false, this.setParentComplete = false, }); final DeliveryItem item; final TourDetails details; final void Function(_ItemAction action) onAction; /// Unterdrückt den „Kein Scanvorgang notwendig"-Hinweis. Für nicht-scanbare /// **Set-Köpfe** (Parent-Artikel): dort ist der Hinweis irreführend, weil die /// (scanbaren) Komponenten darunter sehr wohl gescannt werden. final bool suppressScanHint; /// `true`, wenn dies ein Set-Kopf ist, dessen Komponenten alle fertig /// geladen sind. Dann wird der (selbst nicht scanbare) Kopf grün dargestellt. final bool setParentComplete; @override Widget build(BuildContext context) { final theme = Theme.of(context); final article = details.articleOf(item.articleId); final warehouse = details.warehouseOf(item.warehouseId); // `true` für nicht-scanbare Positionen (Dienstleistungen / Pauschalen / // Set-Köpfe): die Card wird GENAU SO wie ein scanbarer Artikel gerendert, // bekommt aber unten den Hinweis „Kein Scanvorgang notwendig" und keinen // Mengen-Zähler. Aus dem Artikel abgeleitet, damit ein in eine Lagergruppe // eingehängter nicht-scanbarer Set-Kopf automatisch korrekt rendert. final scanNotRequired = !(article?.scannable ?? false); // Effektiv „fertig": eigener Scan-Status ODER (Set-Kopf, dessen Komponenten // alle geladen sind) → der Kopf wird dann ebenfalls grün. final effectiveDone = item.isDone || setParentComplete; 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 (effectiveDone) { 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'), ), ), ], // Set-Kopf, dessen Komponenten alle geladen sind → grünes // „Komplett geladen". Sonst (nicht-scanbare Position, KEIN // Set-Kopf) der normale „Kein Scanvorgang notwendig"-Hinweis; // bei Set-Köpfen ist der unterdrückt (irreführend). if (scanNotRequired && setParentComplete) ...[ const SizedBox(height: 8), const _SetCompleteHint(), ] else if (scanNotRequired && !suppressScanHint) ...[ 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, ), ), ]; }, ); } }