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)
This commit is contained in:
Dennis Nemec
2026-05-14 22:27:56 +02:00
parent ac6b03227d
commit 456fb59668
29 changed files with 5425 additions and 1015 deletions

View File

@ -0,0 +1,125 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/service/phase_service.dart';
/// Liefert die Anzahl der dem aktuellen Fahrer-Team zugeordneten Fahrzeuge.
/// Wird vom [PhaseBloc] bei der Ermittlung der Eintrittsphase aufgerufen.
///
/// Optional: ist die Tour noch nicht geladen oder die Anzahl unbekannt,
/// liefert die Funktion `null` zurück — der BLoC verwendet dann den
/// Default-Eintritt [DeliveryPhase.sortieren].
typedef CarCountResolver = int? Function();
/// Zentraler State für die aktuelle Phase je Fahrzeug. Persistiert über
/// [PhaseService] auf datumsbezogene SharedPreferences-Keys (siehe Service).
///
/// Eintrittsphase nach Fahrzeugauswahl:
/// * 1 Auto im Team → [DeliveryPhase.sortieren] (bisheriges Verhalten).
/// * ≥2 Autos → [DeliveryPhase.auswaehlen] (neuer Auswahl-Schritt).
///
/// Ist bereits eine Phase persistiert, wird diese verwendet (Resume nach
/// Neustart der App). Die Eintrittslogik greift also nur beim "ersten Load
/// des Tages" für ein Fahrzeug.
class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
final PhaseService phaseService;
/// Liefert die aktuelle Anzahl der Team-Fahrzeuge. Wird vom umgebenden
/// Provider so verdrahtet, dass sie aus dem [TourBloc] kommt.
final CarCountResolver? carCountResolver;
PhaseBloc({
PhaseService? phaseService,
this.carCountResolver,
}) : phaseService = phaseService ?? PhaseService(),
super(PhaseInitial()) {
on<PhaseLoadForCar>(_load);
on<PhaseLoaded>(_applyLoaded);
on<PhaseSet>(_set);
}
PhaseReady _ensureReady() {
final current = state;
return current is PhaseReady
? current
: PhaseReady(phaseByCar: const {});
}
/// Bestimmt die initiale Phase für ein frisch ausgewähltes Fahrzeug.
/// Sobald die Tour bekannt ist und ≥2 Fahrzeuge enthält, startet der
/// Fahrer im Auswahl-Schritt; sonst direkt im Sortieren.
DeliveryPhase _entryPhase() {
final count = carCountResolver?.call();
if (count != null && count >= 2) return DeliveryPhase.auswaehlen;
return DeliveryPhase.sortieren;
}
Future<void> _load(PhaseLoadForCar event, Emitter<PhaseState> emit) async {
final current = _ensureReady();
// Wenn bereits geladen, nichts tun — der Stepper-Tap entscheidet aktiv.
if (current.phaseByCar.containsKey(event.carId)) return;
try {
final persisted = await phaseService.load(event.carId);
final persistedMax = await phaseService.loadMax(event.carId);
final phase = persisted ?? _entryPhase();
// Max ist mindestens die aktuelle Phase. Falls in der Persistenz ein
// höherer Wert steht (Rücksprung), den nehmen.
DeliveryPhase maxPhase = phase;
if (persistedMax != null && persistedMax.index > maxPhase.index) {
maxPhase = persistedMax;
}
if (persisted == null) {
// Erste Phase nach Fahrzeugauswahl direkt persistieren, damit
// ein Resume nach App-Neustart die Phase kennt.
await phaseService.save(event.carId, phase);
await phaseService.saveMax(event.carId, maxPhase);
} else if (persistedMax == null) {
// Migration: alte Tage ohne Max-Tracking → einmalig nachziehen.
await phaseService.saveMax(event.carId, maxPhase);
}
add(PhaseLoaded(
carId: event.carId,
phase: phase,
maxPhase: maxPhase,
));
} catch (e, st) {
debugPrint("PhaseBloc._load: $e $st");
// Fail-soft: ohne Persistenz weiter, damit der Flow nicht hängen bleibt.
final fallback = _entryPhase();
add(PhaseLoaded(
carId: event.carId,
phase: fallback,
maxPhase: fallback,
));
}
}
void _applyLoaded(PhaseLoaded event, Emitter<PhaseState> emit) {
final current = _ensureReady();
emit(current.withLoaded(event.carId, event.phase, event.maxPhase));
}
Future<void> _set(PhaseSet event, Emitter<PhaseState> emit) async {
final current = _ensureReady();
final next = current.withPhase(event.carId, event.phase);
emit(next);
try {
await phaseService.save(event.carId, event.phase);
// withPhase hat das Max ggf. hochgezogen — persistieren, damit ein
// Neustart die "höchste erreichte Phase" kennt.
final newMax = next.maxPhaseFor(event.carId);
if (newMax != null) {
await phaseService.saveMax(event.carId, newMax);
}
} catch (e, st) {
debugPrint("PhaseBloc._set: $e $st");
// UI bleibt konsistent, Persistenz-Fehler ignorieren wir bewusst —
// beim nächsten Setzen wird erneut versucht.
}
}
}

View File

@ -0,0 +1,41 @@
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
abstract class PhaseEvent {}
/// Lädt (falls noch nicht geschehen) die persistierte Phase für ein Fahrzeug
/// und emittiert sie. Falls nichts persistiert ist, ermittelt der
/// [PhaseBloc] die Eintrittsphase abhängig von der Anzahl der Team-
/// Fahrzeuge:
/// * 1 Auto → [DeliveryPhase.sortieren]
/// * ≥2 Autos → [DeliveryPhase.auswaehlen]
class PhaseLoadForCar extends PhaseEvent {
final String carId;
PhaseLoadForCar({required this.carId});
}
/// Explizites Setzen einer Phase für ein Fahrzeug — wird sowohl beim
/// Sprung über den Stepper als auch nach automatischem Phasen-Wechsel
/// (z. B. Sortierung bestätigt → beladen) aufgerufen. Persistiert.
class PhaseSet extends PhaseEvent {
final String carId;
final DeliveryPhase phase;
PhaseSet({required this.carId, required this.phase});
}
/// Setzt die Phase für ein Fahrzeug, ohne sie zu persistieren. Wird intern
/// nach einem Load verwendet, da die Quelle bereits SharedPreferences ist.
/// [maxPhase] ist die höchste am Tag erreichte Phase aus der Persistenz —
/// fällt sie weg, wird die aktuelle [phase] als Max angenommen.
class PhaseLoaded extends PhaseEvent {
final String carId;
final DeliveryPhase phase;
final DeliveryPhase maxPhase;
PhaseLoaded({
required this.carId,
required this.phase,
required this.maxPhase,
});
}

