diff --git a/docs/BACKEND_MIGRATION.md b/docs/BACKEND_MIGRATION.md new file mode 100644 index 0000000..c99a373 --- /dev/null +++ b/docs/BACKEND_MIGRATION.md @@ -0,0 +1,189 @@ +# Backend-Migration: ERPframe → Rust-Backend + +Stand: 2026-05-14 + +## Ziel + +Die Holzleitner-Lieferservice-App wird vom alten ERPframe/DOCUframe-Backend +auf das neue Rust-Backend (`/Users/dennis/Desktop/Arbeit/Holzleitner-Backend`) +umgestellt. Auf Client-Seite kommt Clean Architecture konsequent durch: +data-Layer (generierte API-DTOs), domain-Layer (App-Entities), Mapper, +Repositories, Blocs. + +## Entscheidungen + +| Entscheidung | Wahl | Begründung | +|---|---|---| +| API-Client | **OpenAPI-Generator** (`dart` mit `dio` als Provider) | Wir haben sauberes OpenAPI im Backend; handschriftliche Services wären 1000 Zeilen Drift-Risiko. | +| Discount/Payment-Features | **Feature-Flag mit Stubs**, UI-Pfade bleiben kompilierbar | Diese Features existieren im neuen Backend nicht. Stubs werfen `UnsupportedFeatureException`. UI-Aufrufer können sie defensiv behandeln. Spätere Reaktivierung möglich, wenn Kunde bestätigt. | +| Offline-Scan-Queue | **Später**, erst Online-Pfad | Erstmal sauberer Online-Flow. Outbox-Pattern kommt als separate Iteration, wenn das Online-Verhalten sitzt. | +| Phasenfolge | A → B → C → D → E → F | Jede Phase einzeln smoke-getestet, bevor die nächste beginnt. | + +## Backend-Endpunkte, die wir treffen + +Aus dem fertigen Rust-Backend (Stand 2026-05-14): + +| HTTP | Pfad | Use Case | +|---|---|---| +| GET | `/me/tours/today` | Liste der heutigen Touren des angemeldeten Fahrers | +| GET | `/tours/{id}` | Tour-Aggregat (eine Lieferung pro Roundtrip) | +| PUT | `/tours/{id}/delivery-order` | Sortierreihenfolge schreiben | +| POST | `/scans` | Bulk-Scan-Endpoint mit `clientScanId` für Idempotenz | +| POST | `/deliveries/{id}/hold\|resume\|cancel\|complete` | Lifecycle-Übergänge | +| PUT | `/deliveries/{id}/assigned-car` | Fahrzeug-Zuordnung | +| POST | `/deliveries/{id}/notes` | Notiz anlegen (Text/Bild-Anhang) | +| GET | `/me/cars[?includeInactive=true]` | Fahrzeuge des Fahrers | +| POST | `/me/cars` | Fahrzeug anlegen | +| PATCH | `/me/cars/{id}` | Fahrzeug ändern/deaktivieren | +| GET | `/accounts/{personalnummer}` | Account-Stammdaten | + +Auth: Keycloak OIDC, JWT Bearer, OIDC-Discovery via +`http://localhost:8080/realms/holzleitner/.well-known/openid-configuration`, +Audience `holzleitner-api`, Client `holzleitner-app` (public + PKCE). + +## Modell-Alignment + +Die App-Modelle (`lib/model/*`) entsprechen nicht den Backend-Modellen. +Wichtigste Differenzen, die der Mapper auflösen muss: + +| App heute | Backend | +|---|---| +| `Article.amount`, `scannedAmount`, `isParent`, `components[]` | `DeliveryItem.requiredQuantity`, `scanState`, flache Liste, `komponentenArtikelNr?` | +| `DeliveryState.{ongoing, finished, onhold, canceled}` | `DeliveryState.{active, completed, held, canceled}` | +| `Car { int id, String plate }` | `Car { UUID id, i64 accountId, String plate, bool active }` | +| `Note { int id, String content }` | `DeliveryNote { UUID, text?, imageAttachment?, authorPersonalnummer, authorCarId?, createdAt }` | +| `Tour { Driver driver, deliveriesPerCar }` | `TourDetails { Tour, Vec, customers, articles, warehouses, notes }` | +| `Discount { article, note, noteId }` | — kein Backend-Pendant | +| `Payment { id, description, shortcode }` | — kein Backend-Pendant | + +Stücklisten-Komponenten als eigene Entity entfallen. Die Hierarchie kann +die UI aus `komponentenArtikelNr` rekonstruieren, wenn gebraucht. + +## Was als Feature-Flag stubbt + +Discount- und Payment-Pfade bleiben in der UI sichtbar, aber: + +- `DiscountRepository.add/remove/update` → `throw UnsupportedFeatureException("discounts")` +- `PaymentRepository.list/select` → liefert `[]` bzw. `null` +- Aufrufende Stellen (z. B. `TourBloc`) fangen die Exception, zeigen + Snackbar "Funktion nicht verfügbar" und machen weiter. + +Konfigurierbar über `FeatureFlags.discountsEnabled = false` und +`FeatureFlags.paymentsEnabled = false` (Default beides `false`). + +## Phasen + +### Phase A — Foundation +- `dio: ^5` und `dio_smart_retry` (optional) in `pubspec.yaml`. +- `openapi_generator` als `dev_dependency`; Generator-Aufruf in + `tool/generate_api_client.sh` (oder build_runner) verdrahten. +- OpenAPI-Spec lokal aus dem laufenden Backend ziehen + (`curl http://localhost:3000/openapi.json -o openapi/holzleitner.json`), + einchecken (zur Reproduzierbarkeit), generierten Code unter + `lib/data/api/` ablegen, gitignoren. +- `AuthInterceptor` für `dio`: Bearer-Header einhängen, bei 401 versuchen + zu refreshen, sonst Logout-Event feuern. +- `get_it`-Registrierungen für `Dio`, generierte API-Klassen, + Repositories. + +Smoke: `GET /health` und `GET /me/cars` (mit hartkodiertem Token aus +Keycloak) via generiertem Client erreichbar. + +### Phase B — Auth (Keycloak OIDC) +- `flutter_appauth: ^7` für PKCE-Flow. +- `flutter_secure_storage` für Refresh-Token. +- `KeycloakAuthService` mit `login()`, `logout()`, `refresh()`, + `currentAccessToken()`. +- `LoginPage` umbauen: kein Browser-Redirect + Deep-Link, sondern + PKCE-Flow via `flutter_appauth`. Bei Erfolg: Tokens in Secure Storage, + AuthBloc-Event `SetAuthenticatedEvent`. +- `AuthBloc.Authenticated` State trägt jetzt + `{ accessToken, refreshToken, idTokenClaims }` statt `sessionId`. +- `AuthInterceptor` aus Phase A nutzt diesen State. + +Smoke: Login → Token landet im Secure Storage → `GET /me/cars` über +Interceptor klappt. + +### Phase C — Domain & Mapper +- Generierte DTOs (`lib/data/api/model/`) bleiben unverändert. +- Domain-Entities in `lib/domain/entity/` definieren — analog zu heute, + aber an Backend-Felder angepasst (Enum-Werte, UUID-IDs etc.). +- Mapper unter `lib/data/mapper/` pro Entity: + `TourMapper.fromDto(TourDetailsDto) -> Tour`. +- `lib/model/*` und `lib/dto/*` werden **gelöscht**, sobald alle + Aufrufer auf die neuen domain-Entities umgestellt sind. Bis dahin + laufen beide parallel. + +Kein UI-Test nötig — diese Phase ist reines Refactoring. + +### Phase D — Repositories +- Pro Repository abstraktes Interface in `lib/domain/repository/`, + konkrete Implementierung in `lib/data/repository/`. +- Implementierungen rufen den generierten API-Client auf und mappen + DTO ↔ Entity. +- Neu/umgestellt: + - `TourRepository` → `GET /me/tours/today` + `GET /tours/{id}` + + `PUT /tours/{id}/delivery-order` + - `DeliveryRepository` → `POST /deliveries/{id}/{hold,resume,cancel,complete}`, + `PUT /deliveries/{id}/assigned-car` + - `ScanRepository` → `POST /scans` (bulk) + - `NoteRepository` → `POST /deliveries/{id}/notes` + - `CarsRepository` → `GET/POST/PATCH /me/cars` + - `ProcessRepository` (heute Stubs) → bekommt echte Calls +- Wegfall: + - `DiscountRepository` → Stub mit `UnsupportedFeatureException` + - Payment-Methoden in `TourRepository` → Stub `[]` + +Smoke: Login → `loadTourOfToday()` → Tour kommt durch. + +### Phase E — Blocs +- `TourBloc`: an neues `Tour`-Aggregat anpassen, Discount/Payment-Events + hinter Feature-Flag. +- `CarsBloc`: nutzt neue `CarsRepository`-Signaturen (UUID statt int, + `active` statt delete). +- `CarSelectBloc`: speichert Car-UUID statt `int` in + SharedPreferences (Migration der bestehenden gespeicherten Auswahl: + bei UUID-Parse-Fehler einfach `null` und neu auswählen lassen). +- `AuthBloc`: bereits in Phase B angepasst, hier nur noch + Konsistenz-Check. +- `PhaseBloc`: bleibt UI-seitig, konsumiert das neue Aggregat. + +Smoke: Tab-Navigation funktioniert, jeder Tab lädt ohne Crash. + +### Phase F — Smoke des kompletten Flows +Manuell durchklicken: +1. Login (Keycloak) +2. Fahrzeugauswahl +3. Sortier-Phase (Reihenfolge per Drag & Drop, Persistieren) +4. Beladen-Phase (Scan, Hold, Remove) +5. Auslieferung-Phase (Complete, Notizen, Cancel) +6. Logout + +Bei jedem Schritt: Backend-Logs prüfen (richtige Endpoints gehen rein, +keine 4xx/5xx). + +## Was die App nach der Migration *nicht* mehr kann + +- Rabatte (Discount-Pfade als Stub, UI zeigt Snackbar) +- Zahlungsarten-Auswahl (Payment-Pfade als Stub) +- Lieferung-spezifische ERP-Optionen (`DeliveryOption`) — fielen + ohnehin durch das Modell raus + +Diese Features kommen zurück, sobald sie im Backend modelliert sind. + +## Was die App nach der Migration *neu* kann + +- Idempotente Bulk-Scans (Offline-Wiederholbarkeit auf Protokoll-Ebene + schon vorbereitet) +- Saubere Audit-Spuren (`actorCarId` an Scans + Notizen) +- Fahrzeug-Soft-Delete (statt Hardes Löschen) +- Server-vergebene `tour_date` (statt App-Datum) + +## Out of Scope dieser Migration + +- Offline-Queue für Scans (kommt später) +- Object-Storage für Bilder (heute schicken wir Strings, später + pre-signed URLs) +- Cars-Verwaltung von ERPframe migrieren — Fahrzeuge werden neu + angelegt +- ERP-Sync (POST /sync/tour) — das ist DOCUcontrol-Seite, nicht App diff --git a/lib/feature/delivery/bloc/phase_bloc.dart b/lib/feature/delivery/bloc/phase_bloc.dart new file mode 100644 index 0000000..f68a4ca --- /dev/null +++ b/lib/feature/delivery/bloc/phase_bloc.dart @@ -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 { + 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(_load); + on(_applyLoaded); + on(_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 _load(PhaseLoadForCar event, Emitter 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 emit) { + final current = _ensureReady(); + emit(current.withLoaded(event.carId, event.phase, event.maxPhase)); + } + + Future _set(PhaseSet event, Emitter 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. + } + } +} diff --git a/lib/feature/delivery/bloc/phase_event.dart b/lib/feature/delivery/bloc/phase_event.dart new file mode 100644 index 0000000..c10d945 --- /dev/null +++ b/lib/feature/delivery/bloc/phase_event.dart @@ -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, + }); +} diff --git a/lib/feature/delivery/bloc/phase_state.dart b/lib/feature/delivery/bloc/phase_state.dart new file mode 100644 index 0000000..75d1c32 --- /dev/null +++ b/lib/feature/delivery/bloc/phase_state.dart @@ -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 phaseByCar; + final Map 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.from(phaseByCar); + next[carId] = phase; + + final nextMax = Map.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.from(phaseByCar); + next[carId] = phase; + final nextMax = Map.from(maxPhaseByCar); + nextMax[carId] = maxPhase; + return PhaseReady(phaseByCar: next, maxPhaseByCar: nextMax); + } +} diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index c4d3e1b..e611871 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -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 { 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 { on(_load); on(_assignCar); + on(_unassignDelivery); on(_increment); on(_scan); on(_scanComponent); @@ -59,6 +67,9 @@ class TourBloc extends Bloc { on(_calculateDistances); on(_requestSortingInformation); on(_reorderDelivery); + on(_replaceSorting); + on(_confirmSorting); + on(_ensureSortingForCar); on(_carsLoaded); on(_setArticleAmount); } @@ -128,6 +139,128 @@ class TourBloc extends Bloc { } } + Future _ensureSortingForCar( + EnsureSortingForCarEvent event, + Emitter 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 []; + + // 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 = {}; + final merged = []; + 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 a, List 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 _replaceSorting( + ReplaceSortingEvent event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is TourLoaded) { + await ReorderService().saveSortingInformation( + event.newSortingInformation, + ); + emit( + currentState.copyWith( + sortingInformation: event.newSortingInformation, + ), + ); + } + } + + Future _confirmSorting( + ConfirmSortingEvent event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is! TourLoaded) return; + + emit( + currentState.copyWith( + isPersistingSorting: true, + clearSortingPersistError: true, + ), + ); + + final orderedIds = + currentState.sortingInformation[event.carId] ?? const []; + + 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 emit, @@ -230,6 +363,8 @@ class TourBloc extends Bloc { distances: Map.from(currentState.distances ?? {}), sortingInformation: currentState.sortingInformation, pendingScanRequests: currentState.pendingScanRequests, + isPersistingSorting: currentState.isPersistingSorting, + sortingPersistError: currentState.sortingPersistError, ), ); } @@ -409,6 +544,29 @@ class TourBloc extends Bloc { } } + Future _unassignDelivery( + UnassignDeliveryEvent event, + Emitter 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 _load(LoadTour event, Emitter emit) async { try { emit(TourLoading()); diff --git a/lib/feature/delivery/bloc/tour_event.dart b/lib/feature/delivery/bloc/tour_event.dart index f0ceac1..1311060 100644 --- a/lib/feature/delivery/bloc/tour_event.dart +++ b/lib/feature/delivery/bloc/tour_event.dart @@ -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> 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 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; diff --git a/lib/feature/delivery/bloc/tour_state.dart b/lib/feature/delivery/bloc/tour_state.dart index eedc1c6..2784af2 100644 --- a/lib/feature/delivery/bloc/tour_state.dart +++ b/lib/feature/delivery/bloc/tour_state.dart @@ -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? paymentOptions, Map>? 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), ); } } diff --git a/lib/feature/delivery/model/delivery_phase.dart b/lib/feature/delivery/model/delivery_phase.dart new file mode 100644 index 0000000..3bf7028 --- /dev/null +++ b/lib/feature/delivery/model/delivery_phase.dart @@ -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 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 visiblePhasesForCarCount(int carCount) { + if (carCount >= 2) { + return const [ + DeliveryPhase.auswaehlen, + DeliveryPhase.sortieren, + DeliveryPhase.beladen, + DeliveryPhase.ausliefern, + ]; + } + return const [ + DeliveryPhase.sortieren, + DeliveryPhase.beladen, + DeliveryPhase.ausliefern, + ]; + } +} diff --git a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart index af8077c..17c0618 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart @@ -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 { @override Widget build(BuildContext context) { final carState = context.watch().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( builder: (context, state) { diff --git a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart new file mode 100644 index 0000000..8e5e76c --- /dev/null +++ b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart @@ -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 createState() => _DeliverySelectionPageState(); +} + +class _DeliverySelectionPageState extends State { + /// Lokale Multi-Selektion im Tab "Verfügbar". Wird nach erfolgreichem + /// Bulk-Assign geleert. + final Set _selectedIds = {}; + + /// 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 _confirmSelection() async { + if (_selectedIds.isEmpty || _isAssigning) return; + + setState(() => _isAssigning = true); + final tourBloc = context.read(); + final ids = List.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().add( + PhaseSet( + carId: _carIdString, + phase: DeliveryPhase.sortieren, + ), + ); + } + + Future _showReleaseDialog(Delivery delivery) async { + final result = await showDialog( + 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().add( + UnassignDeliveryEvent(deliveryId: delivery.id), + ); + } + + Future _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( + 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().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 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 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( + 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(), + ), + ); + }, + ); + } +} diff --git a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart new file mode 100644 index 0000000..a45def7 --- /dev/null +++ b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart @@ -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 createState() => _DeliverySortPageState(); +} + +class _DeliverySortPageState extends State { + 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().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().add( + EnsureSortingForCarEvent(carId: widget.selectedCarId.toString()), + ); + } + + List _orderedIdsFor(TourLoaded state) { + return state.sortingInformation[widget.selectedCarId.toString()] ?? + const []; + } + + /// 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().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().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( + 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 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 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"), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/delivery/overview/service/phase_service.dart b/lib/feature/delivery/overview/service/phase_service.dart new file mode 100644 index 0000000..aea783f --- /dev/null +++ b/lib/feature/delivery/overview/service/phase_service.dart @@ -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 save(String carId, DeliveryPhase phase) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(carId), phase.persistenceKey); + } + + Future load(String carId) async { + final prefs = await SharedPreferences.getInstance(); + return DeliveryPhaseExtension.fromPersistenceKey(prefs.getString(_key(carId))); + } + + Future saveMax(String carId, DeliveryPhase phase) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_maxKey(carId), phase.persistenceKey); + } + + Future loadMax(String carId) async { + final prefs = await SharedPreferences.getInstance(); + return DeliveryPhaseExtension.fromPersistenceKey( + prefs.getString(_maxKey(carId)), + ); + } + + Future> loadAll(Iterable carIds) async { + final result = {}; + for (final carId in carIds) { + final phase = await load(carId); + if (phase != null) result[carId] = phase; + } + return result; + } +} diff --git a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart new file mode 100644 index 0000000..0c53a71 --- /dev/null +++ b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart @@ -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 createState() => _SortableDeliveryListState(); +} + +class _SortableDeliveryListState extends State { + late List _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 _readSortedListFromBloc() { + final state = context.read().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().state; + if (state is! TourLoaded) return; + + final cars = state.tour.driver.cars; + final carIdStr = widget.selectedCarId.toString(); + final List 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().add( + ReplaceSortingEvent( + carId: carIdStr, + newSortingInformation: container, + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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().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(); +} diff --git a/lib/feature/delivery/repository/process_repository.dart b/lib/feature/delivery/repository/process_repository.dart new file mode 100644 index 0000000..8ace83f --- /dev/null +++ b/lib/feature/delivery/repository/process_repository.dart @@ -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 persistDeliveryOrder({ + required String carId, + required List 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 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 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 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", + ); + } +} diff --git a/lib/feature/loading/model/loading_group.dart b/lib/feature/loading/model/loading_group.dart new file mode 100644 index 0000000..0c616a4 --- /dev/null +++ b/lib/feature/loading/model/loading_group.dart @@ -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
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
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 get externalWarehouseLabels { + final labels = {}; + 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"; +} diff --git a/lib/feature/loading/presentation/loading_customer_page.dart b/lib/feature/loading/presentation/loading_customer_page.dart new file mode 100644 index 0000000..4e64b73 --- /dev/null +++ b/lib/feature/loading/presentation/loading_customer_page.dart @@ -0,0 +1,1125 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; +import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; +import 'package:hl_lieferservice/feature/loading/model/loading_group.dart'; +import 'package:hl_lieferservice/feature/loading/util/loading_order.dart'; +import 'package:hl_lieferservice/feature/loading/widget/article_row.dart'; +import 'package:hl_lieferservice/feature/loading/widget/hold_selection_dialog.dart'; +import 'package:hl_lieferservice/feature/loading/widget/reason_picker_dialog.dart'; +import 'package:hl_lieferservice/feature/scan/presentation/scanner.dart'; +import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; +import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; +import 'package:hl_lieferservice/model/article.dart'; +import 'package:hl_lieferservice/model/delivery.dart'; +import 'package:hl_lieferservice/model/tour.dart'; +import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; + +/// Detail-Ansicht für genau einen Kunden während der Beladen-Phase. +/// +/// Aufgaben: +/// * Scanner-Widget oben — alle erkannten Barcodes werden direkt der +/// aktuellen Lieferung zugeordnet (keine Kunden-Auswahl mehr, da bereits +/// pro Kunde gefiltert). +/// * Übergangs-Dialog "Alle gescannt → Übersicht / Tour starten" pro +/// Kunde, einmalig. +/// * Aktions-Menü (im Customer-Header) zum Abbrechen / Zurückhalten von +/// Artikeln. +/// * Navigation: ein einziges Zurück-Symbol oben rechts in der AppBar +/// (führt per Pop zurück auf die Übersicht). Keine Pfeil-Navigation, +/// kein Phasen-Stepper. +/// +/// Hold-State: +/// Solange das Backend für `reportItemHeld` nur ein Stub ist, lebt der +/// Hold-Zustand ausschließlich im lokalen State dieses Widgets. Die UI +/// blendet betroffene Positionen entsprechend aus. Bei einem echten +/// Backend würde der Stream das Hold-Flag in der Delivery-Datenstruktur +/// mitliefern und dieser lokale Cache fiele weg. +class LoadingCustomerPage extends StatefulWidget { + const LoadingCustomerPage({ + super.key, + this.initialIndex = 0, + }); + + /// Index in der Beladereihenfolge, mit dem die Page öffnet. Wird typischer- + /// weise von der Overview-Page mit dem getappten Kunden-Index gesetzt. + final int initialIndex; + + @override + State createState() => _LoadingCustomerPageState(); +} + +class _LoadingCustomerPageState extends State { + /// Index des aktuell sichtbaren Kunden innerhalb der Beladereihenfolge. + late int _currentIndex; + + /// Trackt Kunden, für die der "alle gescannt"-Dialog bereits gezeigt + /// wurde — verhindert erneutes Auftauchen beim Re-Besuch. + final Set _completedCustomersShown = {}; + + /// Hardware-Scanner-Buffer (analog zur alten ScanPage). + final FocusNode _focusNode = FocusNode(); + String _buffer = ''; + Timer? _bufferTimer; + + /// Lokaler Hold-Cache (siehe Klassen-Doc). Schlüssel über [HoldKey]. + /// Aufgeteilt nach Delivery-ID, damit beim Wechsel zwischen Kunden nichts + /// vermischt wird. + final Map> _heldKeys = >{}; + + /// Aktuell gewähltes Fahrzeug. Wird über den CarSelectBloc synchronisiert, + /// einmalig in initState bevor erster build. + int? _selectedCarId; + + /// Erkennt den Übergang "Lieferung läuft → abgeschlossen", damit der + /// Listener auch dann robust reagiert, wenn der TourBloc zwischendurch + /// rebuilded (z. B. wegen pendingScanRequests). + bool? _lastCompletionFlag; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + + WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); + + final carState = context.read().state; + if (carState is CarSelectComplete) { + _selectedCarId = carState.selectedCar.id; + } + } + + @override + void dispose() { + _focusNode.dispose(); + _bufferTimer?.cancel(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Scanner-Eingang + // --------------------------------------------------------------------------- + + void _handleKey(KeyEvent event) { + if (event is! KeyDownEvent) return; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _bufferTimer?.cancel(); + if (_buffer.isNotEmpty) { + _handleBarcodeScanned(_buffer); + _buffer = ''; + } + } else { + final character = event.character; + if (character != null && character.isNotEmpty) { + _buffer += character; + _bufferTimer?.cancel(); + _bufferTimer = Timer(const Duration(milliseconds: 1000), () { + if (_buffer.isNotEmpty) { + _handleBarcodeScanned(_buffer); + _buffer = ''; + } + }); + } + } + } + + /// Extrahiert die Artikelnummer aus einem Barcode der Form + /// `;;`. Liefert null bei + /// ungültigem Format. Konsistent mit der Logik aus der alten ScanPage. + String? _extractArticleNumber(String barcode) { + debugPrint("QR CODE: $barcode"); + final parts = barcode.split(';'); + if (parts.length != 3) return null; + final articleNumber = parts[0].trim(); + if (articleNumber.isEmpty) return null; + return articleNumber; + } + + void _handleBarcodeScanned(String barcode) { + if (!mounted) return; + if (_selectedCarId == null) { + context.read().add( + FailOperation(message: "Kein Fahrzeug ausgewählt"), + ); + return; + } + + final articleNumber = _extractArticleNumber(barcode); + if (articleNumber == null) { + context.read().add( + FailOperation(message: "Ungültiger Barcode: $barcode"), + ); + return; + } + + final tourState = context.read().state; + if (tourState is! TourLoaded) return; + + // Wir richten den Scan immer an den aktuell sichtbaren Kunden — anders + // als in der alten ScanPage gibt es keine kundenübergreifende + // Disambiguierung mehr, weil die Page kundenfokussiert ist. + final groups = _buildLoadingGroups(tourState); + if (_currentIndex < 0 || _currentIndex >= groups.length) return; + final current = groups[_currentIndex]; + final delivery = current.delivery; + + if (delivery.state == DeliveryState.canceled) { + context.read().add( + FailOperation(message: "Lieferung wurde abgebrochen"), + ); + return; + } + + // 1) Komponenten-Match zuerst (Stückliste). + final parent = delivery.findParentOfComponent(articleNumber); + if (parent != null) { + final comp = parent.findComponent(articleNumber); + if (comp == null) return; + final heldSet = _heldKeys[delivery.id] ?? const {}; + if (heldSet.contains(HoldKey.component(parent, comp))) { + context.read().add( + FailOperation(message: "Komponente ist zurückgehalten"), + ); + return; + } + if (comp.isFullyScanned) { + context.read().add( + FailOperation(message: "Komponente bereits vollständig gescannt"), + ); + return; + } + context.read().add(ScanComponentEvent( + componentArticleNumber: articleNumber, + carId: _selectedCarId!.toString(), + deliveryId: delivery.id, + )); + return; + } + + // 2) Regulärer Artikel-Scan auf den aktuellen Kunden. + final article = delivery.articles.firstWhereOrNull( + (a) => a.articleNumber == articleNumber && !a.isParent, + ); + if (article == null) { + context.read().add( + FailOperation(message: "Artikel gehört nicht zu diesem Kunden"), + ); + return; + } + final heldSet = _heldKeys[delivery.id] ?? const {}; + if (heldSet.contains(HoldKey.article(article))) { + context.read().add( + FailOperation(message: "Artikel ist zurückgehalten"), + ); + return; + } + if (article.scannedAmount + article.scannedRemovedAmount >= + article.amount) { + context.read().add( + FailOperation(message: "Artikel bereits vollständig gescannt"), + ); + return; + } + context.read().add(ScanArticleEvent( + articleNumber: articleNumber, + carId: _selectedCarId!.toString(), + deliveryId: delivery.id, + )); + } + + // --------------------------------------------------------------------------- + // Datenaufbau + // --------------------------------------------------------------------------- + + String? _lookupCarPlate(int? carId, Tour tour) { + if (carId == null) return null; + return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; + } + + /// Beladereihenfolge inkl. abgebrochener Lieferungen für die UI-Anzeige + /// (sichtbar, ausgegraut). Pfeil-Navigation darf sie durchscrollen. + List _buildLoadingGroups(TourLoaded state) { + final carIdStr = _selectedCarId?.toString() ?? ""; + final orderedIds = LoadingOrder.computeForCar( + state: state, + carIdStr: carIdStr, + ); + + final byId = {for (final d in state.tour.deliveries) d.id: d}; + final groups = []; + for (final id in orderedIds) { + final delivery = byId[id]; + if (delivery == null) continue; + if (delivery.state == DeliveryState.finished) continue; + final scannable = + delivery.articles.where((a) => a.scannable).toList(growable: false); + if (scannable.isEmpty && delivery.state != DeliveryState.canceled) { + continue; + } + groups.add(LoadingGroup( + delivery: delivery, + articles: scannable, + carPlate: _lookupCarPlate(delivery.carId, state.tour), + )); + } + return groups; + } + + /// `true`, wenn die Artikel-Position aus dem Standardlager kommt + /// (warehouseNr `null` oder `"0"`). Außenlager-Artikel werden hier nicht + /// betrachtet, weil sie nicht in der Belade-Halle scannbar sind — der + /// Fahrer holt sie erst beim Kundenbesuch ab. + bool _isStandardWarehouse(Article a) { + final nr = a.warehouseNr; + return nr == null || nr.isEmpty || nr == "0"; + } + + /// Aktive Artikel = Standardlager-Artikel, die NICHT zurückgehalten + /// sind. Außenlager-Artikel werden grundsätzlich ausgeschlossen, weil + /// sie nicht in der Beladen-Phase scannbar sind. + List
_activeArticlesOf(LoadingGroup g) { + final held = _heldKeys[g.delivery.id] ?? const {}; + return g.articles.where((a) { + if (!_isStandardWarehouse(a)) return false; + if (held.contains(HoldKey.article(a))) return false; + // Komponenten-Hold deaktiviert den Artikel nicht komplett — wir + // werten in [_isLogicallyComplete] über die nicht-gehaltenen + // Komponenten aus. + return true; + }).toList(growable: false); + } + + /// `true`, wenn — unter Ausschluss der zurückgehaltenen Positionen UND + /// der Außenlager-Artikel — alle scannbaren Einheiten der Lieferung + /// gescannt sind. Berücksichtigt Komponenten-Holds individuell. + bool _isLogicallyComplete(LoadingGroup g) { + final held = _heldKeys[g.delivery.id] ?? const {}; + final standardArticles = + g.articles.where(_isStandardWarehouse).toList(growable: false); + + // Edge-Case 1: Lieferung hat überhaupt keine Standardlager-Artikel + // (alles extern) → in der Beladen-Phase nichts zu tun → fertig. + if (standardArticles.isEmpty) return g.articles.isNotEmpty; + + final actives = _activeArticlesOf(g); + if (actives.isEmpty) { + // Edge-Case 2: alle Standardlager-Artikel sind zurückgehalten → der + // Fahrer hat alles gemeldet, hier ist nichts mehr zu scannen. + return true; + } + for (final a in actives) { + if (a.isParent && a.components.isNotEmpty) { + for (final c in a.components) { + if (held.contains(HoldKey.component(a, c))) continue; + if (!c.isFullyScanned) return false; + } + } else { + if (!a.isFullyScanned) return false; + } + } + return true; + } + + /// Zähler-Tupel "x von y Artikeln gescannt" — bezieht sich auf das + /// Standardlager und ignoriert zurückgehaltene Positionen, damit der + /// Fortschritt für den Fahrer realistisch bleibt. + ({int done, int total}) _progressOf(LoadingGroup g) { + final held = _heldKeys[g.delivery.id] ?? const {}; + int done = 0; + int total = 0; + for (final a in g.articles) { + if (!_isStandardWarehouse(a)) continue; + if (a.isParent && a.components.isNotEmpty) { + for (final c in a.components) { + if (held.contains(HoldKey.component(a, c))) continue; + total += 1; + if (c.isFullyScanned) done += 1; + } + } else { + if (held.contains(HoldKey.article(a))) continue; + total += 1; + if (a.isFullyScanned) done += 1; + } + } + return (done: done, total: total); + } + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + void _openOverview() { + // Die Übersicht ist der Root-Render der Beladen-Phase (siehe home.dart). + // Aus dem Vollbild-Kunden kehrt der Fahrer deshalb per pop dorthin + // zurück — kein erneuter Push, damit der Stack flach bleibt. + Navigator.of(context).pop(); + } + + void _startTour() { + final carState = context.read().state; + if (carState is CarSelectComplete) { + context.read().add( + PhaseSet( + carId: carState.selectedCar.id.toString(), + phase: DeliveryPhase.ausliefern, + ), + ); + } + } + + // --------------------------------------------------------------------------- + // Dialoge & Aktionen + // --------------------------------------------------------------------------- + + Future _maybeShowCompletionDialog( + LoadingGroup current, + List allGroups, + ) async { + if (_completedCustomersShown.contains(current.delivery.id)) return; + + _completedCustomersShown.add(current.delivery.id); + + // "Tour starten"-Variante zeigen, wenn nach Abschluss dieses Kunden + // alle anderen aktiven Lieferungen ebenfalls fertig sind. Abgebrochene + // Lieferungen zählen nicht. Wir prüfen das auf Basis der gebauten + // Gruppen, weil _heldKeys lokal lebt und in _isLogicallyComplete + // berücksichtigt wird. + final allDone = allGroups.every((g) => + g.delivery.state == DeliveryState.canceled || + _isLogicallyComplete(g)); + + final navigator = Navigator.of(context, rootNavigator: true); + + // Wir warten einen Frame, damit der TourBloc-Listener zuende ist und + // dann der Dialog im stabilen UI-Zustand erscheint. + await Future.delayed(Duration.zero); + if (!mounted) return; + + if (allDone) { + final choice = await showDialog<_CompletionChoice>( + context: navigator.context, + builder: (ctx) => AlertDialog( + title: const Text("Beladung abgeschlossen"), + content: const Text( + "Alle Lieferungen sind verladen. Tour jetzt starten?", + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(ctx).pop(_CompletionChoice.overview), + child: const Text("Übersicht"), + ), + FilledButton( + onPressed: () => + Navigator.of(ctx).pop(_CompletionChoice.startTour), + child: const Text("Tour starten"), + ), + ], + ), + ); + if (!mounted || choice == null) return; + switch (choice) { + case _CompletionChoice.overview: + _openOverview(); + break; + case _CompletionChoice.startTour: + _startTour(); + break; + } + } else { + final choice = await showDialog<_CompletionChoice>( + context: navigator.context, + builder: (ctx) => AlertDialog( + title: const Text("Alle Artikel gescannt"), + content: Text( + "Alle Artikel für ${current.delivery.customer.name} wurden " + "gescannt. Zurück zur Übersicht?", + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(null), + child: const Text("Bleiben"), + ), + FilledButton( + onPressed: () => + Navigator.of(ctx).pop(_CompletionChoice.overview), + child: const Text("Zur Übersicht"), + ), + ], + ), + ); + if (!mounted || choice == null) return; + if (choice == _CompletionChoice.overview) { + _openOverview(); + } + } + } + + Future _cancelDeliveryFlow(LoadingGroup current) async { + final reason = await ReasonPickerDialog.show( + context, + title: "Lieferung abbrechen", + subtitle: current.delivery.customer.name, + ); + if (!mounted || reason == null) return; + + // CancelDeliveryEvent feuert den bestehenden tourRepository.cancelDelivery + // Aufruf. Parallel der ProcessRepository-Stub für die Audit-Spur des + // Grunds — bewusst nicht über den TourBloc geleitet, weil der Bloc den + // Grund aktuell nicht kennt und sich der Stub-Aufruf nicht in den Tour- + // Stream einklinkt. Sobald ein realer Endpoint existiert, kann das in + // einen erweiterten Event-Handler gewandert werden. + final tourBloc = context.read(); + tourBloc.add(CancelDeliveryEvent(deliveryId: current.delivery.id)); + + final processRepository = tourBloc.processRepository; + unawaited(processRepository.reportDeliveryCancelled( + deliveryId: current.delivery.id, + reason: reason, + )); + } + + Future _holdItemsFlow(LoadingGroup current) async { + final alreadyHeld = + _heldKeys[current.delivery.id] ?? const {}; + final selected = await HoldSelectionDialog.show( + context, + customerName: current.delivery.customer.name, + articles: current.articles, + alreadyHeld: alreadyHeld, + ); + if (!mounted || selected == null || selected.isEmpty) return; + + final reason = await ReasonPickerDialog.show( + context, + title: "Artikel zurückhalten", + subtitle: + "${selected.length} Position(en) für ${current.delivery.customer.name}", + ); + if (!mounted || reason == null) return; + + final processRepository = context.read().processRepository; + final newHeld = {...alreadyHeld}; + for (final item in selected) { + unawaited(processRepository.reportItemHeld( + deliveryId: current.delivery.id, + articleId: item.article.internalId.toString(), + componentId: item.component?.articleNumber, + reason: reason, + )); + newHeld.add(item.key); + } + setState(() { + _heldKeys[current.delivery.id] = newHeld; + // Hold-Status kann logische Vollständigkeit auslösen → Erkennung neu. + _lastCompletionFlag = null; + }); + } + + // --------------------------------------------------------------------------- + // UI + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, carState) { + if (carState is CarSelectComplete) { + setState(() => _selectedCarId = carState.selectedCar.id); + } + }, + builder: (context, carState) { + return BlocConsumer( + listener: (context, tourState) { + if (tourState is! TourLoaded) return; + final groups = _buildLoadingGroups(tourState); + if (_currentIndex >= groups.length && groups.isNotEmpty) { + _currentIndex = groups.length - 1; + } + if (groups.isEmpty) return; + + final current = groups[_currentIndex.clamp(0, groups.length - 1)]; + if (current.delivery.state == DeliveryState.canceled) { + _lastCompletionFlag = null; + return; + } + + final isComplete = _isLogicallyComplete(current); + // Übergang false → true erkannt → Dialog (einmalig pro Kunde). + if (_lastCompletionFlag == false && isComplete) { + _maybeShowCompletionDialog(current, groups); + } + _lastCompletionFlag = isComplete; + }, + builder: (context, tourState) { + if (tourState is TourLoadingFailed) { + return const DeliveryLoadingFailedPage(); + } + if (tourState is! TourLoaded) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final settingsState = context.read().state; + final useHardwareScanner = settingsState is AppSettingsLoaded && + settingsState.settings.useHardwareScanner; + + final groups = _buildLoadingGroups(tourState); + return _buildScaffold( + tourState: tourState, + groups: groups, + useHardwareScanner: useHardwareScanner, + ); + }, + ); + }, + ); + } + + Widget _buildScaffold({ + required TourLoaded tourState, + required List groups, + required bool useHardwareScanner, + }) { + final hasGroups = groups.isNotEmpty; + final safeIndex = hasGroups ? _currentIndex.clamp(0, groups.length - 1) : 0; + final current = hasGroups ? groups[safeIndex] : null; + final isCanceled = current?.delivery.state == DeliveryState.canceled; + + final theme = Theme.of(context); + return Scaffold( + drawer: const HomeAppDrawer(), + appBar: AppBar( + backgroundColor: theme.primaryColor, + foregroundColor: theme.colorScheme.onPrimary, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: "Zurück", + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text( + "Lieferdetails", + style: TextStyle(fontWeight: FontWeight.w600), + ), + actions: const [ + _AppBarPlateBadge(), + SizedBox(width: 8), + ], + ), + body: KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKey, + child: SafeArea( + top: false, + child: !hasGroups + ? const _EmptyState() + : _buildCustomerView( + tourState: tourState, + groups: groups, + safeIndex: safeIndex, + current: current!, + isCanceled: isCanceled, + useHardwareScanner: useHardwareScanner, + ), + ), + ), + ); + } + + Widget _buildCustomerView({ + required TourLoaded tourState, + required List groups, + required int safeIndex, + required LoadingGroup current, + required bool isCanceled, + required bool useHardwareScanner, + }) { + final progress = _progressOf(current); + final heldSet = _heldKeys[current.delivery.id] ?? const {}; + final theme = Theme.of(context); + + return Column( + children: [ + if (tourState.pendingScanRequests > 0) const LinearProgressIndicator(), + _ScannerSlot( + isCanceled: isCanceled, + useHardwareScanner: useHardwareScanner, + onBarcode: _handleBarcodeScanned, + ), + _CustomerHeader( + position: safeIndex + 1, + total: groups.length, + current: current, + progress: progress, + isCanceled: isCanceled, + actionMenuBuilder: (ctx) => _buildAppBarMenu(ctx, current), + ), + const Divider(height: 1), + Expanded( + child: Opacity( + opacity: isCanceled ? 0.45 : 1.0, + child: ListView( + padding: const EdgeInsets.only(top: 4, bottom: 16), + children: [ + for (final section in _groupArticlesByWarehouse(current.articles)) ...[ + _WarehouseSectionHeader( + label: section.label, + isExternal: section.isExternal, + ), + for (final article in section.articles) + ArticleRow( + article: article, + isHeld: heldSet.contains(HoldKey.article(article)), + disabled: isCanceled, + heldComponents: heldSet, + ), + ], + if (isCanceled) + Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.cancel_outlined, color: Colors.red), + const SizedBox(width: 12), + Expanded( + child: Text( + "Diese Lieferung wurde abgebrochen und wird " + "heute nicht ausgeliefert.", + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + PopupMenuButton<_MenuAction> _buildAppBarMenu( + BuildContext context, + LoadingGroup current, + ) { + return PopupMenuButton<_MenuAction>( + icon: const Icon(Icons.more_vert), + tooltip: "Weitere Aktionen", + onSelected: (action) async { + switch (action) { + case _MenuAction.cancel: + await _cancelDeliveryFlow(current); + break; + case _MenuAction.hold: + await _holdItemsFlow(current); + break; + } + }, + itemBuilder: (ctx) => [ + PopupMenuItem( + value: _MenuAction.hold, + enabled: current.delivery.state != DeliveryState.canceled, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.pause_circle_outline), + title: Text("Artikel nicht heute liefern"), + ), + ), + PopupMenuItem( + value: _MenuAction.cancel, + enabled: current.delivery.state != DeliveryState.canceled, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.cancel_outlined, color: Colors.red), + title: Text( + "Lieferung komplett abbrechen", + style: TextStyle(color: Colors.red), + ), + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Helper-Widgets +// --------------------------------------------------------------------------- + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant), + const SizedBox(height: 12), + Text( + "Keine Lieferungen zum Beladen", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + "Für das ausgewählte Fahrzeug ist die Beladereihenfolge leer.", + textAlign: TextAlign.center, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} + +class _ScannerSlot extends StatelessWidget { + const _ScannerSlot({ + required this.isCanceled, + required this.useHardwareScanner, + required this.onBarcode, + }); + + final bool isCanceled; + final bool useHardwareScanner; + final void Function(String) onBarcode; + + @override + Widget build(BuildContext context) { + if (isCanceled) { + return Container( + height: 110, + margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.withValues(alpha: 0.4)), + ), + child: const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + "Diese Lieferung wurde abgebrochen.", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black54), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + if (useHardwareScanner) { + // Hardware-Scanner liefert Eingaben über den KeyboardListener. + // Wir zeigen einen kompakten Hinweis-Bereich statt der Kamera. + return Container( + height: 60, + margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.qr_code_scanner_outlined, size: 18), + SizedBox(width: 8), + Text("Bereit für Hardware-Scan"), + ], + ), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: BarcodeScannerWidget(onBarcodeDetected: onBarcode), + ); + } +} + +class _CustomerHeader extends StatelessWidget { + const _CustomerHeader({ + required this.position, + required this.total, + required this.current, + required this.progress, + required this.isCanceled, + required this.actionMenuBuilder, + }); + + final int position; + final int total; + final LoadingGroup current; + final ({int done, int total}) progress; + final bool isCanceled; + final Widget Function(BuildContext) actionMenuBuilder; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = isCanceled ? Colors.red.shade400 : theme.colorScheme.primary; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 4, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + backgroundColor: color, + foregroundColor: theme.colorScheme.onPrimary, + child: Text( + "$position", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + current.delivery.customer.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + decoration: isCanceled + ? TextDecoration.lineThrough + : TextDecoration.none, + ), + ), + ), + Text( + "Kunde $position/$total", + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + current.delivery.customer.address.toString(), + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Text( + isCanceled + ? "Lieferung abgebrochen" + : "${progress.done}/${progress.total} Artikel gescannt", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isCanceled + ? Colors.red.shade700 + : (progress.done == progress.total && progress.total > 0 + ? Colors.green.shade700 + : theme.colorScheme.onSurface), + ), + ), + ], + ), + ), + actionMenuBuilder(context), + ], + ), + ); + } +} + +/// Eine Lager-Sektion in der Artikel-Liste — Header + zugehörige Artikel. +class _WarehouseSection { + const _WarehouseSection({ + required this.label, + required this.isExternal, + required this.articles, + }); + + final String label; + final bool isExternal; + final List
articles; +} + +/// Gruppiert Artikel nach Lager. Standardlager (Nummer "0" oder leer/null) +/// landet IMMER an erster Stelle — auch wenn keine Artikel dort liegen, +/// taucht der Header eines aktiven Außenlagers darunter konsistent +/// auf. Außenlager folgen alphabetisch nach Label. +List<_WarehouseSection> _groupArticlesByWarehouse(List
articles) { + const standardKey = "_STD"; + final Map> byKey = {}; + final Map labels = {}; + + bool isExternal(String? nr) => + nr != null && nr.isNotEmpty && nr != "0"; + + for (final a in articles) { + final external = isExternal(a.warehouseNr); + final key = external ? a.warehouseNr! : standardKey; + final label = external + ? ((a.warehouseName?.isNotEmpty ?? false) + ? a.warehouseName! + : "Lager ${a.warehouseNr}") + : "Standardlager"; + byKey.putIfAbsent(key, () =>
[]).add(a); + labels[key] = label; + } + + final keys = byKey.keys.toList(); + keys.sort((a, b) { + // Standardlager IMMER ganz oben. + if (a == standardKey) return -1; + if (b == standardKey) return 1; + return labels[a]!.compareTo(labels[b]!); + }); + + return [ + for (final k in keys) + _WarehouseSection( + label: labels[k]!, + isExternal: k != standardKey, + articles: byKey[k]!, + ), + ]; +} + +/// Voller-Breite-Header über einer Lager-Sektion. Standardlager neutral, +/// Außenlager in deutlichem Orange-Akzent — damit der Fahrer beim Scrollen +/// sofort sieht, wo er noch hinfahren muss. +class _WarehouseSectionHeader extends StatelessWidget { + const _WarehouseSectionHeader({ + required this.label, + required this.isExternal, + }); + + final String label; + final bool isExternal; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final Color bg; + final Color fg; + final IconData icon; + + if (isExternal) { + bg = Colors.deepOrange.withValues(alpha: 0.15); + fg = Colors.deepOrange.shade800; + icon = Icons.warehouse_outlined; + } else { + bg = theme.colorScheme.surfaceContainerHighest; + fg = theme.colorScheme.onSurfaceVariant; + icon = Icons.home_work_outlined; + } + + return Container( + margin: const EdgeInsets.fromLTRB(0, 8, 0, 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: bg, + border: isExternal + ? Border( + left: BorderSide(color: Colors.deepOrange.shade700, width: 4), + ) + : null, + ), + child: Row( + children: [ + Icon(icon, size: 18, color: fg), + const SizedBox(width: 8), + Expanded( + child: Text( + isExternal ? "Außenlager: $label" : label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: fg, + ), + ), + ), + ], + ), + ); + } +} + +/// Plate-Badge für die AppBar — liest das aktiv gewählte Fahrzeug aus dem +/// [CarSelectBloc]. Nutzt einen halbtransparenten Hintergrund, damit das +/// Badge auch auf der Primary-Color-AppBar gut lesbar bleibt. +class _AppBarPlateBadge extends StatelessWidget { + const _AppBarPlateBadge(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! CarSelectComplete) return const SizedBox.shrink(); + final onPrimary = Theme.of(context).colorScheme.onPrimary; + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: onPrimary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_shipping, size: 16, color: onPrimary), + const SizedBox(width: 6), + Text( + state.selectedCar.plate, + style: TextStyle( + color: onPrimary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +enum _MenuAction { cancel, hold } + +enum _CompletionChoice { overview, startTour } diff --git a/lib/feature/loading/presentation/loading_overview_page.dart b/lib/feature/loading/presentation/loading_overview_page.dart new file mode 100644 index 0000000..4321df9 --- /dev/null +++ b/lib/feature/loading/presentation/loading_overview_page.dart @@ -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 _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 = []; + 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( + builder: (context, carState) { + final carIdStr = + carState is CarSelectComplete ? carState.selectedCar.id.toString() : ""; + + return BlocBuilder( + 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 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( + 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 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, + ), + ], + ), + ); + } +} diff --git a/lib/feature/loading/util/loading_order.dart b/lib/feature/loading/util/loading_order.dart new file mode 100644 index 0000000..50f3d9d --- /dev/null +++ b/lib/feature/loading/util/loading_order.dart @@ -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 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 []; + + // 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 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); + } +} diff --git a/lib/feature/loading/widget/article_row.dart b/lib/feature/loading/widget/article_row.dart new file mode 100644 index 0000000..aadfde2 --- /dev/null +++ b/lib/feature/loading/widget/article_row.dart @@ -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 +/// `:`. +/// +/// 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 {}, + 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 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 {}, + }); + + /// 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 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/loading/widget/hold_selection_dialog.dart b/lib/feature/loading/widget/hold_selection_dialog.dart new file mode 100644 index 0000000..4c49dc9 --- /dev/null +++ b/lib/feature/loading/widget/hold_selection_dialog.dart @@ -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
articles; + + /// Set bereits gehaltener Keys — diese erscheinen ausgegraut & disabled. + final Set alreadyHeld; + + static Future?> show( + BuildContext context, { + required String customerName, + required List
articles, + required Set alreadyHeld, + }) { + return showDialog>( + context: context, + builder: (_) => HoldSelectionDialog( + customerName: customerName, + articles: articles, + alreadyHeld: alreadyHeld, + ), + ); + } + + @override + State createState() => _HoldSelectionDialogState(); +} + +class _HoldSelectionDialogState extends State { + final Set _selectedKeys = {}; + late final List _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 _buildItems(List
articles) { + final result = []; + 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 _buildList(ThemeData theme) { + final widgets = []; + + 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), + ), + ), + ), + ); + } +} diff --git a/lib/feature/loading/widget/reason_picker_dialog.dart b/lib/feature/loading/widget/reason_picker_dialog.dart new file mode 100644 index 0000000..fa743a6 --- /dev/null +++ b/lib/feature/loading/widget/reason_picker_dialog.dart @@ -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 _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` 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` mit dem Builder + /// instanziieren. + static Future show( + BuildContext context, { + required String title, + String? subtitle, + }) { + return showDialog( + context: context, + builder: (_) => ReasonPickerDialog(title: title, subtitle: subtitle), + ); + } + + @override + State createState() => _ReasonPickerDialogState(); +} + +class _ReasonPickerDialogState extends State { + 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( + 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"), + ), + ], + ); + } +} diff --git a/lib/feature/scan/presentation/scan_page.dart b/lib/feature/scan/presentation/scan_page.dart deleted file mode 100644 index a5e3621..0000000 --- a/lib/feature/scan/presentation/scan_page.dart +++ /dev/null @@ -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
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 createState() => _ScanPageState(); -} - -class _ScanPageState extends State 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().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 - /// `;;`. - /// 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().add( - FailOperation(message: "Kein Fahrzeug ausgewählt"), - ); - return; - } - - final articleNumber = _extractArticleNumber(barcode); - if (articleNumber == null) { - context.read().add( - FailOperation(message: "Ungültiger Barcode: $barcode"), - ); - return; - } - - final tourState = context.read().state; - if (tourState is! TourLoaded) return; - - // ── 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().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().add(ScanArticleEvent( - articleNumber: articleNumber, - carId: _selectedCarId!.toString(), - deliveryId: tourState.tour.deliveries.first.id, - )); - return; - } - - if (needingDeliveries.length == 1) { - setState(() => _isScanning = true); - context.read().add(ScanArticleEvent( - articleNumber: articleNumber, - carId: _selectedCarId!.toString(), - deliveryId: needingDeliveries.first.id, - )); - return; - } - - _showCustomerSelectionSheet(articleNumber, needingDeliveries, tourState.tour); - } - - void _showCustomerSelectionSheet( - String articleNumber, - List deliveries, - Tour tour, { - bool isComponent = false, - }) { - final tourBloc = context.read(); - 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( - 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( - listener: (context, carState) { - if (carState is CarSelectComplete) { - setState(() => _selectedCarId = carState.selectedCar.id); - } - }, - builder: (context, carState) { - return BlocConsumer( - 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().state; - final useHardwareScanner = settingsState is AppSettingsLoaded && - settingsState.settings.useHardwareScanner; - - if (settingsState is AppSettingsFailed) { - context.read().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() - .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, - ), - ), - ); - } -} diff --git a/lib/widget/app.dart b/lib/widget/app.dart index a43d7cf..3cb76be 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -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 { authBloc: context.read(), ), ), + 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().state; + return tourState is TourLoaded + ? tourState.tour.driver.cars.length + : null; + }, + ), + ), ], child: MaterialApp( // Wrap the Navigator (not just the home route) so the loading diff --git a/lib/widget/home/presentation/home.dart b/lib/widget/home/presentation/home.dart index 0c3393b..0b9615e 100644 --- a/lib/widget/home/presentation/home.dart +++ b/lib/widget/home/presentation/home.dart @@ -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 { + String? _initializedCarId; + @override void initState() { super.initState(); - // Load deliveries - Authenticated state = context.read().state as Authenticated; - context.read().add(LoadTour(teamId: state.user.number)); + // Tour beim ersten Aufbau laden. + final authState = context.read().state as Authenticated; + context.read().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().add(PhaseLoadForCar(carId: carId)); + } - if (index == 1) { - return DeliveryOverviewPage(); - } + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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( + // 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( + 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( 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(), ], diff --git a/lib/widget/home/presentation/home_drawer.dart b/lib/widget/home/presentation/home_drawer.dart new file mode 100644 index 0000000..2e83a2d --- /dev/null +++ b/lib/widget/home/presentation/home_drawer.dart @@ -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().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().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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widget/navigation_bar/presentation/navigation_bar.dart b/lib/widget/navigation_bar/presentation/navigation_bar.dart index b22905d..0c1c1e7 100644 --- a/lib/widget/navigation_bar/presentation/navigation_bar.dart +++ b/lib/widget/navigation_bar/presentation/navigation_bar.dart @@ -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 createState() => _AppNavigationBarState(); -} - -class _AppNavigationBarState extends State { @override Widget build(BuildContext context) { return BlocBuilder( 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().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().add(NavigateToIndex(index: index)); + }, + ); }, ); } diff --git a/lib/widget/phase_banner.dart b/lib/widget/phase_banner.dart new file mode 100644 index 0000000..4f6b2ba --- /dev/null +++ b/lib/widget/phase_banner.dart @@ -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 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"), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widget/phase_stepper/phase_stepper.dart b/lib/widget/phase_stepper/phase_stepper.dart new file mode 100644 index 0000000..7dcc355 --- /dev/null +++ b/lib/widget/phase_stepper/phase_stepper.dart @@ -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? 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 _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().add( + PhaseSet(carId: carId, phase: target), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onPrimary = theme.colorScheme.onPrimary; + + return BlocBuilder( + // 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().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( + 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 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), + ), + ); + } +} diff --git a/lib/widget/warehouse_badge.dart b/lib/widget/warehouse_badge.dart new file mode 100644 index 0000000..a757e4d --- /dev/null +++ b/lib/widget/warehouse_badge.dart @@ -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 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(" + "); + } +}