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:
125
lib/feature/delivery/bloc/phase_bloc.dart
Normal file
125
lib/feature/delivery/bloc/phase_bloc.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
41
lib/feature/delivery/bloc/phase_event.dart
Normal file
41
lib/feature/delivery/bloc/phase_event.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
58
lib/feature/delivery/bloc/phase_state.dart
Normal file
58
lib/feature/delivery/bloc/phase_state.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
94
lib/feature/delivery/model/delivery_phase.dart
Normal file
94
lib/feature/delivery/model/delivery_phase.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/feature/delivery/overview/service/phase_service.dart
Normal file
57
lib/feature/delivery/overview/service/phase_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
178
lib/feature/delivery/overview/widget/sortable_delivery_list.dart
Normal file
178
lib/feature/delivery/overview/widget/sortable_delivery_list.dart
Normal 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();
|
||||
}
|
||||
95
lib/feature/delivery/repository/process_repository.dart
Normal file
95
lib/feature/delivery/repository/process_repository.dart
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/feature/loading/model/loading_group.dart
Normal file
115
lib/feature/loading/model/loading_group.dart
Normal file
@ -0,0 +1,115 @@
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
/// Aggregiert eine [Delivery] mit ihren scannbaren Artikeln zu einer
|
||||
/// Belade-Einheit. Bildet die Datenbasis für die Beladen-Phase (Vollbild-
|
||||
/// Kunde + Übersicht).
|
||||
///
|
||||
/// **Wichtige Geschäftslogik:** Für die Frage "ist diese Lieferung fertig
|
||||
/// beladen?" zählen nur Artikel aus dem **Standardlager** (warehouseNr
|
||||
/// `null` oder `"0"`). Außenlager-Artikel werden separat beim
|
||||
/// Kundenbesuch in der Ausliefer-Phase abgeholt — sie blockieren also
|
||||
/// nicht den Beladen-Abschluss. Konsequenz: alle Counter-Getter
|
||||
/// ([totalArticles], [completeArticles], [scannedUnits], [totalUnits],
|
||||
/// [isComplete], [isPartial], [hasAnyScanned]) ignorieren Außenlager-
|
||||
/// Artikel. Wer alle Artikel braucht, greift direkt auf [articles] zu.
|
||||
///
|
||||
/// Aufrufer füllen [articles] mit den scannbaren Artikeln dieser Lieferung
|
||||
/// (also nicht alle Artikel der Lieferung — vorgefiltert über
|
||||
/// `Article.scannable`).
|
||||
class LoadingGroup {
|
||||
/// Die zugrundeliegende Lieferung (inkl. Kunde, Adresse, State).
|
||||
final Delivery delivery;
|
||||
|
||||
/// Nummernschild des zugewiesenen Fahrzeugs zur Darstellung im Badge.
|
||||
/// `null` wenn die Lieferung noch keinem Auto zugeordnet ist.
|
||||
final String? carPlate;
|
||||
|
||||
/// Die scannbaren Artikel der Lieferung (bereits vorgefiltert).
|
||||
final List<Article> articles;
|
||||
|
||||
const LoadingGroup({
|
||||
required this.delivery,
|
||||
required this.articles,
|
||||
this.carPlate,
|
||||
});
|
||||
|
||||
/// Alle Standardlager-Artikel (Lager-Nummer `null` oder `"0"`). Bildet
|
||||
/// die Basis aller Beladen-Counter, weil Außenlager-Ware nicht in der
|
||||
/// Belade-Halle scannbar ist.
|
||||
List<Article> get _standardArticles => articles
|
||||
.where((a) => !_isExternalWarehouse(a.warehouseNr))
|
||||
.toList(growable: false);
|
||||
|
||||
/// Anzahl der scannbaren Standardlager-Artikel. Parent-Artikel zählen
|
||||
/// als 1 (nicht je Komponente).
|
||||
int get totalArticles => _standardArticles.length;
|
||||
|
||||
/// Anzahl der vollständig gescannten Standardlager-Artikel. Bei Parent-
|
||||
/// Artikeln gilt "vollständig" = alle Komponenten vollständig.
|
||||
int get completeArticles =>
|
||||
_standardArticles.where((a) => a.isFullyScanned).length;
|
||||
|
||||
/// Gesamtanzahl der erwarteten Einzelstücke aus dem Standardlager —
|
||||
/// bei Parent-Artikeln summiert über die Required-Amounts der
|
||||
/// Komponenten.
|
||||
int get totalUnits => _standardArticles.fold(0, (sum, a) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
return sum + a.components.fold(0, (s, c) => s + c.requiredAmount);
|
||||
}
|
||||
return sum + a.amount;
|
||||
});
|
||||
|
||||
/// Bereits gescannte Einzelstücke aus dem Standardlager — analog zu
|
||||
/// [totalUnits].
|
||||
int get scannedUnits => _standardArticles.fold(0, (sum, a) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
return sum + a.components.fold(0, (s, c) => s + c.scannedAmount);
|
||||
}
|
||||
return sum + a.scannedAmount + a.scannedRemovedAmount;
|
||||
});
|
||||
|
||||
/// `true`, wenn alle Standardlager-Artikel vollständig gescannt sind.
|
||||
///
|
||||
/// Edge-Case: Lieferung **ohne** Standardlager-Artikel (alle Artikel
|
||||
/// liegen in Außenlagern) → automatisch fertig, weil in der Beladen-
|
||||
/// Phase nichts zu tun ist.
|
||||
bool get isComplete {
|
||||
if (articles.isEmpty) return false;
|
||||
if (_standardArticles.isEmpty) return true;
|
||||
return completeArticles == totalArticles;
|
||||
}
|
||||
|
||||
/// `true`, wenn mindestens ein Stück gescannt wurde — egal ob Artikel
|
||||
/// vollständig oder nicht.
|
||||
bool get hasAnyScanned => scannedUnits > 0;
|
||||
|
||||
/// `true`, wenn die Lieferung angefangen, aber nicht abgeschlossen wurde.
|
||||
bool get isPartial => hasAnyScanned && !isComplete;
|
||||
|
||||
/// `true`, wenn mindestens ein Artikel der Lieferung NICHT aus dem
|
||||
/// Standard-Lager kommt. Standard-Lager hat die Nummer "0"; ein
|
||||
/// `warehouseNr == null` interpretieren wir als "nicht angegeben" und
|
||||
/// damit als Standard (kein False-Positive auf Datenlücken).
|
||||
bool get hasExternalWarehouseArticles =>
|
||||
articles.any((a) => _isExternalWarehouse(a.warehouseNr));
|
||||
|
||||
/// Eindeutige Liste der Außenlager-Namen, die in dieser Lieferung
|
||||
/// vorkommen — für Badges/Hinweise in der Übersicht. Wenn ein Artikel
|
||||
/// nur eine `warehouseNr` aber keinen Namen hat, wird die Nummer als
|
||||
/// Fallback genommen.
|
||||
List<String> get externalWarehouseLabels {
|
||||
final labels = <String>{};
|
||||
for (final a in articles) {
|
||||
if (!_isExternalWarehouse(a.warehouseNr)) continue;
|
||||
final label = (a.warehouseName?.isNotEmpty ?? false)
|
||||
? a.warehouseName!
|
||||
: "Lager ${a.warehouseNr}";
|
||||
labels.add(label);
|
||||
}
|
||||
return labels.toList(growable: false);
|
||||
}
|
||||
|
||||
static bool _isExternalWarehouse(String? nr) =>
|
||||
nr != null && nr.isNotEmpty && nr != "0";
|
||||
}
|
||||
1125
lib/feature/loading/presentation/loading_customer_page.dart
Normal file
1125
lib/feature/loading/presentation/loading_customer_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
431
lib/feature/loading/presentation/loading_overview_page.dart
Normal file
431
lib/feature/loading/presentation/loading_overview_page.dart
Normal file
@ -0,0 +1,431 @@
|
||||
import 'package:collection/collection.dart';
|
||||
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/bloc/tour_bloc.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/presentation/loading_customer_page.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/util/loading_order.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';
|
||||
|
||||
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden in Beladereihen-
|
||||
/// folge mit Fortschritts-Status. KEIN Scanner — der Scanner-Fokus bleibt
|
||||
/// auf der [LoadingCustomerPage]. Tap auf einen Kunden öffnet seine
|
||||
/// Vollbild-Ansicht mit dem entsprechenden Index.
|
||||
class LoadingOverviewPage extends StatelessWidget {
|
||||
const LoadingOverviewPage({super.key});
|
||||
|
||||
String? _lookupCarPlate(int? carId, Tour tour) {
|
||||
if (carId == null) return null;
|
||||
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
|
||||
}
|
||||
|
||||
List<LoadingGroup> _buildGroups(TourLoaded state, String carIdStr) {
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
builder: (context, carState) {
|
||||
final carIdStr =
|
||||
carState is CarSelectComplete ? carState.selectedCar.id.toString() : "";
|
||||
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, tourState) {
|
||||
if (tourState is TourLoadingFailed) {
|
||||
return const DeliveryLoadingFailedPage();
|
||||
}
|
||||
if (tourState is! TourLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final groups = _buildGroups(tourState, carIdStr);
|
||||
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(140),
|
||||
child: PhaseStepper(
|
||||
currentPhase: DeliveryPhase.beladen,
|
||||
carId: carIdStr,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: groups.isEmpty
|
||||
? const _EmptyOverview()
|
||||
: _OverviewList(groups: groups),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverviewList extends StatelessWidget {
|
||||
const _OverviewList({required this.groups});
|
||||
|
||||
final List<LoadingGroup> groups;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalActive = groups
|
||||
.where((g) => g.delivery.state != DeliveryState.canceled)
|
||||
.length;
|
||||
final doneActive = groups
|
||||
.where((g) =>
|
||||
g.delivery.state != DeliveryState.canceled && g.isComplete)
|
||||
.length;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Beladereihenfolge",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
"$doneActive / $totalActive Kunden",
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: totalActive == 0 ? 0.0 : doneActive / totalActive,
|
||||
minHeight: 6,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
doneActive == totalActive && totalActive > 0
|
||||
? Colors.green
|
||||
: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final g = groups[index];
|
||||
return _OverviewTile(
|
||||
position: index + 1,
|
||||
group: g,
|
||||
onTap: () {
|
||||
// Push (kein pushReplacement): die Übersicht ist seit dem
|
||||
// Routing-Umbau in home.dart die Wurzel der Beladen-Phase.
|
||||
// Vom Vollbild kehrt der Fahrer per pop zurück auf diese
|
||||
// Übersicht — der Stack bleibt damit flach.
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LoadingCustomerPage(initialIndex: index),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverviewTile extends StatelessWidget {
|
||||
const _OverviewTile({
|
||||
required this.position,
|
||||
required this.group,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int position;
|
||||
final LoadingGroup group;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canceled = group.delivery.state == DeliveryState.canceled;
|
||||
final isComplete = group.isComplete;
|
||||
final isPartial = group.isPartial;
|
||||
final hasExternalWarehouse = group.hasExternalWarehouseArticles;
|
||||
|
||||
// cardColor und borderColor sind nicht final, weil das Außenlager-
|
||||
// Highlight sie weiter unten ggf. überschreibt.
|
||||
Color cardColor;
|
||||
Color borderColor;
|
||||
final Color titleColor;
|
||||
final String statusText;
|
||||
final IconData statusIcon;
|
||||
|
||||
if (canceled) {
|
||||
cardColor = Colors.grey.withValues(alpha: 0.08);
|
||||
borderColor = Colors.grey.withValues(alpha: 0.35);
|
||||
titleColor = Colors.grey.shade700;
|
||||
statusText = "Abgebrochen";
|
||||
statusIcon = Icons.cancel_outlined;
|
||||
} else if (isComplete && hasExternalWarehouse) {
|
||||
// Standardlager ist fertig, aber es liegen noch Artikel in einem
|
||||
// anderen Lager — die Lieferung ist also NICHT komplett beladen.
|
||||
// Wir machen das im Status-Text explizit, damit der Fahrer nicht
|
||||
// fälschlich davon ausgeht, dass nichts mehr offen ist.
|
||||
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
|
||||
borderColor = Colors.deepOrange.withValues(alpha: 0.45);
|
||||
titleColor = Colors.deepOrange.shade800;
|
||||
statusText = "Standardlager fertig — Außenlager offen";
|
||||
statusIcon = Icons.warehouse_outlined;
|
||||
} else if (isComplete) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.35);
|
||||
titleColor = Colors.green.shade700;
|
||||
statusText = "Fertig beladen";
|
||||
statusIcon = Icons.check_circle_outline;
|
||||
} else if (isPartial) {
|
||||
cardColor = Colors.orange.withValues(alpha: 0.07);
|
||||
borderColor = Colors.orange.withValues(alpha: 0.35);
|
||||
titleColor = Colors.orange.shade800;
|
||||
statusText = "Beladung läuft";
|
||||
statusIcon = Icons.pending_outlined;
|
||||
} else {
|
||||
cardColor = theme.colorScheme.surfaceContainerLow;
|
||||
borderColor = Colors.transparent;
|
||||
titleColor = theme.colorScheme.onSurface;
|
||||
statusText = "Offen";
|
||||
statusIcon = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
||||
// Außenlager-Hervorhebung: lebt unabhängig vom Scan-Status. Eine
|
||||
// abgebrochene Lieferung bleibt grau, ansonsten überschreibt das
|
||||
// Außenlager-Highlight die Standard-Farben durch ein klar erkennbares
|
||||
// Orange — der Fahrer muss früh genug wissen, dass er ein anderes
|
||||
// Lager anfahren wird. Der Sonderzweig "isComplete && hasExternal-
|
||||
// Warehouse" oben hat das Highlight schon gesetzt, hier greift es
|
||||
// für die noch nicht fertigen Fälle.
|
||||
if (!canceled && hasExternalWarehouse && !isComplete) {
|
||||
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
|
||||
borderColor = Colors.deepOrange.withValues(alpha: 0.65);
|
||||
}
|
||||
|
||||
final progressLabel = canceled
|
||||
? "—"
|
||||
: "${group.completeArticles}/${group.totalArticles} Artikel";
|
||||
|
||||
return Opacity(
|
||||
opacity: canceled ? 0.65 : 1.0,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: borderColor),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor:
|
||||
canceled ? Colors.grey : theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
radius: 18,
|
||||
child: Text(
|
||||
"$position",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
group.delivery.customer.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
decoration: canceled
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
group.delivery.customer.address.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(statusIcon,
|
||||
size: 14, color: titleColor),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Expanded, damit lange Status-Texte wie
|
||||
// "Standardlager fertig — Außenlager offen"
|
||||
// umbrechen statt zu überlaufen.
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: titleColor,
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
progressLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!canceled && hasExternalWarehouse) ...[
|
||||
const SizedBox(height: 6),
|
||||
_ExternalWarehouseBadge(
|
||||
labels: group.externalWarehouseLabels,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hinweis-Badge unter dem Status-Row einer Lieferung mit Artikeln aus
|
||||
/// einem oder mehreren Außenlagern. Listet die betroffenen Lager-Namen
|
||||
/// auf, damit der Fahrer beim Beladen weiß, wohin er zusätzlich muss.
|
||||
class _ExternalWarehouseBadge extends StatelessWidget {
|
||||
const _ExternalWarehouseBadge({required this.labels});
|
||||
|
||||
final List<String> labels;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = labels.isEmpty
|
||||
? "Außenlager"
|
||||
: "Außenlager: ${labels.join(", ")}";
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepOrange.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.6)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warehouse_outlined,
|
||||
size: 14, color: Colors.deepOrange.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.deepOrange.shade800,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyOverview extends StatelessWidget {
|
||||
const _EmptyOverview();
|
||||
|
||||
@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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/feature/loading/util/loading_order.dart
Normal file
68
lib/feature/loading/util/loading_order.dart
Normal file
@ -0,0 +1,68 @@
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
/// Hilfen rund um die Belade-Reihenfolge.
|
||||
///
|
||||
/// Die Beladereihenfolge ist die *Umkehrung* der vom Fahrer bestätigten
|
||||
/// Auslieferungs-Reihenfolge (Sortier-Phase): wer zuletzt ausgeliefert wird,
|
||||
/// kommt zuerst auf den LKW und liegt hinten. Diese Klasse liefert die
|
||||
/// gefilterte und gespiegelte ID-Liste — Quelle ist immer
|
||||
/// `TourLoaded.sortingInformation[carId]`.
|
||||
///
|
||||
/// Filterung:
|
||||
/// * Bei ≥2 Fahrzeugen im Team: nur Lieferungen mit
|
||||
/// `delivery.carId == selectedCarId`.
|
||||
/// * Bei genau 1 Fahrzeug: alle Tour-Lieferungen.
|
||||
///
|
||||
/// Konsistent mit der bestehenden Logik in [DeliverySortPage] und der
|
||||
/// alten `scan_page.dart:792`.
|
||||
class LoadingOrder {
|
||||
const LoadingOrder._();
|
||||
|
||||
/// Berechnet die Belade-Reihenfolge an Delivery-IDs.
|
||||
///
|
||||
/// [carIdStr] ist das String-Pendant der gewählten Auto-ID, weil die
|
||||
/// `sortingInformation` mit String-Keys arbeitet.
|
||||
static List<String> computeForCar({
|
||||
required TourLoaded state,
|
||||
required String carIdStr,
|
||||
}) {
|
||||
final cars = state.tour.driver.cars;
|
||||
final allowedIds = cars.length >= 2
|
||||
? state.tour.deliveries
|
||||
.where((d) => d.carId?.toString() == carIdStr)
|
||||
.map((d) => d.id)
|
||||
.toSet()
|
||||
: state.tour.deliveries.map((d) => d.id).toSet();
|
||||
|
||||
final raw = state.sortingInformation[carIdStr] ?? const <String>[];
|
||||
|
||||
// Mit reversed nach hinten kommt die zuletzt ausgelieferte Lieferung
|
||||
// nach vorne (zuerst beladen).
|
||||
final reversed = raw.reversed.where(allowedIds.contains).toList();
|
||||
|
||||
// Falls die Sortierung leer ist (kann bei frisch geladener Tour
|
||||
// vorkommen, bevor `EnsureSortingForCarEvent` durchlief), fallen wir
|
||||
// auf die unsortierten Tour-IDs zurück — der Fahrer sieht so wenigstens
|
||||
// alle Kunden, ohne dass die Page hängt.
|
||||
if (reversed.isEmpty && allowedIds.isNotEmpty) {
|
||||
return allowedIds.toList(growable: false);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
/// Komfort-Variante, die zusätzlich abgeschlossene Lieferungen rausfiltert
|
||||
/// (für Anzeigen, die nur "noch zu beladen" bzw. aktive Einträge möchten).
|
||||
static List<String> computeActive({
|
||||
required TourLoaded state,
|
||||
required String carIdStr,
|
||||
}) {
|
||||
final order = computeForCar(state: state, carIdStr: carIdStr);
|
||||
final byId = {for (final d in state.tour.deliveries) d.id: d};
|
||||
return order.where((id) {
|
||||
final d = byId[id];
|
||||
if (d == null) return false;
|
||||
return d.state != DeliveryState.finished;
|
||||
}).toList(growable: false);
|
||||
}
|
||||
}
|
||||
535
lib/feature/loading/widget/article_row.dart
Normal file
535
lib/feature/loading/widget/article_row.dart
Normal file
@ -0,0 +1,535 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
import 'package:hl_lieferservice/model/component.dart';
|
||||
|
||||
/// Identifier-Helpers für den Hold-State-Set: ein Artikel ohne Komponenten
|
||||
/// wird mit seiner [Article.internalId] referenziert, eine Komponente mit
|
||||
/// `<articleInternalId>:<componentArticleNumber>`.
|
||||
///
|
||||
/// Wir nutzen bewusst ein einfaches String-Schema statt einer eigenen Klasse,
|
||||
/// weil der Set-Lookup in jedem Row-Rebuild stattfindet und Sets von
|
||||
/// einfachen Strings am preisgünstigsten sind.
|
||||
class HoldKey {
|
||||
/// Schlüssel für einen ganzen (Nicht-Parent-)Artikel.
|
||||
static String article(Article a) => "art:${a.internalId}";
|
||||
|
||||
/// Schlüssel für eine Komponente (Stücklisten-Position) unterhalb eines
|
||||
/// Parent-Artikels.
|
||||
static String component(Article parent, Component c) =>
|
||||
"comp:${parent.internalId}:${c.articleNumber}";
|
||||
}
|
||||
|
||||
/// Visuelle Konstanten für die "Heute zurückgehalten"-Markierung.
|
||||
const _holdBadgeColor = Colors.deepOrange;
|
||||
|
||||
/// Renderer für eine Artikelzeile innerhalb der Beladen-Phase.
|
||||
///
|
||||
/// Unterscheidet automatisch zwischen Parent-Artikel (Stückliste) und
|
||||
/// regulärem Artikel — die Komponenten werden in einem [ParentArticleRow]
|
||||
/// inkl. Liste von [ComponentRow] aufgeklappt dargestellt. Außerhalb dieser
|
||||
/// Klasse sollte nur [ArticleRow] direkt verwendet werden; die anderen
|
||||
/// beiden Widgets sind als Subkomponenten exportiert, falls jemand sie
|
||||
/// gezielt ansteuern möchte.
|
||||
class ArticleRow extends StatelessWidget {
|
||||
const ArticleRow({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.isHeld,
|
||||
required this.disabled,
|
||||
this.heldComponents = const <String>{},
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
/// Der darzustellende Artikel.
|
||||
final Article article;
|
||||
|
||||
/// `true`, wenn der Artikel als Ganzes für heute zurückgehalten ist.
|
||||
/// Bei Parent-Artikeln wird dies an die Komponenten weitergereicht.
|
||||
final bool isHeld;
|
||||
|
||||
/// `true`, wenn die Lieferung selbst (z. B. wegen Abbruch) deaktiviert
|
||||
/// ist — die Zeile wird grundsätzlich ausgegraut, Tap deaktiviert.
|
||||
final bool disabled;
|
||||
|
||||
/// Set der gehaltenen Komponenten-Schlüssel (siehe [HoldKey.component]).
|
||||
/// Wird nur ausgewertet, wenn der Artikel ein Parent ist.
|
||||
final Set<String> heldComponents;
|
||||
|
||||
/// Optional: Tap-Callback, z. B. um den Artikel "manuell" zu inkrementieren.
|
||||
/// Bleibt für die Beladen-Phase aktuell `null` — der Scan-Flow geht über
|
||||
/// den Scanner, nicht den Tap. Lässt aber Raum für spätere Komfort-Aktionen.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Optional: Long-Press, z. B. für ein Kontext-Menü (Unscan).
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (article.isParent && article.components.isNotEmpty) {
|
||||
return ParentArticleRow(
|
||||
article: article,
|
||||
parentHeld: isHeld,
|
||||
disabled: disabled,
|
||||
heldComponents: heldComponents,
|
||||
);
|
||||
}
|
||||
return _RegularArticleRow(
|
||||
article: article,
|
||||
isHeld: isHeld,
|
||||
disabled: disabled,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reguläre Artikel-Zeile (ohne Stückliste) als Card.
|
||||
class _RegularArticleRow extends StatelessWidget {
|
||||
const _RegularArticleRow({
|
||||
required this.article,
|
||||
required this.isHeld,
|
||||
required this.disabled,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
final Article article;
|
||||
final bool isHeld;
|
||||
final bool disabled;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entryDone = article.isFullyScanned;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final effectiveDisabled = disabled || isHeld;
|
||||
|
||||
// Card-Styling abhängig vom Status: gescannt = grünlicher Akzent,
|
||||
// zurückgehalten = orange-Akzent, sonst neutral. So sieht der Fahrer
|
||||
// beim Scrollen ohne Lesen, was schon erledigt ist.
|
||||
final Color cardColor;
|
||||
final Color borderColor;
|
||||
final IconData leadingIcon;
|
||||
final Color leadingColor;
|
||||
|
||||
if (isHeld) {
|
||||
cardColor = _holdBadgeColor.withValues(alpha: 0.07);
|
||||
borderColor = _holdBadgeColor.withValues(alpha: 0.45);
|
||||
leadingIcon = Icons.pause_circle_outline;
|
||||
leadingColor = _holdBadgeColor;
|
||||
} else if (entryDone) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.45);
|
||||
leadingIcon = Icons.check_circle;
|
||||
leadingColor = Colors.green.shade700;
|
||||
} else {
|
||||
cardColor = scheme.surfaceContainerLow;
|
||||
borderColor = scheme.outlineVariant.withValues(alpha: 0.4);
|
||||
leadingIcon = Icons.inventory_2_outlined;
|
||||
leadingColor = scheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: effectiveDisabled ? 0.45 : 1.0,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: borderColor),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: effectiveDisabled ? null : onTap,
|
||||
onLongPress: effectiveDisabled ? null : onLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: leadingColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(leadingIcon, color: leadingColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
article.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: isHeld
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Artikelnr. ${article.articleNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ScanCountBadge(
|
||||
done: article.scannedAmount + article.scannedRemovedAmount,
|
||||
total: article.amount,
|
||||
isComplete: entryDone,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isHeld) ...[
|
||||
const SizedBox(height: 8),
|
||||
const _HeldBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent-Artikel (Stückliste) — zeigt eine Header-Zeile und darunter die
|
||||
/// einzelnen Komponenten als [ComponentRow].
|
||||
class ParentArticleRow extends StatelessWidget {
|
||||
const ParentArticleRow({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.parentHeld,
|
||||
required this.disabled,
|
||||
this.heldComponents = const <String>{},
|
||||
});
|
||||
|
||||
/// Der Parent-Artikel (muss `isParent == true` und `components.isNotEmpty`).
|
||||
final Article article;
|
||||
|
||||
/// `true`, wenn der gesamte Parent-Artikel zurückgehalten ist
|
||||
/// (vererbt sich auf alle Komponenten).
|
||||
final bool parentHeld;
|
||||
|
||||
/// `true`, wenn die Lieferung deaktiviert ist (z. B. abgebrochen).
|
||||
final bool disabled;
|
||||
|
||||
/// Set der gehaltenen Komponenten-Schlüssel (siehe [HoldKey.component]).
|
||||
final Set<String> heldComponents;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final allDone = article.isFullyScanned;
|
||||
final scannedCount =
|
||||
article.components.where((c) => c.isFullyScanned).length;
|
||||
final effectiveDisabled = disabled || parentHeld;
|
||||
|
||||
// Card-Styling für Stückliste — gleiche Logik wie reguläre Artikel,
|
||||
// aber mit Stücklisten-Icon und der Komponenten-Liste innerhalb derselben
|
||||
// Card (visuell gruppiert).
|
||||
final Color cardColor;
|
||||
final Color borderColor;
|
||||
final IconData headerIcon;
|
||||
final Color headerIconColor;
|
||||
|
||||
if (parentHeld) {
|
||||
cardColor = _holdBadgeColor.withValues(alpha: 0.07);
|
||||
borderColor = _holdBadgeColor.withValues(alpha: 0.45);
|
||||
headerIcon = Icons.pause_circle_outline;
|
||||
headerIconColor = _holdBadgeColor;
|
||||
} else if (allDone) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.45);
|
||||
headerIcon = Icons.check_circle;
|
||||
headerIconColor = Colors.green.shade700;
|
||||
} else {
|
||||
cardColor = scheme.surfaceContainerLow;
|
||||
borderColor = scheme.outlineVariant.withValues(alpha: 0.4);
|
||||
headerIcon = Icons.account_tree_outlined;
|
||||
headerIconColor = scheme.primary;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: effectiveDisabled ? 0.45 : 1.0,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: borderColor),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header-Reihe mit Icon, Name, Komponenten-Counter.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: headerIconColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child:
|
||||
Icon(headerIcon, color: headerIconColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
article.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: parentHeld
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Stückliste · $scannedCount/${article.components.length} Komponenten",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
allDone ? Icons.check_circle : Icons.pending_outlined,
|
||||
color: allDone ? Colors.green : Colors.orange,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (parentHeld) ...[
|
||||
const SizedBox(height: 8),
|
||||
const _HeldBadge(),
|
||||
],
|
||||
if (article.components.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
...article.components.map(
|
||||
(c) => ComponentRow(
|
||||
component: c,
|
||||
parentArticle: article,
|
||||
isHeld: parentHeld ||
|
||||
heldComponents.contains(HoldKey.component(article, c)),
|
||||
disabled: disabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine einzelne Komponenten-Zeile (Position einer Stückliste).
|
||||
class ComponentRow extends StatelessWidget {
|
||||
const ComponentRow({
|
||||
super.key,
|
||||
required this.component,
|
||||
required this.parentArticle,
|
||||
required this.isHeld,
|
||||
required this.disabled,
|
||||
});
|
||||
|
||||
/// Die Komponente.
|
||||
final Component component;
|
||||
|
||||
/// Parent-Artikel zur Auflösung des Hold-Keys & Anzeige-Kontextes.
|
||||
final Article parentArticle;
|
||||
|
||||
/// `true`, wenn diese Komponente (oder der Parent) zurückgehalten ist.
|
||||
final bool isHeld;
|
||||
|
||||
/// `true`, wenn die Lieferung deaktiviert ist.
|
||||
final bool disabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final done = component.isFullyScanned;
|
||||
final effectiveDisabled = disabled || isHeld;
|
||||
|
||||
// Component-Reihe sitzt INNERHALB der Parent-Card — daher kein eigener
|
||||
// Card-Wrapper. Stattdessen klare Einrückung + dezente Status-Markierung.
|
||||
final Color iconColor = done
|
||||
? Colors.green.shade700
|
||||
: (isHeld ? _holdBadgeColor : scheme.onSurfaceVariant);
|
||||
final IconData icon = isHeld
|
||||
? Icons.pause_circle_outline
|
||||
: (done ? Icons.check_circle : Icons.radio_button_unchecked);
|
||||
|
||||
return Opacity(
|
||||
opacity: effectiveDisabled ? 0.45 : 1.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(48, 6, 4, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
component.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: isHeld
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Artikelnr. ${component.articleNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ScanCountBadge(
|
||||
done: component.scannedAmount,
|
||||
total: component.requiredAmount,
|
||||
isComplete: done,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isHeld) ...[
|
||||
const SizedBox(height: 4),
|
||||
const _HeldBadge(indented: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kompaktes Mengen-Badge `x / y×` für Artikel-/Komponenten-Karten.
|
||||
/// `compact: true` reduziert Padding und Schriftgröße für die Verwendung
|
||||
/// innerhalb der Parent-Card.
|
||||
class _ScanCountBadge extends StatelessWidget {
|
||||
const _ScanCountBadge({
|
||||
required this.done,
|
||||
required this.total,
|
||||
required this.isComplete,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final int done;
|
||||
final int total;
|
||||
final bool isComplete;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = isComplete ? Colors.green.shade700 : scheme.primary;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 8 : 10,
|
||||
vertical: compact ? 3 : 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
"$done / $total×",
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 11 : 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeldBadge extends StatelessWidget {
|
||||
const _HeldBadge({this.indented = false});
|
||||
|
||||
/// Linke Einrückung — für Komponenten unter dem Parent-Header in der Card.
|
||||
final bool indented;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indented ? 28 : 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _holdBadgeColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: _holdBadgeColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.pause_circle_outline,
|
||||
size: 12, color: _holdBadgeColor),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"Heute zurückgehalten",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _holdBadgeColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/feature/loading/widget/hold_selection_dialog.dart
Normal file
243
lib/feature/loading/widget/hold_selection_dialog.dart
Normal file
@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/widget/article_row.dart';
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
import 'package:hl_lieferservice/model/component.dart';
|
||||
|
||||
/// Eine einzelne, im Hold-Dialog auswählbare Position. Aufrufer erhalten
|
||||
/// nach Bestätigung die ausgewählten Items zurück.
|
||||
///
|
||||
/// Genau eines von [article] / [component] ist gesetzt — beide kombiniert
|
||||
/// ergeben einen Komponenten-Eintrag (component != null mit zugehörigem
|
||||
/// Parent-Artikel in [article]).
|
||||
class HoldSelectionItem {
|
||||
HoldSelectionItem.article(this.article)
|
||||
: component = null,
|
||||
key = HoldKey.article(article);
|
||||
|
||||
HoldSelectionItem.component(this.article, Component this.component)
|
||||
: key = HoldKey.component(article, component);
|
||||
|
||||
/// Artikel — bei Komponenten der zugehörige Parent.
|
||||
final Article article;
|
||||
|
||||
/// Komponente (nur gesetzt, wenn es sich um eine Stücklisten-Position
|
||||
/// handelt).
|
||||
final Component? component;
|
||||
|
||||
/// Eindeutiger Schlüssel zur Hold-State-Verwaltung. Identisch mit den
|
||||
/// Keys, die [HoldKey] erzeugt — so kann ein Aufrufer ohne Umweg den
|
||||
/// internen Hold-Set füllen.
|
||||
final String key;
|
||||
|
||||
String get _displayName => component?.name ?? article.name;
|
||||
|
||||
String get _articleNumber =>
|
||||
component?.articleNumber ?? article.articleNumber;
|
||||
}
|
||||
|
||||
/// Auswahl-Dialog für den Teilabbruch ("Artikel heute nicht liefern").
|
||||
///
|
||||
/// Liefert nach Bestätigung per `Navigator.pop` die Liste der ausgewählten
|
||||
/// [HoldSelectionItem]s. Bei Abbruch ist das Ergebnis `null`. Items, die
|
||||
/// im Set [alreadyHeld] enthalten sind, werden ausgegraut dargestellt und
|
||||
/// sind nicht erneut wählbar.
|
||||
class HoldSelectionDialog extends StatefulWidget {
|
||||
const HoldSelectionDialog({
|
||||
super.key,
|
||||
required this.customerName,
|
||||
required this.articles,
|
||||
required this.alreadyHeld,
|
||||
});
|
||||
|
||||
/// Anzeigename des Kunden — wird im Dialog-Header gezeigt.
|
||||
final String customerName;
|
||||
|
||||
/// Scannbare Artikel der Lieferung (also bereits vorgefiltert).
|
||||
final List<Article> articles;
|
||||
|
||||
/// Set bereits gehaltener Keys — diese erscheinen ausgegraut & disabled.
|
||||
final Set<String> alreadyHeld;
|
||||
|
||||
static Future<List<HoldSelectionItem>?> show(
|
||||
BuildContext context, {
|
||||
required String customerName,
|
||||
required List<Article> articles,
|
||||
required Set<String> alreadyHeld,
|
||||
}) {
|
||||
return showDialog<List<HoldSelectionItem>>(
|
||||
context: context,
|
||||
builder: (_) => HoldSelectionDialog(
|
||||
customerName: customerName,
|
||||
articles: articles,
|
||||
alreadyHeld: alreadyHeld,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<HoldSelectionDialog> createState() => _HoldSelectionDialogState();
|
||||
}
|
||||
|
||||
class _HoldSelectionDialogState extends State<HoldSelectionDialog> {
|
||||
final Set<String> _selectedKeys = <String>{};
|
||||
late final List<HoldSelectionItem> _items;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = _buildItems(widget.articles);
|
||||
}
|
||||
|
||||
/// Erzeugt aus den Artikeln die selektierbaren Einträge. Parent-Artikel
|
||||
/// werden nicht selbst zum Eintrag — ihre Komponenten sind die wählbaren
|
||||
/// Einheiten. Für die Anzeige der Header-Zeile werden Parents über das
|
||||
/// Build-Verfahren (siehe build) separat eingestreut.
|
||||
List<HoldSelectionItem> _buildItems(List<Article> articles) {
|
||||
final result = <HoldSelectionItem>[];
|
||||
for (final a in articles) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
for (final c in a.components) {
|
||||
result.add(HoldSelectionItem.component(a, c));
|
||||
}
|
||||
} else {
|
||||
result.add(HoldSelectionItem.article(a));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (_selectedKeys.contains(key)) {
|
||||
_selectedKeys.remove(key);
|
||||
} else {
|
||||
_selectedKeys.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
final selected =
|
||||
_items.where((i) => _selectedKeys.contains(i.key)).toList();
|
||||
Navigator.of(context).pop(selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Artikel zurückhalten"),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.customerName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Markiere die Positionen, die heute nicht ausgeliefert werden:",
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: _buildList(theme),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _selectedKeys.isEmpty ? null : _confirm,
|
||||
child: const Text("Weiter"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Baut die ListView-Inhalte mit Header-Zeilen für Parent-Artikel.
|
||||
/// Parent-Header sind bewusst nicht klickbar — sie dienen nur zur
|
||||
/// Strukturierung.
|
||||
List<Widget> _buildList(ThemeData theme) {
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (final a in widget.articles) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2, left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.account_tree_outlined,
|
||||
size: 16, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
a.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final c in a.components) {
|
||||
final item = _items.firstWhere(
|
||||
(i) => i.component == c && i.article == a,
|
||||
);
|
||||
widgets.add(_buildTile(item, indent: true));
|
||||
}
|
||||
} else {
|
||||
final item = _items.firstWhere(
|
||||
(i) => i.article == a && i.component == null,
|
||||
);
|
||||
widgets.add(_buildTile(item));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
Widget _buildTile(HoldSelectionItem item, {bool indent = false}) {
|
||||
final alreadyHeld = widget.alreadyHeld.contains(item.key);
|
||||
final selected = _selectedKeys.contains(item.key);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indent ? 16 : 0),
|
||||
child: Opacity(
|
||||
opacity: alreadyHeld ? 0.4 : 1.0,
|
||||
child: CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
value: alreadyHeld ? true : selected,
|
||||
onChanged: alreadyHeld ? null : (_) => _toggle(item.key),
|
||||
title: Text(
|
||||
item._displayName,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Artikelnr. ${item._articleNumber}"
|
||||
"${alreadyHeld ? " · bereits zurückgehalten" : ""}",
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/feature/loading/widget/reason_picker_dialog.dart
Normal file
140
lib/feature/loading/widget/reason_picker_dialog.dart
Normal file
@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Vordefinierte Gründe für Abbruch / Teilabbruch.
|
||||
///
|
||||
/// Die Liste ist absichtlich kurz und fahrernah gehalten — wir fragen keine
|
||||
/// Mini-Romane ab, sondern erlauben das Wichtigste mit einem Tap. Für alles
|
||||
/// Sonstige steht "Anderer Grund" mit Freitext zur Verfügung.
|
||||
const List<String> _predefinedReasons = [
|
||||
"Kunde nicht erreichbar",
|
||||
"Adresse falsch",
|
||||
"Ware beschädigt",
|
||||
"Zugang nicht möglich",
|
||||
"Anderer Grund",
|
||||
];
|
||||
|
||||
/// Schlüssel-Konstante für die "Anderer Grund"-Option — damit Aufrufer den
|
||||
/// Vergleich nicht über String-Literals führen müssen.
|
||||
const String _otherReasonOption = "Anderer Grund";
|
||||
|
||||
/// Wiederverwendbarer Grund-Dialog für Beladen-Phase: sowohl der komplette
|
||||
/// Lieferungs-Abbruch als auch das Zurückhalten einzelner Artikel /
|
||||
/// Komponenten landen in diesem Picker.
|
||||
///
|
||||
/// Liefert per `showDialog<String>` den finalen Grundtext zurück — also
|
||||
/// entweder einen der vordefinierten Strings oder den vom Fahrer
|
||||
/// eingegebenen Freitext. Bei Abbruch des Dialogs ist das Ergebnis `null`.
|
||||
class ReasonPickerDialog extends StatefulWidget {
|
||||
const ReasonPickerDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
/// Anzeigetitel des Dialogs (z. B. "Lieferung abbrechen").
|
||||
final String title;
|
||||
|
||||
/// Optionaler erläuternder Untertitel (z. B. Name des Kunden).
|
||||
final String? subtitle;
|
||||
|
||||
/// Komfort-Helfer: zeigt den Dialog und liefert das Ergebnis. Aufrufer
|
||||
/// müssen so nicht mehr selbst `showDialog<String>` mit dem Builder
|
||||
/// instanziieren.
|
||||
static Future<String?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? subtitle,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => ReasonPickerDialog(title: title, subtitle: subtitle),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ReasonPickerDialog> createState() => _ReasonPickerDialogState();
|
||||
}
|
||||
|
||||
class _ReasonPickerDialogState extends State<ReasonPickerDialog> {
|
||||
String? _selected;
|
||||
final TextEditingController _freeText = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_freeText.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isOther => _selected == _otherReasonOption;
|
||||
|
||||
bool get _canConfirm {
|
||||
if (_selected == null) return false;
|
||||
if (_isOther) return _freeText.text.trim().isNotEmpty;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
if (!_canConfirm) return;
|
||||
final reason = _isOther ? _freeText.text.trim() : _selected!;
|
||||
Navigator.of(context).pop(reason);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.subtitle != null) ...[
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
..._predefinedReasons.map((reason) {
|
||||
return RadioListTile<String>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(reason),
|
||||
value: reason,
|
||||
groupValue: _selected,
|
||||
onChanged: (val) => setState(() => _selected = val),
|
||||
);
|
||||
}),
|
||||
if (_isOther)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: TextField(
|
||||
controller: _freeText,
|
||||
autofocus: true,
|
||||
maxLines: 3,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Bitte Grund angeben",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _canConfirm ? _confirm : null,
|
||||
child: const Text("Bestätigen"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,927 +0,0 @@
|
||||
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/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/overview/presentation/delivery_fail_page.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/component.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:hl_lieferservice/model/tour.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_event.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _DeliveryGroup {
|
||||
final Delivery delivery;
|
||||
final String? carPlate;
|
||||
final List<Article> articles;
|
||||
|
||||
const _DeliveryGroup({
|
||||
required this.delivery,
|
||||
required this.articles,
|
||||
this.carPlate,
|
||||
});
|
||||
|
||||
int get totalArticles => articles.length;
|
||||
|
||||
int get completeArticles => articles
|
||||
.where((a) => a.isFullyScanned)
|
||||
.length;
|
||||
|
||||
int get totalUnits => articles.fold(0, (sum, a) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
return sum + a.components.fold(0, (s, c) => s + c.requiredAmount);
|
||||
}
|
||||
return sum + a.amount;
|
||||
});
|
||||
|
||||
int get scannedUnits => articles.fold(0, (sum, a) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
return sum + a.components.fold(0, (s, c) => s + c.scannedAmount);
|
||||
}
|
||||
return sum + a.scannedAmount + a.scannedRemovedAmount;
|
||||
});
|
||||
|
||||
bool get isComplete => totalArticles > 0 && completeArticles == totalArticles;
|
||||
|
||||
bool get hasAnyScanned => scannedUnits > 0;
|
||||
|
||||
bool get isPartial => hasAnyScanned && !isComplete;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScanPage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ScanPage extends StatefulWidget {
|
||||
const ScanPage({super.key});
|
||||
|
||||
@override
|
||||
State<ScanPage> createState() => _ScanPageState();
|
||||
}
|
||||
|
||||
class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
String _buffer = '';
|
||||
Timer? _bufferTimer;
|
||||
int? _selectedCarId;
|
||||
bool _isScanning = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus());
|
||||
|
||||
final carState = context.read<CarSelectBloc>().state;
|
||||
if (carState is CarSelectComplete) {
|
||||
_selectedCarId = carState.selectedCar.id;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_focusNode.dispose();
|
||||
_bufferTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scanner input
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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`, wenn der Barcode dem erwarteten Format nicht entspricht.
|
||||
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;
|
||||
|
||||
// ── 1. Try component match first (Stückliste) ──
|
||||
final componentDeliveries = tourState.tour.deliveries
|
||||
.where((d) => d.state != DeliveryState.finished)
|
||||
.where((d) {
|
||||
final parent = d.findParentOfComponent(articleNumber);
|
||||
if (parent == null) return false;
|
||||
final comp = parent.findComponent(articleNumber);
|
||||
return comp != null && !comp.isFullyScanned;
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (componentDeliveries.isNotEmpty) {
|
||||
if (componentDeliveries.length == 1) {
|
||||
setState(() => _isScanning = true);
|
||||
context.read<TourBloc>().add(ScanComponentEvent(
|
||||
componentArticleNumber: articleNumber,
|
||||
carId: _selectedCarId!.toString(),
|
||||
deliveryId: componentDeliveries.first.id,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
_showCustomerSelectionSheet(
|
||||
articleNumber,
|
||||
componentDeliveries,
|
||||
tourState.tour,
|
||||
isComponent: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Regular article scan ──
|
||||
final needingDeliveries = tourState.tour.deliveries
|
||||
.where((d) => d.state != DeliveryState.finished)
|
||||
.where((d) => d.articles.any((a) =>
|
||||
a.articleNumber == articleNumber &&
|
||||
!a.isParent &&
|
||||
a.scannedAmount + a.scannedRemovedAmount < a.amount))
|
||||
.toList();
|
||||
|
||||
if (needingDeliveries.isEmpty) {
|
||||
setState(() => _isScanning = true);
|
||||
context.read<TourBloc>().add(ScanArticleEvent(
|
||||
articleNumber: articleNumber,
|
||||
carId: _selectedCarId!.toString(),
|
||||
deliveryId: tourState.tour.deliveries.first.id,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (needingDeliveries.length == 1) {
|
||||
setState(() => _isScanning = true);
|
||||
context.read<TourBloc>().add(ScanArticleEvent(
|
||||
articleNumber: articleNumber,
|
||||
carId: _selectedCarId!.toString(),
|
||||
deliveryId: needingDeliveries.first.id,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
_showCustomerSelectionSheet(articleNumber, needingDeliveries, tourState.tour);
|
||||
}
|
||||
|
||||
void _showCustomerSelectionSheet(
|
||||
String articleNumber,
|
||||
List<Delivery> deliveries,
|
||||
Tour tour, {
|
||||
bool isComponent = false,
|
||||
}) {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final carId = _selectedCarId!;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.help_outline, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Für welchen Kunden?",
|
||||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(height: 1),
|
||||
...deliveries.map((delivery) {
|
||||
final carPlate = _lookupCarPlate(delivery.carId, tour);
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: Text(delivery.customer.name),
|
||||
subtitle: Text(
|
||||
delivery.customer.address.toString(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: carPlate != null ? _carBadge(ctx, carPlate) : null,
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
setState(() => _isScanning = true);
|
||||
if (isComponent) {
|
||||
tourBloc.add(ScanComponentEvent(
|
||||
componentArticleNumber: articleNumber,
|
||||
carId: carId.toString(),
|
||||
deliveryId: delivery.id,
|
||||
));
|
||||
} else {
|
||||
tourBloc.add(ScanArticleEvent(
|
||||
articleNumber: articleNumber,
|
||||
carId: carId.toString(),
|
||||
deliveryId: delivery.id,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
String? _lookupCarPlate(int? carId, Tour tour) {
|
||||
if (carId == null) return null;
|
||||
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
|
||||
}
|
||||
|
||||
List<_DeliveryGroup> _buildDeliveryGroups(Tour tour) {
|
||||
final List<_DeliveryGroup> groups = [];
|
||||
|
||||
for (final delivery in tour.deliveries) {
|
||||
if (delivery.state == DeliveryState.finished) continue;
|
||||
final scannableArticles =
|
||||
delivery.articles.where((a) => a.scannable).toList();
|
||||
if (scannableArticles.isEmpty) continue;
|
||||
|
||||
groups.add(_DeliveryGroup(
|
||||
delivery: delivery,
|
||||
articles: scannableArticles,
|
||||
carPlate: _lookupCarPlate(delivery.carId, tour),
|
||||
));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Widgets
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Widget _carBadge(BuildContext context, String plate) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_shipping_outlined,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
plate,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressHeader(List<_DeliveryGroup> allGroups) {
|
||||
final total = allGroups.length;
|
||||
final done = allGroups.where((g) => g.isComplete).length;
|
||||
final progress = total > 0 ? done / total : 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Beladungsfortschritt",
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"$done / $total Kunden",
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 6,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
done == total && total > 0
|
||||
? Colors.green
|
||||
: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeliveryTile(_DeliveryGroup group) {
|
||||
final isComplete = group.isComplete;
|
||||
final isPartial = group.isPartial;
|
||||
|
||||
final Color cardColor;
|
||||
final Color borderColor;
|
||||
final Color titleColor;
|
||||
final Color leadingColor;
|
||||
|
||||
if (isComplete) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.35);
|
||||
titleColor = Colors.green.shade700;
|
||||
leadingColor = Colors.green;
|
||||
} else if (isPartial) {
|
||||
cardColor = Colors.orange.withValues(alpha: 0.07);
|
||||
borderColor = Colors.orange.withValues(alpha: 0.35);
|
||||
titleColor = Colors.orange.shade800;
|
||||
leadingColor = Colors.orange.shade700;
|
||||
} else {
|
||||
cardColor = Theme.of(context).colorScheme.surfaceContainerLow;
|
||||
borderColor = Colors.transparent;
|
||||
titleColor = Theme.of(context).colorScheme.onSurface;
|
||||
leadingColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: borderColor),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: isComplete
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: leadingColor,
|
||||
size: 32,
|
||||
key: const ValueKey('done'),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 36,
|
||||
key: const ValueKey('progress'),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${group.completeArticles}/${group.totalArticles}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: leadingColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
group.delivery.customer.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
group.delivery.customer.address.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: group.carPlate != null
|
||||
? _carBadge(context, group.carPlate!)
|
||||
: null,
|
||||
children: [
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
...group.articles.map(_buildArticleEntry),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleEntry(Article article) {
|
||||
if (article.isParent && article.components.isNotEmpty) {
|
||||
return _buildParentArticleEntry(article);
|
||||
}
|
||||
|
||||
final entryDone = article.isFullyScanned;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
|
||||
leading: Icon(
|
||||
entryDone ? Icons.check_circle_outline : Icons.inventory_2_outlined,
|
||||
color: entryDone
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
article.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Artikelnr. ${article.articleNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: entryDone
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a parent article (Stückliste) with its components listed below.
|
||||
Widget _buildParentArticleEntry(Article article) {
|
||||
final allDone = article.isFullyScanned;
|
||||
final scannedCount =
|
||||
article.components.where((c) => c.isFullyScanned).length;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
|
||||
leading: Icon(
|
||||
allDone
|
||||
? Icons.check_circle_outline
|
||||
: Icons.account_tree_outlined,
|
||||
color: allDone
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
article.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Stückliste · $scannedCount/${article.components.length} Komponenten",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
allDone ? Icons.check_circle : Icons.pending_outlined,
|
||||
color: allDone ? Colors.green : Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
...article.components.map(_buildComponentEntry),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Single component row, indented below the parent article.
|
||||
Widget _buildComponentEntry(Component component) {
|
||||
final done = component.isFullyScanned;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 32),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 0),
|
||||
leading: Icon(
|
||||
done
|
||||
? Icons.check_circle_outline
|
||||
: Icons.radio_button_unchecked,
|
||||
color: done
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
size: 16,
|
||||
),
|
||||
title: Text(
|
||||
component.name,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Artikelnr. ${component.articleNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${component.scannedAmount} / ${component.requiredAmount}×',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: done
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tab views
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Widget _buildOpenTab(
|
||||
TourLoaded state,
|
||||
List<_DeliveryGroup> openGroups,
|
||||
List<_DeliveryGroup> allGroups,
|
||||
bool useHardwareScanner,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_isScanning)
|
||||
const LinearProgressIndicator(),
|
||||
if (!useHardwareScanner && openGroups.isNotEmpty)
|
||||
Stack(
|
||||
children: [
|
||||
BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned),
|
||||
if (state.pendingScanRequests > 0)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildProgressHeader(allGroups),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: openGroups.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: 64,
|
||||
color: Colors.green.shade400,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
"Alle Kunden vollständig beladen!",
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 96),
|
||||
itemCount: openGroups.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildDeliveryTile(openGroups[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadedTab(List<_DeliveryGroup> loadedGroups) {
|
||||
if (_selectedCarId == null) {
|
||||
return const Center(child: Text("Kein Fahrzeug ausgewählt"));
|
||||
}
|
||||
|
||||
if (loadedGroups.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
"Noch keine Kunden im Auto",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 96),
|
||||
itemCount: loadedGroups.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildDeliveryTile(loadedGroups[index]),
|
||||
);
|
||||
}
|
||||
|
||||
@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 && tourState.pendingScanRequests == 0) {
|
||||
setState(() => _isScanning = false);
|
||||
}
|
||||
},
|
||||
builder: (context, tourState) {
|
||||
if (tourState is TourLoadingFailed) {
|
||||
return const DeliveryLoadingFailedPage();
|
||||
}
|
||||
|
||||
if (tourState is! TourLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final settingsState = context.read<SettingsBloc>().state;
|
||||
final useHardwareScanner = settingsState is AppSettingsLoaded &&
|
||||
settingsState.settings.useHardwareScanner;
|
||||
|
||||
if (settingsState is AppSettingsFailed) {
|
||||
context.read<OperationBloc>().add(FailOperation(
|
||||
message:
|
||||
"Einstellungen konnten nicht geladen werden. Nutze Kamera-Scanner.",
|
||||
));
|
||||
}
|
||||
|
||||
final allGroups = _buildDeliveryGroups(tourState.tour);
|
||||
|
||||
// Offen: Lieferung hat noch mindestens einen nicht vollständig
|
||||
// gescannten Artikel (über alle Autos hinweg).
|
||||
final openGroups =
|
||||
allGroups.where((g) => !g.isComplete).toList();
|
||||
|
||||
// Im Auto: Lieferung des aktuellen Autos, bei der mindestens ein
|
||||
// Stück gescannt wurde.
|
||||
final loadedGroups = allGroups
|
||||
.where((g) =>
|
||||
g.delivery.carId == _selectedCarId && g.hasAnyScanned)
|
||||
.toList();
|
||||
|
||||
final allDone = tourState.tour.deliveries.isNotEmpty &&
|
||||
openGroups.isEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Beladung"),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||
centerTitle: false,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: Theme.of(context).colorScheme.onSecondary,
|
||||
unselectedLabelColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondary
|
||||
.withValues(alpha: 0.6),
|
||||
indicatorColor: Theme.of(context).colorScheme.onSecondary,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.pending_outlined, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
const Text("Offen"),
|
||||
if (openGroups.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
_tabBadge(
|
||||
context,
|
||||
openGroups.length.toString(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.local_shipping_outlined, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
const Text("Im Auto"),
|
||||
if (loadedGroups.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
_tabBadge(
|
||||
context,
|
||||
loadedGroups.length.toString(),
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: allDone
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
context
|
||||
.read<NavigationBloc>()
|
||||
.add(NavigateToIndex(index: 1));
|
||||
},
|
||||
icon: const Icon(Icons.local_shipping_outlined),
|
||||
label: const Text("Tour starten"),
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
)
|
||||
: null,
|
||||
body: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _handleKey,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildOpenTab(
|
||||
tourState,
|
||||
openGroups,
|
||||
allGroups,
|
||||
useHardwareScanner,
|
||||
),
|
||||
_buildLoadedTab(loadedGroups),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabBadge(BuildContext context, String label, {Color? color}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (color ?? Theme.of(context).colorScheme.onSecondary)
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,9 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/service/cars_service.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||
@ -69,6 +71,20 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
authBloc: context.read<AuthBloc>(),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
// PhaseBloc darf erst NACH dem TourBloc gebaut werden,
|
||||
// da er die Anzahl der Team-Fahrzeuge daraus liest, um
|
||||
// beim ersten Load eines Fahrzeugs die korrekte
|
||||
// Eintrittsphase (Auswählen vs. Sortieren) zu bestimmen.
|
||||
create: (context) => PhaseBloc(
|
||||
carCountResolver: () {
|
||||
final tourState = context.read<TourBloc>().state;
|
||||
return tourState is TourLoaded
|
||||
? tourState.tour.driver.cars.length
|
||||
: null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
// Wrap the Navigator (not just the home route) so the loading
|
||||
|
||||
@ -2,18 +2,34 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.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/car_selection/presentation/selected_car_bar.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.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/phase_state.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_overview_page.dart';
|
||||
import 'package:hl_lieferservice/feature/scan/presentation/scan_page.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/presentation/selected_car_bar.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_sort_page.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/presentation/loading_overview_page.dart';
|
||||
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
|
||||
import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart';
|
||||
|
||||
|
||||
/// Wurzel-Widget des authentifizierten Bereichs. Routet anhand der aktuellen
|
||||
/// Phase des ausgewählten Fahrzeugs:
|
||||
///
|
||||
/// * Phase Sortieren / Beladen → die jeweilige Phase-Page wird direkt
|
||||
/// gerendert (kein BottomNav). Navigation läuft über den Phasen-Stepper.
|
||||
/// * Phase Ausliefern → klassisches Home mit BottomNavigationBar
|
||||
/// (Auslieferung / Fahrzeuge / Einstellungen). Beladung als Tab entfällt,
|
||||
/// da die Phase abgeschlossen ist.
|
||||
class Home extends StatefulWidget {
|
||||
const Home({super.key});
|
||||
|
||||
@ -22,46 +38,131 @@ class Home extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeState extends State<Home> {
|
||||
String? _initializedCarId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Load deliveries
|
||||
Authenticated state = context.read<AuthBloc>().state as Authenticated;
|
||||
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
|
||||
// Tour beim ersten Aufbau laden.
|
||||
final authState = context.read<AuthBloc>().state as Authenticated;
|
||||
context.read<TourBloc>().add(LoadTour(teamId: authState.user.number));
|
||||
}
|
||||
|
||||
Widget _buildPage(index) {
|
||||
if (index == 0) {
|
||||
return ScanPage();
|
||||
}
|
||||
/// Stellt sicher, dass für das aktuell gewählte Auto eine Phase im
|
||||
/// [PhaseBloc] existiert. Wird im build() reaktiv aufgerufen, daher mit
|
||||
/// `_initializedCarId` gegen mehrfache Loads gesichert.
|
||||
///
|
||||
/// Wichtig: Wir feuern den Load erst, sobald die Tour geladen ist —
|
||||
/// sonst kennt der PhaseBloc die Anzahl der Team-Fahrzeuge nicht und
|
||||
/// würde fälschlich mit `sortieren` einsteigen, statt mit `auswaehlen`.
|
||||
void _ensurePhaseLoaded(String carId) {
|
||||
if (_initializedCarId == carId) return;
|
||||
_initializedCarId = carId;
|
||||
context.read<PhaseBloc>().add(PhaseLoadForCar(carId: carId));
|
||||
}
|
||||
|
||||
if (index == 1) {
|
||||
return DeliveryOverviewPage();
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
builder: (context, carState) {
|
||||
// Ohne ausgewähltes Auto bleibt Home leer — der CarSelectionEnforcer
|
||||
// legt die Selection-Page als Overlay darüber.
|
||||
if (carState is! CarSelectComplete) {
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
}
|
||||
|
||||
if (index == 2) {
|
||||
return CarManagementPage();
|
||||
}
|
||||
final carId = carState.selectedCar.id.toString();
|
||||
|
||||
if (index == 3) {
|
||||
return SettingsPage();
|
||||
}
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
// Tour-Status mitnehmen, weil die Eintrittsphase davon abhängt.
|
||||
// Nur bei TourLoaded triggern wir den Phasen-Load.
|
||||
builder: (context, tourState) {
|
||||
if (tourState is TourLoaded) {
|
||||
_ensurePhaseLoaded(carId);
|
||||
}
|
||||
|
||||
return Container();
|
||||
return BlocBuilder<PhaseBloc, PhaseState>(
|
||||
builder: (context, phaseState) {
|
||||
final phase = phaseState is PhaseReady
|
||||
? phaseState.phaseFor(carId)
|
||||
: null;
|
||||
|
||||
// Solange weder Tour noch Phase geladen sind, kurzen Spinner
|
||||
// zeigen — das dauert in der Praxis maximal einen Frame.
|
||||
if (phase == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildForPhase(context, phase, carState.selectedCar.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForPhase(
|
||||
BuildContext context,
|
||||
DeliveryPhase phase,
|
||||
int selectedCarId,
|
||||
) {
|
||||
switch (phase) {
|
||||
case DeliveryPhase.auswaehlen:
|
||||
// Auswahl-Page nur sichtbar bei Teams mit ≥2 Fahrzeugen — der
|
||||
// PhaseBloc setzt diese Phase nicht für Ein-Auto-Teams.
|
||||
return DeliverySelectionPage(selectedCarId: selectedCarId);
|
||||
case DeliveryPhase.sortieren:
|
||||
// Sort-Page baut eigenen Scaffold inkl. Stepper-Header.
|
||||
return DeliverySortPage(selectedCarId: selectedCarId);
|
||||
case DeliveryPhase.beladen:
|
||||
// Beladen-Phase: Einstieg über die Übersicht. Der Fahrer wählt selbst
|
||||
// aus, mit welchem Kunden er starten möchte — das Kunden-Vollbild
|
||||
// wird per Tap auf eine Karte geöffnet (siehe LoadingOverviewPage).
|
||||
return const LoadingOverviewPage();
|
||||
case DeliveryPhase.ausliefern:
|
||||
return const _DeliveryHome();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Klassisches Home für die Auslieferungs-Phase: BottomNavigationBar mit
|
||||
/// drei Tabs (Auslieferung / Fahrzeuge / Einstellungen). Die Beladung als
|
||||
/// Tab entfällt bewusst — wer in dieser Phase zurück zur Beladung möchte,
|
||||
/// nutzt den Phasen-Stepper auf den jeweiligen Pages oder den Drawer.
|
||||
class _DeliveryHome extends StatelessWidget {
|
||||
const _DeliveryHome();
|
||||
|
||||
Widget _buildPage(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return const DeliveryOverviewPage();
|
||||
case 1:
|
||||
return const CarManagementPage();
|
||||
case 2:
|
||||
return const SettingsPage();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NavigationBloc, NavigationState>(
|
||||
builder: (context, state) {
|
||||
final currentState = state as NavigationInfo;
|
||||
final navIndex = state is NavigationInfo ? state.navigationIndex : 0;
|
||||
// Bei einem Tab-Index, der außerhalb des neuen Bereichs liegt
|
||||
// (z. B. vom alten 4-Tab-Layout: 0..3), normieren wir defensiv auf 0.
|
||||
final safeIndex = (navIndex >= 0 && navIndex <= 2) ? navIndex : 0;
|
||||
|
||||
return Scaffold(
|
||||
body: _buildPage(currentState.navigationIndex),
|
||||
body: _buildPage(safeIndex),
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
children: const [
|
||||
SelectedCarBar(),
|
||||
AppNavigationBar(),
|
||||
],
|
||||
|
||||
115
lib/widget/home/presentation/home_drawer.dart
Normal file
115
lib/widget/home/presentation/home_drawer.dart
Normal file
@ -0,0 +1,115 @@
|
||||
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/events.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
|
||||
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
|
||||
|
||||
/// Globales Side-Menu für Fahrzeuge & Einstellungen.
|
||||
///
|
||||
/// Da Sortieren und Beladen kein BottomNav mehr haben, sind diese
|
||||
/// administrativen Funktionen nur noch über den Drawer erreichbar.
|
||||
/// Während der Auslieferungs-Phase steht zusätzlich das alte BottomNav
|
||||
/// weiterhin zur Verfügung.
|
||||
class HomeAppDrawer extends StatelessWidget {
|
||||
const HomeAppDrawer({super.key});
|
||||
|
||||
void _openPage(BuildContext context, Widget page) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => page),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final carState = context.watch<CarSelectBloc>().state;
|
||||
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: BoxDecoration(color: theme.primaryColor),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"Holzleitner",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSecondary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (carState is CarSelectComplete)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_shipping,
|
||||
color: theme.colorScheme.onSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
carState.selectedCar.plate,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Kein Fahrzeug ausgewählt",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSecondary.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (carState is CarSelectComplete)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.swap_horiz),
|
||||
title: const Text("Fahrzeug wechseln"),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.read<CarSelectBloc>().add(CarSelectChange());
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.local_shipping_outlined),
|
||||
title: const Text("Fahrzeuge verwalten"),
|
||||
onTap: () => _openPage(context, const CarManagementPage()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: const Text("Einstellungen"),
|
||||
onTap: () => _openPage(context, const SettingsPage()),
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(height: 1),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
"Lieferservice-App",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,47 +4,46 @@ import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_event.dart';
|
||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
|
||||
|
||||
class AppNavigationBar extends StatefulWidget {
|
||||
/// BottomNavigationBar des Home-Scaffolds — nur in der Auslieferungs-Phase
|
||||
/// sichtbar (siehe `Home`). Die Tabs spiegeln die in dieser Phase relevanten
|
||||
/// Bereiche wider: Auslieferung, Fahrzeugverwaltung, Einstellungen.
|
||||
///
|
||||
/// Beladung als Tab wurde bewusst entfernt: ist die App in der Auslieferung,
|
||||
/// gehört die Beladung organisatorisch der Vergangenheit an. Wer dorthin
|
||||
/// zurück muss, nutzt den Phasen-Stepper.
|
||||
class AppNavigationBar extends StatelessWidget {
|
||||
const AppNavigationBar({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _AppNavigationBarState();
|
||||
}
|
||||
|
||||
class _AppNavigationBarState extends State<AppNavigationBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NavigationBloc, NavigationState>(
|
||||
builder: (context, state) {
|
||||
if (state is NavigationInfo) {
|
||||
return NavigationBar(
|
||||
selectedIndex: state.navigationIndex,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.barcode_reader),
|
||||
label: "Beladung",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.fire_truck),
|
||||
label: "Auslieferung",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.local_shipping),
|
||||
label: "Fahrzeuge",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: "Einstellungen",
|
||||
),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
context.read<NavigationBloc>().add(NavigateToIndex(index: index));
|
||||
},
|
||||
);
|
||||
}
|
||||
if (state is! NavigationInfo) return const SizedBox.shrink();
|
||||
|
||||
return Container();
|
||||
final navIndex = state.navigationIndex;
|
||||
final safeIndex = (navIndex >= 0 && navIndex <= 2) ? navIndex : 0;
|
||||
|
||||
return NavigationBar(
|
||||
selectedIndex: safeIndex,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.fire_truck),
|
||||
label: "Auslieferung",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.local_shipping),
|
||||
label: "Fahrzeuge",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: "Einstellungen",
|
||||
),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
context.read<NavigationBloc>().add(NavigateToIndex(index: index));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
121
lib/widget/phase_banner.dart
Normal file
121
lib/widget/phase_banner.dart
Normal file
@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
|
||||
|
||||
/// Schmaler Info-Banner für die aktuelle Phase.
|
||||
///
|
||||
/// Wurde durch den [PhaseStepper] als primäre Navigations-Anzeige ersetzt,
|
||||
/// bleibt aber als kompakte Alternative für Stellen verfügbar, an denen
|
||||
/// kein vollwertiger Stepper sinnvoll ist (z. B. Dialoge, Sub-Pages).
|
||||
///
|
||||
/// Hinweis: Damit "Schritt X von Y" stimmt, MUSS dem Banner die sichtbare
|
||||
/// Phasen-Liste mitgegeben werden — diese ist team-spezifisch (Ein- vs.
|
||||
/// Mehrauto-Team) und kann nicht hartkodiert werden.
|
||||
class PhaseBanner extends StatelessWidget {
|
||||
const PhaseBanner({
|
||||
super.key,
|
||||
required this.phase,
|
||||
required this.visiblePhases,
|
||||
this.onAdvance,
|
||||
this.onBack,
|
||||
this.advanceLabel,
|
||||
});
|
||||
|
||||
final DeliveryPhase phase;
|
||||
|
||||
/// Die für den aktuellen Fahrer relevanten Phasen — bestimmt die Werte
|
||||
/// für "Schritt X von Y" und muss konsistent zum [PhaseStepper] sein.
|
||||
final List<DeliveryPhase> visiblePhases;
|
||||
|
||||
final VoidCallback? onAdvance;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Optionaler Text für den Vorwärts-Button. Default ist "Phase abschließen".
|
||||
final String? advanceLabel;
|
||||
|
||||
Color _color(BuildContext context) {
|
||||
return switch (phase) {
|
||||
DeliveryPhase.auswaehlen => Colors.blueGrey.shade100,
|
||||
DeliveryPhase.sortieren => Colors.indigo.shade100,
|
||||
DeliveryPhase.beladen => Theme.of(context).colorScheme.primaryContainer,
|
||||
DeliveryPhase.ausliefern => Colors.green.shade100,
|
||||
};
|
||||
}
|
||||
|
||||
Color _foreground(BuildContext context) {
|
||||
return switch (phase) {
|
||||
DeliveryPhase.auswaehlen => Colors.blueGrey.shade800,
|
||||
DeliveryPhase.sortieren => Colors.indigo.shade800,
|
||||
DeliveryPhase.beladen =>
|
||||
Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
DeliveryPhase.ausliefern => Colors.green.shade800,
|
||||
};
|
||||
}
|
||||
|
||||
IconData _icon() {
|
||||
return switch (phase) {
|
||||
DeliveryPhase.auswaehlen => Icons.checklist_outlined,
|
||||
DeliveryPhase.sortieren => Icons.reorder,
|
||||
DeliveryPhase.beladen => Icons.inventory_2_outlined,
|
||||
DeliveryPhase.ausliefern => Icons.local_shipping_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = _foreground(context);
|
||||
|
||||
return Material(
|
||||
color: _color(context),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (onBack != null)
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: Icon(Icons.arrow_back, color: fg),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: "Zurück",
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 8),
|
||||
Icon(_icon(), color: fg, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
phase.displayName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Schritt ${phase.stepNumberIn(visiblePhases)} von ${visiblePhases.length}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: fg.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onAdvance != null)
|
||||
TextButton(
|
||||
onPressed: onAdvance,
|
||||
style: TextButton.styleFrom(foregroundColor: fg),
|
||||
child: Text(advanceLabel ?? "Phase abschließen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
357
lib/widget/phase_stepper/phase_stepper.dart
Normal file
357
lib/widget/phase_stepper/phase_stepper.dart
Normal file
@ -0,0 +1,357 @@
|
||||
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/bloc/phase_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/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
|
||||
|
||||
/// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte
|
||||
/// (Auswählen) → Sortieren → Beladen → Ausliefern.
|
||||
///
|
||||
/// Ersetzt während aller Pre-Lieferungs-Phasen die [BottomNavigationBar]
|
||||
/// und dient zugleich als globaler Header oberhalb der Phase-Inhalts-Page.
|
||||
///
|
||||
/// Sichtbare Phasen:
|
||||
/// * Ein-Auto-Teams: Sortieren, Beladen, Ausliefern (3 Schritte).
|
||||
/// * Mehr-Auto-Teams: Auswählen, Sortieren, Beladen, Ausliefern (4 Schritte).
|
||||
///
|
||||
/// Die Sichtbarkeitsliste wird intern aus dem [TourBloc] abgeleitet
|
||||
/// (Anzahl `tour.driver.cars`). So müssen Aufrufer den Stepper nicht mit
|
||||
/// Routing-Wissen versorgen — er bleibt eine reine Anzeige-Komponente,
|
||||
/// die auf den globalen State reagiert.
|
||||
///
|
||||
/// Verhalten:
|
||||
/// * Aktuelle Phase ist visuell hervorgehoben.
|
||||
/// * Bereits besuchte Phasen (Phasen, die der Fahrer heute schon erreicht
|
||||
/// hatte — verfolgt über `maxPhase` im [PhaseBloc]) lassen sich antippen,
|
||||
/// auch wenn man aktuell auf einer früheren Phase steht. Damit kann der
|
||||
/// Fahrer beliebig zwischen besuchten Schritten hin- und herspringen.
|
||||
/// * Noch nicht besuchte Phasen sind sperren (SnackBar-Hinweis).
|
||||
/// * Über das Menü-Icon links wird der Drawer geöffnet (Fahrzeuge/Settings).
|
||||
/// * Rechts steht das Plate des aktiv gewählten Fahrzeugs.
|
||||
class PhaseStepper extends StatelessWidget {
|
||||
const PhaseStepper({
|
||||
super.key,
|
||||
required this.currentPhase,
|
||||
required this.carId,
|
||||
this.visiblePhases,
|
||||
});
|
||||
|
||||
/// Die aktuell aktive Phase des Fahrzeugs.
|
||||
final DeliveryPhase currentPhase;
|
||||
|
||||
/// Auto-ID, an der die Phase im [PhaseBloc] gespeichert wird.
|
||||
final String carId;
|
||||
|
||||
/// Optionaler Override für Tests / Sonderfälle. Default: aus TourBloc
|
||||
/// abgeleitet (Anzahl cars im Team).
|
||||
final List<DeliveryPhase>? visiblePhases;
|
||||
|
||||
IconData _iconFor(DeliveryPhase phase) {
|
||||
return switch (phase) {
|
||||
DeliveryPhase.auswaehlen => Icons.checklist_outlined,
|
||||
DeliveryPhase.sortieren => Icons.reorder,
|
||||
DeliveryPhase.beladen => Icons.inventory_2_outlined,
|
||||
DeliveryPhase.ausliefern => Icons.local_shipping_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stellt sicher, dass auch die [currentPhase] in der angezeigten Liste
|
||||
/// enthalten ist. Falls z. B. ein persistierter `auswaehlen`-State noch
|
||||
/// aus einem Mehr-Auto-Team stammt, das Team aber inzwischen nur noch
|
||||
/// ein Auto hat, würde die Phase sonst unsichtbar — wir nehmen sie dann
|
||||
/// vorne mit auf, damit der Stepper konsistent bleibt.
|
||||
List<DeliveryPhase> _effectivePhases(int carCount) {
|
||||
final base = DeliveryPhaseExtension.visiblePhasesForCarCount(carCount);
|
||||
if (base.contains(currentPhase)) return base;
|
||||
return [currentPhase, ...base];
|
||||
}
|
||||
|
||||
void _onTap(
|
||||
BuildContext context,
|
||||
DeliveryPhase target,
|
||||
DeliveryPhase maxReached,
|
||||
) {
|
||||
// Vergleich über die natürliche Enum-Reihenfolge, weil "höchste erreichte
|
||||
// Phase" cross-Team konsistent ist (1- vs. Mehr-Auto-Teams).
|
||||
if (target.index > maxReached.index) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Schritt \"${target.displayName}\" ist noch nicht erreicht.",
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (target == currentPhase) return;
|
||||
|
||||
// Vor dem Phasen-Wechsel alle gepushten Routen entfernen (z. B. die
|
||||
// LoadingOverviewPage). Sonst rendert home.dart die neue Phase nur im
|
||||
// Hintergrund, während die alte Page mit ihrem Scanner-State sichtbar
|
||||
// bleibt — was zu einer toten Kamera-View führt.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
|
||||
context.read<PhaseBloc>().add(
|
||||
PhaseSet(carId: carId, phase: target),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final onPrimary = theme.colorScheme.onPrimary;
|
||||
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
// Stepper reagiert auf cars.length-Änderungen — sonst praktisch statisch.
|
||||
buildWhen: (prev, curr) {
|
||||
if (visiblePhases != null) return prev != curr; // override aktiv
|
||||
final prevCars = prev is TourLoaded ? prev.tour.driver.cars.length : 0;
|
||||
final currCars = curr is TourLoaded ? curr.tour.driver.cars.length : 0;
|
||||
return prevCars != currCars || prev.runtimeType != curr.runtimeType;
|
||||
},
|
||||
builder: (context, tourState) {
|
||||
final carCount = tourState is TourLoaded
|
||||
? tourState.tour.driver.cars.length
|
||||
: 0;
|
||||
final phases = visiblePhases ?? _effectivePhases(carCount);
|
||||
|
||||
// Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche
|
||||
// Vorwärts-Sprünge erlaubt sind.
|
||||
final phaseBlocState = context.watch<PhaseBloc>().state;
|
||||
final maxReached = phaseBlocState is PhaseReady
|
||||
? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase)
|
||||
: currentPhase;
|
||||
|
||||
return Material(
|
||||
color: theme.primaryColor,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 8, 30),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Menü-Button — öffnet den Drawer des umgebenden Scaffolds.
|
||||
IconButton(
|
||||
icon: Icon(Icons.menu, color: onPrimary),
|
||||
tooltip: "Menü",
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
const Spacer(),
|
||||
BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
builder: (context, state) {
|
||||
if (state is! CarSelectComplete) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_shipping,
|
||||
color: onPrimary,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
state.selectedCar.plate,
|
||||
style: TextStyle(
|
||||
color: onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (int i = 0; i < phases.length; i++) ...[
|
||||
Expanded(
|
||||
child: _StepperItem(
|
||||
phase: phases[i],
|
||||
currentPhase: currentPhase,
|
||||
maxReached: maxReached,
|
||||
visiblePhases: phases,
|
||||
icon: _iconFor(phases[i]),
|
||||
onTap: () =>
|
||||
_onTap(context, phases[i], maxReached),
|
||||
),
|
||||
),
|
||||
if (i < phases.length - 1)
|
||||
_Connector(
|
||||
// Verbindung gilt als "abgehakt", wenn die rechte
|
||||
// Phase bereits besucht wurde.
|
||||
isPassed:
|
||||
phases[i + 1].index <= maxReached.index,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepperItem extends StatelessWidget {
|
||||
const _StepperItem({
|
||||
required this.phase,
|
||||
required this.currentPhase,
|
||||
required this.maxReached,
|
||||
required this.visiblePhases,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final DeliveryPhase phase;
|
||||
final DeliveryPhase currentPhase;
|
||||
final DeliveryPhase maxReached;
|
||||
final List<DeliveryPhase> visiblePhases;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final onPrimary = theme.colorScheme.onPrimary;
|
||||
|
||||
final int myStep = phase.stepNumberIn(visiblePhases);
|
||||
|
||||
final bool isCurrent = phase == currentPhase;
|
||||
// "Besucht" = irgendwann heute schon erreicht (über Enum-Index), aber
|
||||
// nicht die aktuelle Phase. Erlaubt sowohl Rück- als auch Vorwärts-Tap.
|
||||
final bool isPassed =
|
||||
!isCurrent && phase.index <= maxReached.index;
|
||||
|
||||
final Color circleColor;
|
||||
final Color iconColor;
|
||||
final Color labelColor;
|
||||
final FontWeight labelWeight;
|
||||
|
||||
if (isCurrent) {
|
||||
circleColor = onPrimary;
|
||||
iconColor = theme.primaryColor;
|
||||
labelColor = onPrimary;
|
||||
labelWeight = FontWeight.w700;
|
||||
} else if (isPassed) {
|
||||
circleColor = onPrimary.withValues(alpha: 0.85);
|
||||
iconColor = theme.primaryColor;
|
||||
labelColor = onPrimary;
|
||||
labelWeight = FontWeight.w600;
|
||||
} else {
|
||||
circleColor = onPrimary.withValues(alpha: 0.25);
|
||||
iconColor = onPrimary.withValues(alpha: 0.65);
|
||||
labelColor = onPrimary.withValues(alpha: 0.65);
|
||||
labelWeight = FontWeight.w500;
|
||||
}
|
||||
|
||||
final child = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: circleColor,
|
||||
shape: BoxShape.circle,
|
||||
border: isCurrent
|
||||
? Border.all(color: onPrimary, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 18),
|
||||
),
|
||||
if (isPassed)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 13,
|
||||
height: 13,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade600,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: theme.primaryColor, width: 1.5),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
phase.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: labelColor,
|
||||
fontSize: 12,
|
||||
fontWeight: labelWeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: isCurrent,
|
||||
label:
|
||||
"${phase.displayName}, Schritt $myStep von ${visiblePhases.length}",
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Connector extends StatelessWidget {
|
||||
const _Connector({required this.isPassed});
|
||||
|
||||
final bool isPassed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onPrimary = Theme.of(context).colorScheme.onPrimary;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 2,
|
||||
color: isPassed ? onPrimary : onPrimary.withValues(alpha: 0.35),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
lib/widget/warehouse_badge.dart
Normal file
73
lib/widget/warehouse_badge.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Einheitliche Visualisierung für "Artikel aus Außenlager".
|
||||
///
|
||||
/// Wir nutzen bewusst nur eine Farbe (Amber) — selbst bei 5–10 möglichen
|
||||
/// Lagern bleibt die Karte mit einem zusätzlichen Lagernamen als Text
|
||||
/// lesbar. Mehrere Lager-Farben hätten zu Konfusion geführt und einzelne
|
||||
/// Lager nicht eindeutig zugeordnet.
|
||||
///
|
||||
/// [warehouseNames] ist optional: ohne Namen erscheint nur "Außenlager".
|
||||
class WarehouseBadge extends StatelessWidget {
|
||||
const WarehouseBadge({
|
||||
super.key,
|
||||
this.warehouseNames = const [],
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// Die Distinct-Liste der Lagernamen (kommt typischerweise aus
|
||||
/// Delivery.distinctExternalWarehouseNames).
|
||||
final List<String> warehouseNames;
|
||||
|
||||
/// Kompakte Darstellung für enge Bereiche wie Sortier- und Scan-Listen.
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = Colors.amber.shade700;
|
||||
final bg = Colors.amber.shade100;
|
||||
|
||||
final label = _buildLabel();
|
||||
final iconSize = compact ? 14.0 : 16.0;
|
||||
final textStyle = TextStyle(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: compact ? 11 : 12,
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
label: "Artikel aus $label",
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : 8,
|
||||
vertical: compact ? 2 : 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: fg.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warehouse, size: iconSize, color: fg),
|
||||
SizedBox(width: compact ? 4 : 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildLabel() {
|
||||
if (warehouseNames.isEmpty) return "Außenlager";
|
||||
if (warehouseNames.length == 1) return warehouseNames.first;
|
||||
return warehouseNames.join(" + ");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user