View File

@ -0,0 +1,58 @@
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
abstract class PhaseState {}
class PhaseInitial extends PhaseState {}
/// Die aktive Phase pro Fahrzeug. Die Map ist pro Auto-ID separiert,
/// weil zwei Lieferketten am selben Tag (selten, aber möglich) parallel
/// laufen könnten und sich gegenseitig nicht überschreiben dürfen.
///
/// Zusätzlich wird die **höchste je heute erreichte Phase** pro Fahrzeug
/// gehalten ([maxPhaseByCar]). Damit kann der Stepper Vorwärts-Sprünge
/// auf bereits besuchte Phasen erlauben, auch wenn der Fahrer vorher
/// zurückgesprungen ist.
class PhaseReady extends PhaseState {
final Map<String, DeliveryPhase> phaseByCar;
final Map<String, DeliveryPhase> maxPhaseByCar;
PhaseReady({
required this.phaseByCar,
this.maxPhaseByCar = const {},
});
DeliveryPhase? phaseFor(String carId) => phaseByCar[carId];
/// Höchste am Tag erreichte Phase für [carId]. Fallback: aktuelle Phase
/// — wenn nichts gemerkt ist, gilt die aktuelle Phase als "höchste".
DeliveryPhase? maxPhaseFor(String carId) =>
maxPhaseByCar[carId] ?? phaseByCar[carId];
/// Setzt eine neue Phase und bumpt das Max-Tracking, falls die neue Phase
/// in der Enum-Reihenfolge höher ist als das bisherige Max.
PhaseReady withPhase(String carId, DeliveryPhase phase) {
final next = Map<String, DeliveryPhase>.from(phaseByCar);
next[carId] = phase;
final nextMax = Map<String, DeliveryPhase>.from(maxPhaseByCar);
final currentMax = nextMax[carId];
if (currentMax == null || phase.index > currentMax.index) {
nextMax[carId] = phase;
}
return PhaseReady(phaseByCar: next, maxPhaseByCar: nextMax);
}
/// Wird nach dem Laden aus dem [PhaseService] verwendet, damit die echte
/// historische Max-Phase übernommen wird (nicht automatisch geboostet).
PhaseReady withLoaded(
String carId,
DeliveryPhase phase,
DeliveryPhase maxPhase,
) {
final next = Map<String, DeliveryPhase>.from(phaseByCar);
next[carId] = phase;
final nextMax = Map<String, DeliveryPhase>.from(maxPhaseByCar);
nextMax[carId] = maxPhase;
return PhaseReady(phaseByCar: next, maxPhaseByCar: nextMax);
}
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_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/repository/process_repository.dart';
import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart';
import 'package:hl_lieferservice/feature/delivery/overview/service/distance_service.dart';
import 'package:hl_lieferservice/feature/delivery/overview/service/reorder_service.dart';
@ -20,10 +21,16 @@ class TourBloc extends Bloc<TourEvent, TourState> {
OperationBloc opBloc;
AuthBloc authBloc;
TourRepository tourRepository;
ProcessRepository processRepository;
StreamSubscription? _combinedSubscription;
TourBloc({required this.opBloc, required this.authBloc, required this.tourRepository})
: super(TourInitial()) {
TourBloc({
required this.opBloc,
required this.authBloc,
required this.tourRepository,
ProcessRepository? processRepository,
}) : processRepository = processRepository ?? ProcessRepository(),
super(TourInitial()) {
_combinedSubscription = CombineLatestStream.combine2(
tourRepository.tour,
tourRepository.paymentOptions,
@ -41,6 +48,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
on<LoadTour>(_load);
on<AssignCarEvent>(_assignCar);
on<UnassignDeliveryEvent>(_unassignDelivery);
on<IncrementArticleScanAmount>(_increment);
on<ScanArticleEvent>(_scan);
on<ScanComponentEvent>(_scanComponent);
@ -59,6 +67,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
on<RequestDeliveryDistanceEvent>(_calculateDistances);
on<RequestSortingInformationEvent>(_requestSortingInformation);
on<ReorderDeliveryEvent>(_reorderDelivery);
on<ReplaceSortingEvent>(_replaceSorting);
on<ConfirmSortingEvent>(_confirmSorting);
on<EnsureSortingForCarEvent>(_ensureSortingForCar);
on<CarsLoadedEvent>(_carsLoaded);
on<SetArticleAmountEvent>(_setArticleAmount);
}
@ -128,6 +139,128 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
Future<void> _ensureSortingForCar(
EnsureSortingForCarEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is! TourLoaded) return;
// Ein-Auto-Teams: alle Tour-Lieferungen sind die eigenen — die
// Zuordnung erfolgt erst beim Scannen.
// Mehr-Auto-Teams: nach dem Auswahl-Schritt sind die Lieferungen
// bereits per `assignCar` mit der eigenen carId verknüpft; wir
// sortieren dann ausschließlich die eigenen.
final cars = currentState.tour.driver.cars;
final allTourIds = cars.length >= 2
? currentState.tour.deliveries
.where((d) => d.carId?.toString() == event.carId)
.map((d) => d.id)
.toList(growable: false)
: currentState.tour.deliveries
.map((d) => d.id)
.toList(growable: false);
final existing =
currentState.sortingInformation[event.carId] ?? const <String>[];
// Bestehende Reihenfolge beibehalten (nur Einträge, die noch in der Tour
// sind), dann fehlende Tour-Lieferungen hinten anhängen.
final allIdsSet = allTourIds.toSet();
final seen = <String>{};
final merged = <String>[];
for (final id in existing) {
if (allIdsSet.contains(id) && seen.add(id)) merged.add(id);
}
for (final id in allTourIds) {
if (seen.add(id)) merged.add(id);
}
if (merged.length == existing.length &&
_listEquals(merged, existing.toList())) {
// Nichts zu tun — Bucket ist bereits konsistent.
return;
}
final container = {...currentState.sortingInformation};
container[event.carId] = merged;
await ReorderService().saveSortingInformation(container);
emit(currentState.copyWith(sortingInformation: container));
}
bool _listEquals(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
Future<void> _replaceSorting(
ReplaceSortingEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is TourLoaded) {
await ReorderService().saveSortingInformation(
event.newSortingInformation,
);
emit(
currentState.copyWith(
sortingInformation: event.newSortingInformation,
),
);
}
}
Future<void> _confirmSorting(
ConfirmSortingEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is! TourLoaded) return;
emit(
currentState.copyWith(
isPersistingSorting: true,
clearSortingPersistError: true,
),
);
final orderedIds =
currentState.sortingInformation[event.carId] ?? const <String>[];
try {
await processRepository.persistDeliveryOrder(
carId: event.carId,
orderedDeliveryIds: orderedIds,
);
// Hinweis: Der eigentliche Phasen-Wechsel auf `beladen` läuft über
// den [PhaseBloc], angestoßen vom UI-Listener in DeliverySortPage.
// So bleibt der Phasen-State zentral und der TourBloc unabhängig.
// Re-read the latest state — earlier emits or upstream tour updates
// may have replaced it while the await was in flight.
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(isPersistingSorting: false));
}
} catch (e, st) {
debugPrint("Fehler beim Persistieren der Sortierung: $e $st");
final latest = state;
if (latest is TourLoaded) {
emit(
latest.copyWith(
isPersistingSorting: false,
sortingPersistError:
"Reihenfolge konnte nicht gespeichert werden. Bitte erneut versuchen.",
),
);
}
}
}
void _calculateDistances(
RequestDeliveryDistanceEvent event,
Emitter<TourState> emit,
@ -230,6 +363,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
distances: Map<String, double>.from(currentState.distances ?? {}),
sortingInformation: currentState.sortingInformation,
pendingScanRequests: currentState.pendingScanRequests,
isPersistingSorting: currentState.isPersistingSorting,
sortingPersistError: currentState.sortingPersistError,
),
);
}
@ -409,6 +544,29 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
Future<void> _unassignDelivery(
UnassignDeliveryEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is! TourLoaded) return;
opBloc.add(StartOperation());
try {
// Stub-Aufruf — solange kein echter Endpoint existiert, gibt es
// hier kein Tour-Update; das lokale Modell behält den alten carId-
// Wert. Mit dem echten Endpoint wird die Tour anschließend über
// den tourRepository-Stream aktualisiert (analog zu assignCar).
await processRepository.unassignDeliveryFromCar(
deliveryId: event.deliveryId,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Freigeben der Lieferung");
}
}
Future<void> _load(LoadTour event, Emitter<TourState> emit) async {
try {
emit(TourLoading());

View File

@ -37,6 +37,41 @@ class ReorderDeliveryEvent extends TourEvent {
ReorderDeliveryEvent({required this.newPosition, required this.oldPosition, required this.carId});
}
/// Ersetzt die komplette Sortier-Information (z. B. beim Zurücksetzen auf
/// die Default-Reihenfolge in der Sortier-Page). Persistiert lokal über
/// den ReorderService, kein Backend-Call.
class ReplaceSortingEvent extends TourEvent {
final String carId;
final Map<String, List<String>> newSortingInformation;
ReplaceSortingEvent({
required this.carId,
required this.newSortingInformation,
});
}
/// Bestätigung der Sortierung durch den Fahrer. Löst den (aktuell ge-
/// stubbten) Backend-Call zur Persistierung der Reihenfolge aus und
/// schaltet bei Erfolg in Phase [DeliveryPhase.beladen].
class ConfirmSortingEvent extends TourEvent {
final String carId;
ConfirmSortingEvent({required this.carId});
}
/// Stellt sicher, dass der Sortier-Bucket für [carId] alle aktuell in der
/// Tour vorhandenen Lieferungen enthält — und zwar UNABHÄNGIG von
/// `delivery.carId`. Hintergrund: zum Sortier-Zeitpunkt sind die Lieferungen
/// dem Fahrzeug oft noch nicht zugeordnet (das passiert erst beim Scannen
/// während des Beladens). Der Fahrer hat aber bereits sein Tagesfahrzeug
/// gewählt, also gehören alle Tour-Lieferungen in diesen Bucket. Bestehende
/// Reihenfolge bleibt erhalten, fehlende IDs werden hinten angehängt.
class EnsureSortingForCarEvent extends TourEvent {
final String carId;
EnsureSortingForCarEvent({required this.carId});
}
class TourUpdated extends TourEvent {
Tour tour;
List<Payment> payments;
@ -63,6 +98,17 @@ class AssignCarEvent extends TourEvent {
AssignCarEvent({required this.deliveryId, required this.carId});
}
/// Hebt die Fahrzeug-Zuordnung einer Lieferung wieder auf. Wird im
/// Auswahl-Schritt ausgelöst, wenn der Fahrer eine eigene Lieferung
/// "freigibt", damit ein Kollege sie übernehmen kann. Aktuell ruft der
/// Handler nur den ProcessRepository-Stub auf — ein echtes Update der
/// Tour erfolgt erst mit dem realen Backend-Endpoint (siehe Repository).
class UnassignDeliveryEvent extends TourEvent {
final String deliveryId;
UnassignDeliveryEvent({required this.deliveryId});
}
class IncrementArticleScanAmount extends TourEvent {
String internalArticleId;
String deliveryId;

View File

@ -19,12 +19,23 @@ class TourLoaded extends TourState {
/// rapid-fire scans coexist without one prematurely clearing the indicator.
int pendingScanRequests;
/// True während der Backend-Call zur Persistierung der Sortier-Reihenfolge
/// läuft. Wird vom Bestätigungs-Button in der Sortier-Page für Spinner
/// und Button-Disabled-State ausgewertet.
bool isPersistingSorting;
/// Letzte Fehlermeldung des Sortier-Persist-Calls. Wird nach Anzeige
/// durch das UI ge-leert (z. B. nach SnackBar).
String? sortingPersistError;
TourLoaded({
required this.tour,
this.distances,
required this.paymentOptions,
required this.sortingInformation,
this.pendingScanRequests = 0,
this.isPersistingSorting = false,
this.sortingPersistError,
});
TourLoaded copyWith({
@ -33,6 +44,9 @@ class TourLoaded extends TourState {
List<Payment>? paymentOptions,
Map<String, List<String>>? sortingInformation,
int? pendingScanRequests,
bool? isPersistingSorting,
String? sortingPersistError,
bool clearSortingPersistError = false,
}) {
return TourLoaded(
tour: tour ?? this.tour,
@ -40,6 +54,10 @@ class TourLoaded extends TourState {
paymentOptions: paymentOptions ?? this.paymentOptions,
sortingInformation: sortingInformation ?? this.sortingInformation,
pendingScanRequests: pendingScanRequests ?? this.pendingScanRequests,
isPersistingSorting: isPersistingSorting ?? this.isPersistingSorting,
sortingPersistError: clearSortingPersistError
? null
: (sortingPersistError ?? this.sortingPersistError),
);
}
}

View File

@ -0,0 +1,94 @@
/// Die expliziten Phasen des Lieferprozesses nach der Fahrzeugauswahl.
///
/// Die Reihenfolge im Enum entspricht der logischen Tagesabfolge:
/// 1. [auswaehlen] (nur bei Teams mit mehreren Fahrzeugen)
/// 2. [sortieren]
/// 3. [beladen]
/// 4. [ausliefern]
///
/// Wichtig: Die Schrittnummer ist NICHT statisch (z. B. "Sortieren = 2"),
/// sondern hängt von der für den Fahrer sichtbaren Phasenmenge ab — bei
/// Ein-Auto-Teams entfällt [auswaehlen] und [sortieren] wird zu Schritt 1.
/// Die Berechnung erfolgt deshalb dynamisch im [PhaseStepper] bzw. in der
/// BLoC-Schicht und nicht auf dem Enum selbst.
///
/// Der Fahrer darf zwischen bereits erreichten Phasen frei zurückspringen
/// — die Phase ist daher kein Zwangs-Stepper, sondern markiert lediglich
/// die aktuelle Bühne des Tages.
enum DeliveryPhase {
/// Aus den Tour-Lieferungen die eigenen Aufträge auswählen / umladen
/// (nur bei ≥2 Fahrzeugen im Team relevant).
auswaehlen,
/// Reihenfolge der Lieferungen festlegen (Drag & Drop).
sortieren,
/// Beladung mit Scan, geführt nach der gewählten Reihenfolge.
beladen,
/// Beladung abgeschlossen, Auslieferung läuft.
ausliefern,
}
extension DeliveryPhaseExtension on DeliveryPhase {
/// Stabile Schlüssel für die Persistenz. Bewusst entkoppelt von der
/// enum-Reihenfolge, damit zukünftige Umbenennungen den Disk-State nicht
/// brechen.
String get persistenceKey => switch (this) {
DeliveryPhase.auswaehlen => "auswaehlen",
DeliveryPhase.sortieren => "sortieren",
DeliveryPhase.beladen => "beladen",
DeliveryPhase.ausliefern => "ausliefern",
};
/// Anzeigename für die UI (Banner, Stepper).
String get displayName => switch (this) {
DeliveryPhase.auswaehlen => "Auswählen",
DeliveryPhase.sortieren => "Sortieren",
DeliveryPhase.beladen => "Beladen",
DeliveryPhase.ausliefern => "Ausliefern",
};
/// Schrittnummer (1-basiert) in einer gegebenen Phasen-Sichtbarkeitsliste.
///
/// Dynamische Variante, die [stepNumber] (statisch) ersetzt — der Stepper
/// erhält je nach Team-Konfiguration eine andere [visiblePhases]-Liste und
/// bestimmt darüber, welche Nummer hier angezeigt wird.
///
/// Liefert `-1`, wenn die Phase nicht in [visiblePhases] enthalten ist.
int stepNumberIn(List<DeliveryPhase> visiblePhases) {
final idx = visiblePhases.indexOf(this);
return idx == -1 ? -1 : idx + 1;
}
static DeliveryPhase? fromPersistenceKey(String? key) {
if (key == null) return null;
for (final phase in DeliveryPhase.values) {
if (phase.persistenceKey == key) return phase;
}
return null;
}
/// Liefert die für den Fahrer sichtbaren Phasen abhängig von der Anzahl
/// der Team-Fahrzeuge:
///
/// * 0 oder 1 Auto → [sortieren, beladen, ausliefern]
/// * ≥2 Autos → [auswaehlen, sortieren, beladen, ausliefern]
///
/// Single source of truth für Stepper und Banner.
static List<DeliveryPhase> visiblePhasesForCarCount(int carCount) {
if (carCount >= 2) {
return const [
DeliveryPhase.auswaehlen,
DeliveryPhase.sortieren,
DeliveryPhase.beladen,
DeliveryPhase.ausliefern,
];
}
return const [
DeliveryPhase.sortieren,
DeliveryPhase.beladen,
DeliveryPhase.ausliefern,
];
}
}

View File

@ -2,12 +2,20 @@ import 'package:flutter/material.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/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import '../../bloc/tour_bloc.dart';
import '../../bloc/tour_state.dart';
/// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene
/// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt
/// nur noch die letzte Phase. Der Phasen-Stepper bleibt sichtbar, damit der
/// Fahrer bei Bedarf zurückspringen kann; das BottomNav der Auslieferung
/// liegt im umgebenden `Home`-Scaffold.
class DeliveryOverviewPage extends StatefulWidget {
const DeliveryOverviewPage({super.key});
@ -49,37 +57,20 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
@override
Widget build(BuildContext context) {
final carState = context.watch<CarSelectBloc>().state;
final carId = carState is CarSelectComplete
? carState.selectedCar.id.toString()
: "";
return Scaffold(
appBar: AppBar(
title: const Text("Auslieferung"),
centerTitle: false,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
actions: [
if (carState is CarSelectComplete)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_shipping,
color: Theme.of(context).colorScheme.onSecondary,
size: 20,
),
const SizedBox(width: 6),
Text(
carState.selectedCar.plate,
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
// Drawer ist hier ebenfalls aktiv, damit der Menü-Button des Steppers
// konsistent über alle Phasen funktioniert.
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.ausliefern,
carId: carId,
),
),
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {

View File

@ -0,0 +1,479 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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/phase_stepper/phase_stepper.dart';
/// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen
/// Lieferungen aus dem gemeinsamen Tour-Pool.
///
/// Aufbau:
/// * Tab "Verfügbar" — alle Lieferungen, die noch keinem Fahrzeug
/// zugeordnet sind. Multiselect, Bulk-Bestätigung per BottomBar-Button.
/// * Tab "Vergeben" — Lieferungen, die bereits zugeordnet sind. Tap auf
/// eigene Lieferung → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog.
/// * Persistent untere BottomBar: "Weiter zum Sortieren" — wechselt die
/// Phase. Es gibt bewusst keinen Zwang, dass alle Lieferungen verteilt
/// sein müssen; der Fahrer entscheidet wann er weiterzieht.
class DeliverySelectionPage extends StatefulWidget {
const DeliverySelectionPage({
super.key,
required this.selectedCarId,
});
/// ID des aktuell gewählten Fahrzeugs (Eigene Lieferungen / Ziel von
/// Übernahmen).
final int selectedCarId;
@override
State<StatefulWidget> createState() => _DeliverySelectionPageState();
}
class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
/// Lokale Multi-Selektion im Tab "Verfügbar". Wird nach erfolgreichem
/// Bulk-Assign geleert.
final Set<String> _selectedIds = <String>{};
/// True während sequentieller Assign-Calls — schaltet den
/// Bestätigungs-Button auf Spinner und disabled.
bool _isAssigning = false;
String get _carIdString => widget.selectedCarId.toString();
/// Sucht das Plate eines Autos in der Tour-Driver-Liste. Liefert "?" als
/// Fallback, falls die Zuordnung nicht (mehr) im Team enthalten ist —
/// z. B. nach Personalwechsel zwischen Tour-Synchronisationen.
String _plateFor(int? carId, Tour tour) {
if (carId == null) return "?";
final car = tour.driver.cars.firstWhereOrNull((c) => c.id == carId);
return car?.plate ?? "?";
}
/// Bulk-Assign der aktuell selektierten Lieferungen an das eigene Auto.
/// Sequentiell, damit der lokale Tour-Stream nach jedem Schritt
/// konsistent ist und die Listen-Filter live mitwandern. Bei Fehlern
/// wird eine SnackBar gezeigt und die Selektion bleibt erhalten.
Future<void> _confirmSelection() async {
if (_selectedIds.isEmpty || _isAssigning) return;
setState(() => _isAssigning = true);
final tourBloc = context.read<TourBloc>();
final ids = List<String>.from(_selectedIds);
try {
for (final id in ids) {
tourBloc.add(
AssignCarEvent(deliveryId: id, carId: _carIdString),
);
}
// Hinweis: TourBloc verarbeitet die Events asynchron; lokale
// Tour-Updates erfolgen über den Stream. Wir leeren die Selektion
// optimistisch, damit der Fahrer ein klares Feedback bekommt.
if (!mounted) return;
setState(_selectedIds.clear);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Fehler beim Übernehmen: $e")),
);
} finally {
if (mounted) setState(() => _isAssigning = false);
}
}
/// Wechselt in die nächste Phase (Sortieren). Der Fahrer kann jederzeit
/// zurückspringen — die persistierte Phase wird über den Stepper-Tap
/// zurückgesetzt.
void _goToSorting() {
context.read<PhaseBloc>().add(
PhaseSet(
carId: _carIdString,
phase: DeliveryPhase.sortieren,
),
);
}
Future<void> _showReleaseDialog(Delivery delivery) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung freigeben"),
content: Text(
"${delivery.customer.name} wurde Ihrem Fahrzeug zugeordnet. "
"Möchten Sie diese Lieferung wieder freigeben?",
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Freigeben"),
),
],
),
);
if (result != true || !mounted) return;
context.read<TourBloc>().add(
UnassignDeliveryEvent(deliveryId: delivery.id),
);
}
Future<void> _showTakeoverDialog(Delivery delivery, Tour tour) async {
final foreignPlate = _plateFor(delivery.carId, tour);
final ownPlate = _plateFor(widget.selectedCarId, tour);
final theme = Theme.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung umladen"),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(text: "${delivery.customer.name} ist aktuell "),
TextSpan(
text: foreignPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " zugeordnet. Möchten Sie diese "
"Lieferung auf "),
TextSpan(
text: ownPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " umladen?"),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
),
FilledButton(
// Warnfarbe, da das Umladen eine bestehende Zuordnung
// überschreibt — last write wins. `colorScheme.error` ist
// bewusst hart gewählt, damit der Fahrer den Eingriff in
// fremde Disposition bewusst bestätigt.
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Übernehmen"),
),
],
),
);
if (result != true || !mounted) return;
context.read<TourBloc>().add(
AssignCarEvent(
deliveryId: delivery.id,
carId: _carIdString,
),
);
}
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
Widget _plateBadge(BuildContext context, String plate, {bool own = false}) {
final theme = Theme.of(context);
final bg = own
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest;
final fg = own
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 12, color: fg),
const SizedBox(width: 4),
Text(
plate,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: fg,
),
),
],
),
);
}
Widget _emptyState({
required IconData icon,
required String title,
String? subtitle,
}) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _availableTab(List<Delivery> available) {
if (available.isEmpty) {
return _emptyState(
icon: Icons.inbox_outlined,
title: "Alle Lieferungen sind verteilt.",
subtitle: "Im Tab \"Vergeben\" können Sie eigene Lieferungen "
"freigeben oder fremde übernehmen.",
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: available.length,
itemBuilder: (context, index) {
final delivery = available[index];
final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile(
key: ValueKey("available-${delivery.id}"),
value: isSelected,
onChanged: _isAssigning
? null
: (checked) {
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
title: Text(
delivery.customer.name,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
);
},
);
}
Widget _assignedTab(List<Delivery> assigned, Tour tour) {
if (assigned.isEmpty) {
return _emptyState(
icon: Icons.local_shipping_outlined,
title: "Noch keine Lieferungen verteilt.",
);
}
final theme = Theme.of(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: assigned.length,
itemBuilder: (context, index) {
final delivery = assigned[index];
final isOwn = delivery.carId == widget.selectedCarId;
final plate = _plateFor(delivery.carId, tour);
return Material(
color: isOwn
? theme.colorScheme.primaryContainer.withValues(alpha: 0.35)
: null,
child: ListTile(
key: ValueKey("assigned-${delivery.id}"),
onTap: () {
if (isOwn) {
_showReleaseDialog(delivery);
} else {
_showTakeoverDialog(delivery, tour);
}
},
leading: Icon(
isOwn ? Icons.check_circle : Icons.person_outline,
color: isOwn
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
delivery.customer.name,
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 12),
),
trailing: _plateBadge(context, plate, own: isOwn),
),
);
},
);
}
/// BottomBar mit Bulk-Confirm (kontextabhängig) + Phasen-Wechsel.
/// Confirm-Button ist nur sichtbar, wenn etwas selektiert ist —
/// "Weiter zum Sortieren" bleibt immer sichtbar.
Widget _buildBottomBar() {
final theme = Theme.of(context);
final hasSelection = _selectedIds.isNotEmpty;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasSelection)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed:
_isAssigning ? null : _confirmSelection,
icon: _isAssigning
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.check),
label: Text(
"Auswahl bestätigen (${_selectedIds.length})",
),
),
),
if (hasSelection) const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isAssigning ? null : _goToSorting,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zum Sortieren"),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoadingFailed) {
return const DeliveryLoadingFailedPage();
}
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final available = state.tour.deliveries
.where((d) => d.carId == null)
.toList();
final assigned = state.tour.deliveries
.where((d) => d.carId != null)
.toList();
// Falls eine selektierte Lieferung in der Zwischenzeit zugeordnet
// wurde (z. B. durch einen parallelen Vorgang), Selektion bereinigen.
_selectedIds.removeWhere(
(id) => !available.any((d) => d.id == id),
);
return DefaultTabController(
length: 2,
child: Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140 + kTextTabBarHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PhaseStepper(
currentPhase: DeliveryPhase.auswaehlen,
carId: _carIdString,
),
Material(
color: Theme.of(context).primaryColor,
child: TabBar(
labelColor:
Theme.of(context).colorScheme.onPrimary,
unselectedLabelColor: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.6),
indicatorColor:
Theme.of(context).colorScheme.onPrimary,
tabs: [
Tab(text: "Verfügbar (${available.length})"),
Tab(text: "Vergeben (${assigned.length})"),
],
),
),
],
),
),
body: TabBarView(
children: [
_availableTab(available),
_assignedTab(assigned, state.tour),
],
),
bottomNavigationBar: _buildBottomBar(),
),
);
},
);
}
}

