import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.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/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/delivery/overview/presentation/delivery_fail_page.dart'; import 'package:hl_lieferservice/feature/loading/model/loading_group.dart'; import 'package:hl_lieferservice/feature/loading/util/loading_order.dart'; import 'package:hl_lieferservice/feature/loading/widget/article_row.dart'; import 'package:hl_lieferservice/feature/loading/widget/hold_selection_dialog.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_picker_dialog.dart'; import 'package:hl_lieferservice/feature/scan/presentation/scanner.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; import 'package:hl_lieferservice/model/article.dart'; import 'package:hl_lieferservice/model/delivery.dart'; import 'package:hl_lieferservice/model/tour.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; /// Detail-Ansicht für genau einen Kunden während der Beladen-Phase. /// /// Aufgaben: /// * Scanner-Widget oben — alle erkannten Barcodes werden direkt der /// aktuellen Lieferung zugeordnet (keine Kunden-Auswahl mehr, da bereits /// pro Kunde gefiltert). /// * Übergangs-Dialog "Alle gescannt → Übersicht / Tour starten" pro /// Kunde, einmalig. /// * Aktions-Menü (im Customer-Header) zum Abbrechen / Zurückhalten von /// Artikeln. /// * Navigation: ein einziges Zurück-Symbol oben rechts in der AppBar /// (führt per Pop zurück auf die Übersicht). Keine Pfeil-Navigation, /// kein Phasen-Stepper. /// /// Hold-State: /// Solange das Backend für `reportItemHeld` nur ein Stub ist, lebt der /// Hold-Zustand ausschließlich im lokalen State dieses Widgets. Die UI /// blendet betroffene Positionen entsprechend aus. Bei einem echten /// Backend würde der Stream das Hold-Flag in der Delivery-Datenstruktur /// mitliefern und dieser lokale Cache fiele weg. class LoadingCustomerPage extends StatefulWidget { const LoadingCustomerPage({ super.key, this.initialIndex = 0, }); /// Index in der Beladereihenfolge, mit dem die Page öffnet. Wird typischer- /// weise von der Overview-Page mit dem getappten Kunden-Index gesetzt. final int initialIndex; @override State createState() => _LoadingCustomerPageState(); } class _LoadingCustomerPageState extends State { /// Index des aktuell sichtbaren Kunden innerhalb der Beladereihenfolge. late int _currentIndex; /// Trackt Kunden, für die der "alle gescannt"-Dialog bereits gezeigt /// wurde — verhindert erneutes Auftauchen beim Re-Besuch. final Set _completedCustomersShown = {}; /// Hardware-Scanner-Buffer (analog zur alten ScanPage). final FocusNode _focusNode = FocusNode(); String _buffer = ''; Timer? _bufferTimer; /// Lokaler Hold-Cache (siehe Klassen-Doc). Schlüssel über [HoldKey]. /// Aufgeteilt nach Delivery-ID, damit beim Wechsel zwischen Kunden nichts /// vermischt wird. final Map> _heldKeys = >{}; /// Aktuell gewähltes Fahrzeug. Wird über den CarSelectBloc synchronisiert, /// einmalig in initState bevor erster build. String? _selectedCarId; /// Erkennt den Übergang "Lieferung läuft → abgeschlossen", damit der /// Listener auch dann robust reagiert, wenn der TourBloc zwischendurch /// rebuilded (z. B. wegen pendingScanRequests). bool? _lastCompletionFlag; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); final carState = context.read().state; if (carState is CarSelectComplete) { _selectedCarId = carState.selectedCar.id; } } @override void dispose() { _focusNode.dispose(); _bufferTimer?.cancel(); super.dispose(); } // --------------------------------------------------------------------------- // Scanner-Eingang // --------------------------------------------------------------------------- void _handleKey(KeyEvent event) { if (event is! KeyDownEvent) return; if (event.logicalKey == LogicalKeyboardKey.enter) { _bufferTimer?.cancel(); if (_buffer.isNotEmpty) { _handleBarcodeScanned(_buffer); _buffer = ''; } } else { final character = event.character; if (character != null && character.isNotEmpty) { _buffer += character; _bufferTimer?.cancel(); _bufferTimer = Timer(const Duration(milliseconds: 1000), () { if (_buffer.isNotEmpty) { _handleBarcodeScanned(_buffer); _buffer = ''; } }); } } } /// Extrahiert die Artikelnummer aus einem Barcode der Form /// `;;`. Liefert null bei /// ungültigem Format. Konsistent mit der Logik aus der alten ScanPage. String? _extractArticleNumber(String barcode) { debugPrint("QR CODE: $barcode"); final parts = barcode.split(';'); if (parts.length != 3) return null; final articleNumber = parts[0].trim(); if (articleNumber.isEmpty) return null; return articleNumber; } void _handleBarcodeScanned(String barcode) { if (!mounted) return; if (_selectedCarId == null) { context.read().add( FailOperation(message: "Kein Fahrzeug ausgewählt"), ); return; } final articleNumber = _extractArticleNumber(barcode); if (articleNumber == null) { context.read().add( FailOperation(message: "Ungültiger Barcode: $barcode"), ); return; } final tourState = context.read().state; if (tourState is! TourLoaded) return; // Wir richten den Scan immer an den aktuell sichtbaren Kunden — anders // als in der alten ScanPage gibt es keine kundenübergreifende // Disambiguierung mehr, weil die Page kundenfokussiert ist. final groups = _buildLoadingGroups(tourState); if (_currentIndex < 0 || _currentIndex >= groups.length) return; final current = groups[_currentIndex]; final delivery = current.delivery; if (delivery.state == DeliveryState.canceled) { context.read().add( FailOperation(message: "Lieferung wurde abgebrochen"), ); return; } // 1) Komponenten-Match zuerst (Stückliste). final parent = delivery.findParentOfComponent(articleNumber); if (parent != null) { final comp = parent.findComponent(articleNumber); if (comp == null) return; final heldSet = _heldKeys[delivery.id] ?? const {}; if (heldSet.contains(HoldKey.component(parent, comp))) { context.read().add( FailOperation(message: "Komponente ist zurückgehalten"), ); return; } if (comp.isFullyScanned) { context.read().add( FailOperation(message: "Komponente bereits vollständig gescannt"), ); return; } context.read().add(ScanComponentEvent( componentArticleNumber: articleNumber, carId: _selectedCarId!.toString(), deliveryId: delivery.id, )); return; } // 2) Regulärer Artikel-Scan auf den aktuellen Kunden. final article = delivery.articles.firstWhereOrNull( (a) => a.articleNumber == articleNumber && !a.isParent, ); if (article == null) { context.read().add( FailOperation(message: "Artikel gehört nicht zu diesem Kunden"), ); return; } final heldSet = _heldKeys[delivery.id] ?? const {}; if (heldSet.contains(HoldKey.article(article))) { context.read().add( FailOperation(message: "Artikel ist zurückgehalten"), ); return; } if (article.scannedAmount + article.scannedRemovedAmount >= article.amount) { context.read().add( FailOperation(message: "Artikel bereits vollständig gescannt"), ); return; } context.read().add(ScanArticleEvent( articleNumber: articleNumber, carId: _selectedCarId!.toString(), deliveryId: delivery.id, )); } // --------------------------------------------------------------------------- // Datenaufbau // --------------------------------------------------------------------------- String? _lookupCarPlate(String? carId, Tour tour) { if (carId == null) return null; return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; } /// Beladereihenfolge inkl. abgebrochener Lieferungen für die UI-Anzeige /// (sichtbar, ausgegraut). Pfeil-Navigation darf sie durchscrollen. List _buildLoadingGroups(TourLoaded state) { final carIdStr = _selectedCarId?.toString() ?? ""; final orderedIds = LoadingOrder.computeForCar( state: state, carIdStr: carIdStr, ); final byId = {for (final d in state.tour.deliveries) d.id: d}; final groups = []; for (final id in orderedIds) { final delivery = byId[id]; if (delivery == null) continue; if (delivery.state == DeliveryState.finished) continue; final scannable = delivery.articles.where((a) => a.scannable).toList(growable: false); if (scannable.isEmpty && delivery.state != DeliveryState.canceled) { continue; } groups.add(LoadingGroup( delivery: delivery, articles: scannable, carPlate: _lookupCarPlate(delivery.carId, state.tour), )); } return groups; } /// `true`, wenn die Artikel-Position aus dem Standardlager kommt /// (warehouseNr `null` oder `"0"`). Außenlager-Artikel werden hier nicht /// betrachtet, weil sie nicht in der Belade-Halle scannbar sind — der /// Fahrer holt sie erst beim Kundenbesuch ab. bool _isStandardWarehouse(Article a) { final nr = a.warehouseNr; return nr == null || nr.isEmpty || nr == "0"; } /// Aktive Artikel = Standardlager-Artikel, die NICHT zurückgehalten /// sind. Außenlager-Artikel werden grundsätzlich ausgeschlossen, weil /// sie nicht in der Beladen-Phase scannbar sind. List
_activeArticlesOf(LoadingGroup g) { final held = _heldKeys[g.delivery.id] ?? const {}; return g.articles.where((a) { if (!_isStandardWarehouse(a)) return false; if (held.contains(HoldKey.article(a))) return false; // Komponenten-Hold deaktiviert den Artikel nicht komplett — wir // werten in [_isLogicallyComplete] über die nicht-gehaltenen // Komponenten aus. return true; }).toList(growable: false); } /// `true`, wenn — unter Ausschluss der zurückgehaltenen Positionen UND /// der Außenlager-Artikel — alle scannbaren Einheiten der Lieferung /// gescannt sind. Berücksichtigt Komponenten-Holds individuell. bool _isLogicallyComplete(LoadingGroup g) { final held = _heldKeys[g.delivery.id] ?? const {}; final standardArticles = g.articles.where(_isStandardWarehouse).toList(growable: false); // Edge-Case 1: Lieferung hat überhaupt keine Standardlager-Artikel // (alles extern) → in der Beladen-Phase nichts zu tun → fertig. if (standardArticles.isEmpty) return g.articles.isNotEmpty; final actives = _activeArticlesOf(g); if (actives.isEmpty) { // Edge-Case 2: alle Standardlager-Artikel sind zurückgehalten → der // Fahrer hat alles gemeldet, hier ist nichts mehr zu scannen. return true; } for (final a in actives) { if (a.isParent && a.components.isNotEmpty) { for (final c in a.components) { if (held.contains(HoldKey.component(a, c))) continue; if (!c.isFullyScanned) return false; } } else { if (!a.isFullyScanned) return false; } } return true; } /// Zähler-Tupel "x von y Artikeln gescannt" — bezieht sich auf das /// Standardlager und ignoriert zurückgehaltene Positionen, damit der /// Fortschritt für den Fahrer realistisch bleibt. ({int done, int total}) _progressOf(LoadingGroup g) { final held = _heldKeys[g.delivery.id] ?? const {}; int done = 0; int total = 0; for (final a in g.articles) { if (!_isStandardWarehouse(a)) continue; if (a.isParent && a.components.isNotEmpty) { for (final c in a.components) { if (held.contains(HoldKey.component(a, c))) continue; total += 1; if (c.isFullyScanned) done += 1; } } else { if (held.contains(HoldKey.article(a))) continue; total += 1; if (a.isFullyScanned) done += 1; } } return (done: done, total: total); } // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- void _openOverview() { // Die Übersicht ist der Root-Render der Beladen-Phase (siehe home.dart). // Aus dem Vollbild-Kunden kehrt der Fahrer deshalb per pop dorthin // zurück — kein erneuter Push, damit der Stack flach bleibt. Navigator.of(context).pop(); } void _startTour() { final carState = context.read().state; if (carState is CarSelectComplete) { context.read().add( PhaseSet( carId: carState.selectedCar.id.toString(), phase: DeliveryPhase.ausliefern, ), ); } } // --------------------------------------------------------------------------- // Dialoge & Aktionen // --------------------------------------------------------------------------- Future _maybeShowCompletionDialog( LoadingGroup current, List allGroups, ) async { if (_completedCustomersShown.contains(current.delivery.id)) return; _completedCustomersShown.add(current.delivery.id); // "Tour starten"-Variante zeigen, wenn nach Abschluss dieses Kunden // alle anderen aktiven Lieferungen ebenfalls fertig sind. Abgebrochene // Lieferungen zählen nicht. Wir prüfen das auf Basis der gebauten // Gruppen, weil _heldKeys lokal lebt und in _isLogicallyComplete // berücksichtigt wird. final allDone = allGroups.every((g) => g.delivery.state == DeliveryState.canceled || _isLogicallyComplete(g)); final navigator = Navigator.of(context, rootNavigator: true); // Wir warten einen Frame, damit der TourBloc-Listener zuende ist und // dann der Dialog im stabilen UI-Zustand erscheint. await Future.delayed(Duration.zero); if (!mounted) return; if (allDone) { final choice = await showDialog<_CompletionChoice>( context: navigator.context, builder: (ctx) => AlertDialog( title: const Text("Beladung abgeschlossen"), content: const Text( "Alle Lieferungen sind verladen. Tour jetzt starten?", ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(_CompletionChoice.overview), child: const Text("Übersicht"), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(_CompletionChoice.startTour), child: const Text("Tour starten"), ), ], ), ); if (!mounted || choice == null) return; switch (choice) { case _CompletionChoice.overview: _openOverview(); break; case _CompletionChoice.startTour: _startTour(); break; } } else { final choice = await showDialog<_CompletionChoice>( context: navigator.context, builder: (ctx) => AlertDialog( title: const Text("Alle Artikel gescannt"), content: Text( "Alle Artikel für ${current.delivery.customer.name} wurden " "gescannt. Zurück zur Übersicht?", ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(null), child: const Text("Bleiben"), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(_CompletionChoice.overview), child: const Text("Zur Übersicht"), ), ], ), ); if (!mounted || choice == null) return; if (choice == _CompletionChoice.overview) { _openOverview(); } } } Future _cancelDeliveryFlow(LoadingGroup current) async { final reason = await ReasonPickerDialog.show( context, title: "Lieferung abbrechen", subtitle: current.delivery.customer.name, ); if (!mounted || reason == null) return; // CancelDeliveryEvent feuert den bestehenden tourRepository.cancelDelivery // Aufruf. Parallel der ProcessRepository-Stub für die Audit-Spur des // Grunds — bewusst nicht über den TourBloc geleitet, weil der Bloc den // Grund aktuell nicht kennt und sich der Stub-Aufruf nicht in den Tour- // Stream einklinkt. Sobald ein realer Endpoint existiert, kann das in // einen erweiterten Event-Handler gewandert werden. final tourBloc = context.read(); tourBloc.add(CancelDeliveryEvent(deliveryId: current.delivery.id)); final processRepository = tourBloc.processRepository; unawaited(processRepository.reportDeliveryCancelled( deliveryId: current.delivery.id, reason: reason, )); } Future _holdItemsFlow(LoadingGroup current) async { final alreadyHeld = _heldKeys[current.delivery.id] ?? const {}; final selected = await HoldSelectionDialog.show( context, customerName: current.delivery.customer.name, articles: current.articles, alreadyHeld: alreadyHeld, ); if (!mounted || selected == null || selected.isEmpty) return; final reason = await ReasonPickerDialog.show( context, title: "Artikel zurückhalten", subtitle: "${selected.length} Position(en) für ${current.delivery.customer.name}", ); if (!mounted || reason == null) return; final processRepository = context.read().processRepository; final newHeld = {...alreadyHeld}; for (final item in selected) { unawaited(processRepository.reportItemHeld( deliveryId: current.delivery.id, articleId: item.article.internalId.toString(), componentId: item.component?.articleNumber, reason: reason, )); newHeld.add(item.key); } setState(() { _heldKeys[current.delivery.id] = newHeld; // Hold-Status kann logische Vollständigkeit auslösen → Erkennung neu. _lastCompletionFlag = null; }); } // --------------------------------------------------------------------------- // UI // --------------------------------------------------------------------------- @override Widget build(BuildContext context) { return BlocConsumer( listener: (context, carState) { if (carState is CarSelectComplete) { setState(() => _selectedCarId = carState.selectedCar.id); } }, builder: (context, carState) { return BlocConsumer( listener: (context, tourState) { if (tourState is! TourLoaded) return; final groups = _buildLoadingGroups(tourState); if (_currentIndex >= groups.length && groups.isNotEmpty) { _currentIndex = groups.length - 1; } if (groups.isEmpty) return; final current = groups[_currentIndex.clamp(0, groups.length - 1)]; if (current.delivery.state == DeliveryState.canceled) { _lastCompletionFlag = null; return; } final isComplete = _isLogicallyComplete(current); // Übergang false → true erkannt → Dialog (einmalig pro Kunde). if (_lastCompletionFlag == false && isComplete) { _maybeShowCompletionDialog(current, groups); } _lastCompletionFlag = isComplete; }, builder: (context, tourState) { if (tourState is TourLoadingFailed) { return const DeliveryLoadingFailedPage(); } if (tourState is! TourLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } final settingsState = context.read().state; final useHardwareScanner = settingsState is AppSettingsLoaded && settingsState.settings.useHardwareScanner; final groups = _buildLoadingGroups(tourState); return _buildScaffold( tourState: tourState, groups: groups, useHardwareScanner: useHardwareScanner, ); }, ); }, ); } Widget _buildScaffold({ required TourLoaded tourState, required List groups, required bool useHardwareScanner, }) { final hasGroups = groups.isNotEmpty; final safeIndex = hasGroups ? _currentIndex.clamp(0, groups.length - 1) : 0; final current = hasGroups ? groups[safeIndex] : null; final isCanceled = current?.delivery.state == DeliveryState.canceled; final theme = Theme.of(context); return Scaffold( drawer: const HomeAppDrawer(), appBar: AppBar( backgroundColor: theme.primaryColor, foregroundColor: theme.colorScheme.onPrimary, leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "Zurück", onPressed: () => Navigator.of(context).pop(), ), title: const Text( "Lieferdetails", style: TextStyle(fontWeight: FontWeight.w600), ), actions: const [ _AppBarPlateBadge(), SizedBox(width: 8), ], ), body: KeyboardListener( focusNode: _focusNode, onKeyEvent: _handleKey, child: SafeArea( top: false, child: !hasGroups ? const _EmptyState() : _buildCustomerView( tourState: tourState, groups: groups, safeIndex: safeIndex, current: current!, isCanceled: isCanceled, useHardwareScanner: useHardwareScanner, ), ), ), ); } Widget _buildCustomerView({ required TourLoaded tourState, required List groups, required int safeIndex, required LoadingGroup current, required bool isCanceled, required bool useHardwareScanner, }) { final progress = _progressOf(current); final heldSet = _heldKeys[current.delivery.id] ?? const {}; final theme = Theme.of(context); return Column( children: [ if (tourState.pendingScanRequests > 0) const LinearProgressIndicator(), _ScannerSlot( isCanceled: isCanceled, useHardwareScanner: useHardwareScanner, onBarcode: _handleBarcodeScanned, ), _CustomerHeader( position: safeIndex + 1, total: groups.length, current: current, progress: progress, isCanceled: isCanceled, actionMenuBuilder: (ctx) => _buildAppBarMenu(ctx, current), ), const Divider(height: 1), Expanded( child: Opacity( opacity: isCanceled ? 0.45 : 1.0, child: ListView( padding: const EdgeInsets.only(top: 4, bottom: 16), children: [ for (final section in _groupArticlesByWarehouse(current.articles)) ...[ _WarehouseSectionHeader( label: section.label, isExternal: section.isExternal, ), for (final article in section.articles) ArticleRow( article: article, isHeld: heldSet.contains(HoldKey.article(article)), disabled: isCanceled, heldComponents: heldSet, ), ], if (isCanceled) Padding( padding: const EdgeInsets.all(16), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), border: Border.all( color: Colors.red.withValues(alpha: 0.3)), ), child: Row( children: [ const Icon(Icons.cancel_outlined, color: Colors.red), const SizedBox(width: 12), Expanded( child: Text( "Diese Lieferung wurde abgebrochen und wird " "heute nicht ausgeliefert.", style: theme.textTheme.bodyMedium, ), ), ], ), ), ), ], ), ), ), ], ); } PopupMenuButton<_MenuAction> _buildAppBarMenu( BuildContext context, LoadingGroup current, ) { return PopupMenuButton<_MenuAction>( icon: const Icon(Icons.more_vert), tooltip: "Weitere Aktionen", onSelected: (action) async { switch (action) { case _MenuAction.cancel: await _cancelDeliveryFlow(current); break; case _MenuAction.hold: await _holdItemsFlow(current); break; } }, itemBuilder: (ctx) => [ PopupMenuItem( value: _MenuAction.hold, enabled: current.delivery.state != DeliveryState.canceled, child: const ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: Icon(Icons.pause_circle_outline), title: Text("Artikel nicht heute liefern"), ), ), PopupMenuItem( value: _MenuAction.cancel, enabled: current.delivery.state != DeliveryState.canceled, child: const ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: Icon(Icons.cancel_outlined, color: Colors.red), title: Text( "Lieferung komplett abbrechen", style: TextStyle(color: Colors.red), ), ), ), ], ); } } // --------------------------------------------------------------------------- // Helper-Widgets // --------------------------------------------------------------------------- class _EmptyState extends StatelessWidget { const _EmptyState(); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant), const SizedBox(height: 12), Text( "Keine Lieferungen zum Beladen", style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( "Für das ausgewählte Fahrzeug ist die Beladereihenfolge leer.", textAlign: TextAlign.center, style: TextStyle(color: scheme.onSurfaceVariant), ), ), ], ), ); } } class _ScannerSlot extends StatelessWidget { const _ScannerSlot({ required this.isCanceled, required this.useHardwareScanner, required this.onBarcode, }); final bool isCanceled; final bool useHardwareScanner; final void Function(String) onBarcode; @override Widget build(BuildContext context) { if (isCanceled) { return Container( height: 110, margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), decoration: BoxDecoration( color: Colors.grey.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.withValues(alpha: 0.4)), ), child: const Center( child: Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Text( "Diese Lieferung wurde abgebrochen.", style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black54), textAlign: TextAlign.center, ), ), ), ); } if (useHardwareScanner) { // Hardware-Scanner liefert Eingaben über den KeyboardListener. // Wir zeigen einen kompakten Hinweis-Bereich statt der Kamera. return Container( height: 60, margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), alignment: Alignment.center, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: const [ Icon(Icons.qr_code_scanner_outlined, size: 18), SizedBox(width: 8), Text("Bereit für Hardware-Scan"), ], ), ); } return Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), child: BarcodeScannerWidget(onBarcodeDetected: onBarcode), ); } } class _CustomerHeader extends StatelessWidget { const _CustomerHeader({ required this.position, required this.total, required this.current, required this.progress, required this.isCanceled, required this.actionMenuBuilder, }); final int position; final int total; final LoadingGroup current; final ({int done, int total}) progress; final bool isCanceled; final Widget Function(BuildContext) actionMenuBuilder; @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = isCanceled ? Colors.red.shade400 : theme.colorScheme.primary; return Padding( padding: const EdgeInsets.fromLTRB(16, 6, 4, 8), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ CircleAvatar( backgroundColor: color, foregroundColor: theme.colorScheme.onPrimary, child: Text( "$position", style: const TextStyle(fontWeight: FontWeight.bold), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( current.delivery.customer.name, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, decoration: isCanceled ? TextDecoration.lineThrough : TextDecoration.none, ), ), ), Text( "Kunde $position/$total", style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: 2), Text( current.delivery.customer.address.toString(), style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 6), Text( isCanceled ? "Lieferung abgebrochen" : "${progress.done}/${progress.total} Artikel gescannt", style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: isCanceled ? Colors.red.shade700 : (progress.done == progress.total && progress.total > 0 ? Colors.green.shade700 : theme.colorScheme.onSurface), ), ), ], ), ), actionMenuBuilder(context), ], ), ); } } /// Eine Lager-Sektion in der Artikel-Liste — Header + zugehörige Artikel. class _WarehouseSection { const _WarehouseSection({ required this.label, required this.isExternal, required this.articles, }); final String label; final bool isExternal; final List
articles; } /// Gruppiert Artikel nach Lager. Standardlager (Nummer "0" oder leer/null) /// landet IMMER an erster Stelle — auch wenn keine Artikel dort liegen, /// taucht der Header eines aktiven Außenlagers darunter konsistent /// auf. Außenlager folgen alphabetisch nach Label. List<_WarehouseSection> _groupArticlesByWarehouse(List
articles) { const standardKey = "_STD"; final Map> byKey = {}; final Map labels = {}; bool isExternal(String? nr) => nr != null && nr.isNotEmpty && nr != "0"; for (final a in articles) { final external = isExternal(a.warehouseNr); final key = external ? a.warehouseNr! : standardKey; final label = external ? ((a.warehouseName?.isNotEmpty ?? false) ? a.warehouseName! : "Lager ${a.warehouseNr}") : "Standardlager"; byKey.putIfAbsent(key, () =>
[]).add(a); labels[key] = label; } final keys = byKey.keys.toList(); keys.sort((a, b) { // Standardlager IMMER ganz oben. if (a == standardKey) return -1; if (b == standardKey) return 1; return labels[a]!.compareTo(labels[b]!); }); return [ for (final k in keys) _WarehouseSection( label: labels[k]!, isExternal: k != standardKey, articles: byKey[k]!, ), ]; } /// Voller-Breite-Header über einer Lager-Sektion. Standardlager neutral, /// Außenlager in deutlichem Orange-Akzent — damit der Fahrer beim Scrollen /// sofort sieht, wo er noch hinfahren muss. class _WarehouseSectionHeader extends StatelessWidget { const _WarehouseSectionHeader({ required this.label, required this.isExternal, }); final String label; final bool isExternal; @override Widget build(BuildContext context) { final theme = Theme.of(context); final Color bg; final Color fg; final IconData icon; if (isExternal) { bg = Colors.deepOrange.withValues(alpha: 0.15); fg = Colors.deepOrange.shade800; icon = Icons.warehouse_outlined; } else { bg = theme.colorScheme.surfaceContainerHighest; fg = theme.colorScheme.onSurfaceVariant; icon = Icons.home_work_outlined; } return Container( margin: const EdgeInsets.fromLTRB(0, 8, 0, 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: bg, border: isExternal ? Border( left: BorderSide(color: Colors.deepOrange.shade700, width: 4), ) : null, ), child: Row( children: [ Icon(icon, size: 18, color: fg), const SizedBox(width: 8), Expanded( child: Text( isExternal ? "Außenlager: $label" : label, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: fg, ), ), ), ], ), ); } } /// Plate-Badge für die AppBar — liest das aktiv gewählte Fahrzeug aus dem /// [CarSelectBloc]. Nutzt einen halbtransparenten Hintergrund, damit das /// Badge auch auf der Primary-Color-AppBar gut lesbar bleibt. class _AppBarPlateBadge extends StatelessWidget { const _AppBarPlateBadge(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is! CarSelectComplete) return const SizedBox.shrink(); final onPrimary = Theme.of(context).colorScheme.onPrimary; return Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: onPrimary.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.local_shipping, size: 16, color: onPrimary), const SizedBox(width: 6), Text( state.selectedCar.plate, style: TextStyle( color: onPrimary, fontWeight: FontWeight.bold, fontSize: 14, ), ), ], ), ), ); }, ); } } enum _MenuAction { cancel, hold } enum _CompletionChoice { overview, startTour }