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)
1126 lines
38 KiB
Dart
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 }
|