View File

@ -0,0 +1,369 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/widget/sortable_delivery_list.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer
/// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte
/// und bestätigt am Ende mit einem expliziten Klick. Erst dann wird die
/// Reihenfolge ans Backend übertragen und in Phase [DeliveryPhase.beladen]
/// gewechselt.
class DeliverySortPage extends StatefulWidget {
const DeliverySortPage({
super.key,
required this.selectedCarId,
this.onPhaseAdvanced,
});
final int selectedCarId;
/// Optionaler Hook, damit die übergeordnete Routing-Stelle nach Erfolg
/// auf die nächste Phase wechseln kann (rerender der Beladungs-Page).
final VoidCallback? onPhaseAdvanced;
@override
State<StatefulWidget> createState() => _DeliverySortPageState();
}
class _DeliverySortPageState extends State<DeliverySortPage> {
late final SortableDeliveryListController _listController;
/// Verhindert mehrfache SnackBars für denselben Fehler-State.
String? _lastShownErrorSignature;
/// Letzter Tour-"Fingerabdruck" (Anzahl + erste/letzte ID), zu dem wir den
/// Sortier-Bucket des aktuellen Autos bereits konsistent gemacht haben.
/// Verhindert unnötige Event-Stürme, wenn der TourBloc häufig rebuildet.
String? _lastEnsuredTourSignature;
/// Trackt, ob im letzten Listener-Tick `isPersistingSorting` true war —
/// damit wir den Übergang persisting → fertig zuverlässig erkennen,
/// auch wenn der Listener für Bucket-Maintenance bereits zwischendurch
/// gefeuert hat.
bool _wasPersisting = false;
@override
void initState() {
super.initState();
_listController = SortableDeliveryListController();
// Falls Tour bereits geladen ist: Sortier-Bucket sofort konsistent
// machen. Sonst übernimmt das der BlocConsumer-Listener beim ersten
// TourLoaded.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_ensureBucketIfNeeded(context.read<TourBloc>().state);
});
}
String _tourSignature(TourLoaded state) {
final ids = state.tour.deliveries.map((d) => d.id).toList();
if (ids.isEmpty) return "0";
return "${ids.length}|${ids.first}|${ids.last}";
}
void _ensureBucketIfNeeded(TourState state) {
if (state is! TourLoaded) return;
final signature = _tourSignature(state);
if (signature == _lastEnsuredTourSignature) return;
_lastEnsuredTourSignature = signature;
context.read<TourBloc>().add(
EnsureSortingForCarEvent(carId: widget.selectedCarId.toString()),
);
}
List<String> _orderedIdsFor(TourLoaded state) {
return state.sortingInformation[widget.selectedCarId.toString()] ??
const <String>[];
}
/// Phase im zentralen [PhaseBloc] auf "beladen" setzen. Der Bloc kümmert
/// sich um Persistenz via [PhaseService]. Der optionale UI-Callback wird
/// (aus Rückwärtskompatibilität) zusätzlich gefeuert.
void _advanceToLoading() {
context.read<PhaseBloc>().add(
PhaseSet(
carId: widget.selectedCarId.toString(),
phase: DeliveryPhase.beladen,
),
);
widget.onPhaseAdvanced?.call();
}
void _skipEmptyToLoading() => _advanceToLoading();
void _confirm() {
// _wasPersisting hier setzen — der erste Listener-Tick mit
// isPersistingSorting=true wird von listenWhen herausgefiltert
// (kein "phaseEnded"-Übergang), daher müssen wir das Flag vorher
// selbst hochziehen. Sonst erkennt der Listener beim zweiten Tick
// (isPersistingSorting=false) den Übergang nicht und der
// Phasen-Wechsel auf "beladen" bleibt aus.
_wasPersisting = true;
context.read<TourBloc>().add(
ConfirmSortingEvent(carId: widget.selectedCarId.toString()),
);
}
Widget _hintCard({required IconData icon, required String text, Color? color}) {
return Padding(
padding: const EdgeInsets.all(15),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 15),
child: Icon(icon, color: color ?? Colors.blueAccent),
),
Expanded(child: Text(text)),
],
),
);
}
Widget _emptyState() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
const SizedBox(height: 12),
Text(
"Keine Lieferungen heute",
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
"Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.",
textAlign: TextAlign.center,
),
),
],
);
}
Widget _singleDeliveryHint(String singleId, TourLoaded state) {
final delivery = state.tour.deliveries.firstWhere(
(d) => d.id == singleId,
orElse: () => state.tour.deliveries.first,
);
return Column(
children: [
_hintCard(
icon: Icons.info_outline,
text:
"Nur eine Lieferung — die Reihenfolge ist trivial. "
"Tippen Sie auf \"Weiter zur Beladung\", um fortzufahren.",
),
const Divider(),
ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"1",
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 11),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<TourBloc, TourState>(
listenWhen: (prev, curr) {
// Bucket-Maintenance: erstes TourLoaded oder Tour-Inhalt geändert.
final bucketTrigger = (prev is! TourLoaded && curr is TourLoaded) ||
(prev is TourLoaded &&
curr is TourLoaded &&
prev.tour.deliveries.length != curr.tour.deliveries.length);
if (bucketTrigger) return true;
if (prev is! TourLoaded || curr is! TourLoaded) return false;
final phaseEnded =
prev.isPersistingSorting && !curr.isPersistingSorting;
final newError =
curr.sortingPersistError != null &&
curr.sortingPersistError != prev.sortingPersistError;
return phaseEnded || newError;
},
listener: (context, state) {
if (state is! TourLoaded) return;
// 1) Bucket-Konsistenz nachziehen, falls die Tour gerade erst geladen
// wurde oder sich verändert hat.
_ensureBucketIfNeeded(state);
// 2) Fehler-SnackBar für Confirm-Fehler.
final err = state.sortingPersistError;
if (err != null && err != _lastShownErrorSignature) {
_lastShownErrorSignature = err;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err)),
);
_wasPersisting = state.isPersistingSorting;
return;
}
// 3) Echter Übergang persisting → fertig + kein Fehler → erfolgreich
// bestätigt → nächste Phase. Übergang über das Feld _wasPersisting
// erkannt, da der Listener auch bei Bucket-Triggern feuert.
if (_wasPersisting && !state.isPersistingSorting && err == null) {
_advanceToLoading();
}
_wasPersisting = state.isPersistingSorting;
},
builder: (context, state) {
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final orderedIds = _orderedIdsFor(state);
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.sortieren,
carId: widget.selectedCarId.toString(),
),
),
body: SafeArea(
child: _buildBody(state, orderedIds),
),
bottomNavigationBar: _buildBottomBar(state, orderedIds),
);
},
);
}
Widget _buildBody(TourLoaded state, List<String> orderedIds) {
if (orderedIds.isEmpty) {
return _emptyState();
}
if (orderedIds.length == 1) {
return _singleDeliveryHint(orderedIds.first, state);
}
return Column(
children: [
_hintCard(
icon: Icons.info_outline,
text:
"Ziehen Sie die einzelnen Lieferungen mit dem Finger in die "
"gewünschte Position. Die Reihenfolge wird erst beim "
"Bestätigen ans System übertragen.",
),
const Divider(height: 1),
Expanded(
child: SortableDeliveryList(
selectedCarId: widget.selectedCarId,
controller: _listController,
),
),
],
);
}
Widget _buildBottomBar(TourLoaded state, List<String> orderedIds) {
final isLoading = state.isPersistingSorting;
final theme = Theme.of(context);
// Spezialfälle: 0 / 1 Lieferungen → vereinfachte BottomBar
if (orderedIds.isEmpty) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading ? null : _skipEmptyToLoading,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zur Beladung"),
),
),
),
);
}
if (orderedIds.length == 1) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading ? null : _confirm,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.arrow_forward),
label: const Text("Weiter zur Beladung"),
),
),
),
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isLoading
? null
: () => _listController.resetToDefault(),
icon: const Icon(Icons.restart_alt),
label: const Text("Zurücksetzen"),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: isLoading ? null : _confirm,
icon: isLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.check),
label: const Text("Reihenfolge bestätigen"),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Persistiert die aktuelle Phase pro Fahrzeug. Der Key ist datumsspezifisch,
/// damit ein App-Neustart am nächsten Tag automatisch wieder mit Phase 1
/// (Sortieren) startet — die Phase eines Vortags hat keine Bedeutung mehr.
///
/// Zusätzlich wird die **höchste am Tag erreichte Phase** pro Fahrzeug
/// persistiert (eigener Key-Suffix `_max`). Der Stepper nutzt diesen Wert,
/// um Vorwärts-Sprünge auf bereits besuchte Phasen zu erlauben — auch wenn
/// der Fahrer zwischenzeitlich zurückgesprungen ist.
class PhaseService {
static const _prefix = "delivery_phase";
String _key(String carId) {
final now = DateTime.now();
final date = "${now.year}_${now.month}_${now.day}";
return "${_prefix}_${date}_$carId";
}
String _maxKey(String carId) {
final now = DateTime.now();
final date = "${now.year}_${now.month}_${now.day}";
return "${_prefix}_max_${date}_$carId";
}
Future<void> save(String carId, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key(carId), phase.persistenceKey);
}
Future<DeliveryPhase?> load(String carId) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(prefs.getString(_key(carId)));
}
Future<void> saveMax(String carId, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_maxKey(carId), phase.persistenceKey);
}
Future<DeliveryPhase?> loadMax(String carId) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(
prefs.getString(_maxKey(carId)),
);
}
Future<Map<String, DeliveryPhase>> loadAll(Iterable<String> carIds) async {
final result = <String, DeliveryPhase>{};
for (final carId in carIds) {
final phase = await load(carId);
if (phase != null) result[carId] = phase;
}
return result;
}
}

