Files
Holzleitner-Lieferservice-App/lib/feature/loading/presentation/loading_customer_page.dart
Dennis Nemec 456fb59668 Phasenbasierte Lieferübersicht + Beladen-Flow, plus Migrationsplan für Rust-Backend
UI-Restructuring:
- TabBar in scan_page durch dedizierte Phasen ersetzt: Sortieren / Beladen / Ausliefern
- PhaseBloc + PhaseService leiten Phase aus Tour-/Item-States ab
- DeliverySelectionPage (ab 2 Autos) und DeliverySortPage als eigene Flows
- LoadingOverviewPage / LoadingCustomerPage für die Beladephase
- PhaseStepper-Widget im Home für Phasen-Anzeige
- Lager-Differenzierung (Standardlager 0 vs. Außenlager) via WarehouseBadge

Process-Stubs:
- ProcessRepository für Hold/Cancel/Sort/Assign-Flows (stub, bereit für Backend-Anbindung)

Doku:
- docs/BACKEND_MIGRATION.md: Phasenplan für Umstellung auf das neue
  Rust-Backend (OpenAPI-Generator, Keycloak OIDC, Clean-Arch-Layering)
2026-05-14 22:27:56 +02:00

1126 lines
38 KiB
Dart

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<LoadingCustomerPage> createState() => _LoadingCustomerPageState();
}
class _LoadingCustomerPageState extends State<LoadingCustomerPage> {
/// 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<String> _completedCustomersShown = <String>{};
/// 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<String, Set<String>> _heldKeys = <String, Set<String>>{};
/// Aktuell gewähltes Fahrzeug. Wird über den CarSelectBloc synchronisiert,
/// einmalig in initState bevor erster build.
int? _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<CarSelectBloc>().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
/// `<artikelnummer>;<kundennummer>;<belegnummer>`. 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<OperationBloc>().add(
FailOperation(message: "Kein Fahrzeug ausgewählt"),
);
return;
}
final articleNumber = _extractArticleNumber(barcode);
if (articleNumber == null) {
context.read<OperationBloc>().add(
FailOperation(message: "Ungültiger Barcode: $barcode"),
);
return;
}
final tourState = context.read<TourBloc>().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<OperationBloc>().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 <String>{};
if (heldSet.contains(HoldKey.component(parent, comp))) {
context.read<OperationBloc>().add(
FailOperation(message: "Komponente ist zurückgehalten"),
);
return;
}
if (comp.isFullyScanned) {
context.read<OperationBloc>().add(
FailOperation(message: "Komponente bereits vollständig gescannt"),
);
return;
}
context.read<TourBloc>().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<OperationBloc>().add(
FailOperation(message: "Artikel gehört nicht zu diesem Kunden"),
);
return;
}
final heldSet = _heldKeys[delivery.id] ?? const <String>{};
if (heldSet.contains(HoldKey.article(article))) {
context.read<OperationBloc>().add(
FailOperation(message: "Artikel ist zurückgehalten"),
);
return;
}
if (article.scannedAmount + article.scannedRemovedAmount >=
article.amount) {
context.read<OperationBloc>().add(
FailOperation(message: "Artikel bereits vollständig gescannt"),
);
return;
}
context.read<TourBloc>().add(ScanArticleEvent(
articleNumber: articleNumber,
carId: _selectedCarId!.toString(),
deliveryId: delivery.id,
));
}
// ---------------------------------------------------------------------------
// Datenaufbau
// ---------------------------------------------------------------------------
String? _lookupCarPlate(int? 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<LoadingGroup> _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 = <LoadingGroup>[];
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<Article> _activeArticlesOf(LoadingGroup g) {
final held = _heldKeys[g.delivery.id] ?? const <String>{};
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 <String>{};
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 <String>{};
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<CarSelectBloc>().state;
if (carState is CarSelectComplete) {
context.read<PhaseBloc>().add(
PhaseSet(
carId: carState.selectedCar.id.toString(),
phase: DeliveryPhase.ausliefern,
),
);
}
}
// ---------------------------------------------------------------------------
// Dialoge & Aktionen
// ---------------------------------------------------------------------------
Future<void> _maybeShowCompletionDialog(
LoadingGroup current,
List<LoadingGroup> 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<void>.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<void> _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>();
tourBloc.add(CancelDeliveryEvent(deliveryId: current.delivery.id));
final processRepository = tourBloc.processRepository;
unawaited(processRepository.reportDeliveryCancelled(
deliveryId: current.delivery.id,
reason: reason,
));
}
Future<void> _holdItemsFlow(LoadingGroup current) async {
final alreadyHeld =
_heldKeys[current.delivery.id] ?? const <String>{};
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<TourBloc>().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<CarSelectBloc, CarSelectState>(
listener: (context, carState) {
if (carState is CarSelectComplete) {
setState(() => _selectedCarId = carState.selectedCar.id);
}
},
builder: (context, carState) {
return BlocConsumer<TourBloc, TourState>(
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<SettingsBloc>().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<LoadingGroup> 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<LoadingGroup> groups,
required int safeIndex,
required LoadingGroup current,
required bool isCanceled,
required bool useHardwareScanner,
}) {
final progress = _progressOf(current);
final heldSet = _heldKeys[current.delivery.id] ?? const <String>{};
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<Article> 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<Article> articles) {
const standardKey = "_STD";
final Map<String, List<Article>> byKey = {};
final Map<String, String> 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, () => <Article>[]).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<CarSelectBloc, CarSelectState>(
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 }