View File

@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/util.dart';
import 'package:hl_lieferservice/model/delivery.dart';
/// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs.
///
/// Hält die aktuell sichtbare Reihenfolge in lokalem State, damit das
/// Verschieben spürbar unmittelbar wirkt — und gibt jeden Drop zusätzlich
/// als [ReorderDeliveryEvent] an den [TourBloc] weiter. Dort übernimmt
/// der [ReorderService] die lokale Persistenz. Es findet hier bewusst
/// **kein** API-Call statt; der Backend-Sync läuft erst, wenn der Fahrer
/// die Reihenfolge in der übergeordneten Page bestätigt.
class SortableDeliveryList extends StatefulWidget {
const SortableDeliveryList({
super.key,
required this.selectedCarId,
this.controller,
});
final int? selectedCarId;
/// Optionaler Controller zum Zurücksetzen der Liste durch Eltern-Widgets
/// (z. B. Button "Zurücksetzen" in der Page).
final SortableDeliveryListController? controller;
@override
State<StatefulWidget> createState() => _SortableDeliveryListState();
}
class _SortableDeliveryListState extends State<SortableDeliveryList> {
late List<String> _localSortedList;
@override
void initState() {
super.initState();
_localSortedList = _readSortedListFromBloc();
widget.controller?._attach(this);
}
@override
void didUpdateWidget(covariant SortableDeliveryList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller?._detach(this);
widget.controller?._attach(this);
}
}
@override
void dispose() {
widget.controller?._detach(this);
super.dispose();
}
List<String> _readSortedListFromBloc() {
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
return [
...state.sortingInformation[widget.selectedCarId.toString()] ?? [],
];
}
return [];
}
/// Setzt die Liste auf die natürliche Reihenfolge zurück, in der die
/// Lieferungen in der Tour stehen. Wird vom Controller (Button
/// "Zurücksetzen") aufgerufen und meldet jeden notwendigen Swap als
/// Reorder-Event, damit der lokale Persistenz-State synchron bleibt.
///
/// Filterlogik (muss konsistent zu `_ensureSortingForCar` im TourBloc
/// sein):
/// * Ein-Auto-Teams: alle Tour-Lieferungen.
/// * Mehr-Auto-Teams: nur Lieferungen, die dem ausgewählten Fahrzeug
/// nach der Auswahl bereits zugeordnet sind.
void _resetToDefault() {
final state = context.read<TourBloc>().state;
if (state is! TourLoaded) return;
final cars = state.tour.driver.cars;
final carIdStr = widget.selectedCarId.toString();
final List<String> defaultOrder = cars.length >= 2
? state.tour.deliveries
.where((d) => d.carId?.toString() == carIdStr)
.map((d) => d.id)
.toList()
: state.tour.deliveries.map((d) => d.id).toList();
setState(() {
_localSortedList = [...defaultOrder];
});
final container = {
...state.sortingInformation,
carIdStr: [...defaultOrder],
};
context.read<TourBloc>().add(
ReplaceSortingEvent(
carId: carIdStr,
newSortingInformation: container,
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is! TourLoaded) {
return const Center(child: CircularProgressIndicator());
}
return ReorderableListView(
buildDefaultDragHandles: true,
onReorder: (oldIndex, newIndex) {
setState(() {
_localSortedList = reorderList(
_localSortedList,
oldIndex,
newIndex,
);
});
context.read<TourBloc>().add(
ReorderDeliveryEvent(
newPosition: newIndex,
oldPosition: oldIndex,
carId: widget.selectedCarId.toString(),
),
);
},
children: _localSortedList.map((id) {
final Delivery delivery = state.tour.deliveries.firstWhere(
(delivery) => delivery.id == id,
);
final int pos = _localSortedList.indexOf(id) + 1;
return ListTile(
key: Key("reorder-item-${delivery.id}"),
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"$pos",
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 11),
),
trailing: const Icon(Icons.drag_handle),
);
}).toList(),
);
},
);
}
}
/// Schmaler Controller, mit dem Eltern-Widgets die Liste zurücksetzen
/// können, ohne den internen State direkt anzufassen.
class SortableDeliveryListController {
_SortableDeliveryListState? _state;
void _attach(_SortableDeliveryListState state) => _state = state;
void _detach(_SortableDeliveryListState state) {
if (_state == state) _state = null;
}
/// Setzt die Liste auf die Default-Reihenfolge (Tour-Reihenfolge) zurück.
void resetToDefault() => _state?._resetToDefault();
}

View File

@ -0,0 +1,95 @@
import 'package:flutter/foundation.dart';
/// Repository für prozess-übergreifende Aktionen entlang des
/// Phasen-Workflows (Sortieren → Beladen → Ausliefern).
///
/// Aktuell nur ein Stub: Sobald der entsprechende Backend-Endpunkt
/// von DOCUcontrol/VARIOcontrol bereitsteht, wird hier die echte
/// HTTP-Implementierung eingehängt — die Aufrufer (TourBloc) bleiben
/// dann unverändert.
class ProcessRepository {
ProcessRepository();
/// Persistiert die vom Fahrer bestätigte Lieferreihenfolge für ein
/// Fahrzeug am Backend. Wird ausschließlich beim Bestätigen-Button
/// in der Sortier-Page aufgerufen, nicht bei jedem Drag&Drop —
/// das lokale Reorder läuft weiterhin nur über ReorderService.
Future<void> persistDeliveryOrder({
required String carId,
required List<String> orderedDeliveryIds,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.persistDeliveryOrder (stub) — carId=$carId, "
"order=$orderedDeliveryIds",
);
}
/// Hebt die Zuordnung einer Lieferung zu einem Fahrzeug auf. Wird im
/// Auswahl-Schritt verwendet, wenn der Fahrer eine bereits seinem Auto
/// zugeordnete Lieferung wieder "freigibt", damit ein Kollege sie
/// übernehmen kann.
///
/// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-
/// bzw. VARIOcontrol-Endpoint zum Entfernen der Fahrzeug-Zuordnung
/// steht. Aktuell nur Stub mit Log, damit der UI-Flow getestet werden
/// kann; die Tour wird nach diesem Call NICHT automatisch refresht
/// (das übernimmt später der Stream nach dem echten Endpoint).
Future<void> unassignDeliveryFromCar({
required String deliveryId,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.unassignDeliveryFromCar (stub) — "
"deliveryId=$deliveryId",
);
}
/// Meldet einen vom Fahrer dokumentierten Lieferungs-Abbruch (komplette
/// Lieferung) an das Backend mit Begründung. Wird zusätzlich zum
/// bestehenden `cancelDelivery`-Endpoint im [TourRepository] gefeuert,
/// damit auf Backend-Seite die Stornogründe getrennt geführt werden
/// können (Reporting / Logistik-Audit).
///
/// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-
/// Endpoint zum Berichten eines Abbruch-Grunds bereitsteht. Bis dahin
/// nur Log, damit der UI-Flow geübt werden kann.
Future<void> reportDeliveryCancelled({
required String deliveryId,
required String reason,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.reportDeliveryCancelled (stub) — "
"deliveryId=$deliveryId, reason=$reason",
);
}
/// Meldet einen einzelnen Artikel oder eine Komponente als "heute nicht
/// auszuliefern" (Teilabbruch). Der Eintrag bleibt in der Lieferung
/// bestehen, wird aber im UI ausgegraut und vom Beladen-Fortschritt
/// ausgeschlossen. [componentId] wird genau dann gesetzt, wenn nur eine
/// einzelne Stücklisten-Position betroffen ist.
///
/// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-/
/// VARIOcontrol-Endpoint für "Position zurückhalten" bereitsteht. Bis
/// dahin wird der Hold-State ausschließlich lokal in der jeweiligen
/// Beladen-Page gehalten (siehe `LoadingCustomerPage._heldKeys`).
Future<void> reportItemHeld({
required String deliveryId,
required String articleId,
String? componentId,
required String reason,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.reportItemHeld (stub) — "
"deliveryId=$deliveryId, articleId=$articleId, "
"componentId=$componentId, reason=$reason",
);
}
}