Files
Holzleitner-Lieferservice-App/lib/feature/delivery/bloc/phase_bloc.dart
Dennis Nemec 3add6b498e fix(phasen): Reset zeigt sofort nur 'Sortieren' - keine ererbten Haekchen
Nach einem Tour-Wechsel (Tageswechsel im Kopf-Kalender, Resync) blieben bis
zum asynchronen Nachladen aus den SharedPreferences noch Phase und Haekchen
(maxReached) der vorherigen Tour sichtbar; zudem konnte ein persistiertes
Max uebernommen werden, obwohl fuer die neue Tour-Version gar keine Phase
persistiert war.

- Bei Tour-Wechsel eines bereits geladenen Autos den In-Memory-Stand SOFORT
  synchron auf die Eintrittsphase setzen (nur Sortieren aktiv, keine
  Haekchen); ein persistierter Stand der neuen Tour folgt per PhaseLoaded
- persistedMax nur uebernehmen, wenn fuer diese Tour-Version auch eine Phase
  persistiert ist (frische Tour-Version startet immer ohne ererbte Haekchen)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-09 01:25:20 +02:00

178 lines
7.4 KiB
Dart

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();
/// Liefert einen Token, der die aktuell geladene Tour-Version identifiziert
/// (typischerweise aus `Tour.syncedAt`). Der [PhaseService] bindet die
/// persistierten Phasen an diesen Token — ein neuer ERP-Sync / Demo-Seed
/// erzeugt einen neuen Token und damit einen frischen Phasen-Stand.
///
/// Liefert `null`, wenn (noch) keine Tour geladen ist; der BLoC nutzt dann
/// einen neutralen Fallback-Token.
typedef TourTokenResolver = String? Function();
/// Zentraler State für die aktuelle Phase je Fahrzeug. Persistiert über
/// [PhaseService] auf tour-token-bezogene SharedPreferences-Keys (siehe Service).
///
/// Eintrittsphase nach Fahrzeugauswahl:
/// * 1 Auto im Team → [DeliveryPhase.sortieren] (bisheriges Verhalten).
/// * ≥2 Autos → [DeliveryPhase.auswaehlen] (neuer Auswahl-Schritt).
///
/// Ist bereits eine Phase persistiert, wird diese verwendet (Resume nach
/// Neustart der App). Die Eintrittslogik greift also nur beim "ersten Load
/// des Tages" für ein Fahrzeug.
class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
final PhaseService phaseService;
/// Liefert die aktuelle Anzahl der Team-Fahrzeuge. Wird vom umgebenden
/// Provider so verdrahtet, dass sie aus dem [TourBloc] kommt.
final CarCountResolver? carCountResolver;
/// Liefert den Tour-Token (aus `Tour.syncedAt`). Bindet die persistierten
/// Phasen an die aktuelle Tour-Version.
final TourTokenResolver? tourTokenResolver;
/// Merkt sich pro Fahrzeug, unter welchem Tour-Token der In-Memory-Stand
/// geladen wurde. Ändert sich der Token (Tageswechsel im Kopf-Kalender,
/// neuer Sync), lädt der nächste [PhaseLoadForCar] den persistierten Stand
/// der **neuen** Tour nach, statt den alten Tag weiterzuzeigen. Der alte
/// Tag bleibt persistiert und wird bei Rückkehr wiederhergestellt.
final Map<String, String> _loadedTokenByCar = {};
PhaseBloc({
PhaseService? phaseService,
this.carCountResolver,
this.tourTokenResolver,
}) : phaseService = phaseService ?? PhaseService(),
super(PhaseInitial()) {
on<PhaseLoadForCar>(_load);
on<PhaseLoaded>(_applyLoaded);
on<PhaseSet>(_set);
}
/// Aktueller Tour-Token oder neutraler Fallback, falls noch keine Tour
/// geladen ist (z. B. `TourEmpty`) — dort ist die Phase ohnehin
/// bedeutungslos.
String _token() => tourTokenResolver?.call() ?? 'no-tour';
PhaseReady _ensureReady() {
final current = state;
return current is PhaseReady
? current
: PhaseReady(phaseByCar: const {});
}
/// Bestimmt die initiale Phase für ein frisch ausgewähltes Fahrzeug.
/// Sobald die Tour bekannt ist und ≥2 Fahrzeuge enthält, startet der
/// Fahrer im Auswahl-Schritt; sonst direkt im Sortieren.
DeliveryPhase _entryPhase() {
final count = carCountResolver?.call();
if (count != null && count >= 2) return DeliveryPhase.auswaehlen;
return DeliveryPhase.sortieren;
}
Future<void> _load(PhaseLoadForCar event, Emitter<PhaseState> emit) async {
final current = _ensureReady();
final token = _token();
// Bereits für GENAU diese Tour-Version geladen → nichts tun (der
// Stepper-Tap entscheidet aktiv). Ein anderer Token (Tageswechsel, neuer
// Sync) lädt den persistierten Stand der neuen Tour nach: ein neuer Tag
// startet so wieder in der Eintrittsphase (Beladen/Ausliefern zurück),
// der vorherige Tag behält seinen Stand für die Rückkehr.
if (current.phaseByCar.containsKey(event.carId) &&
_loadedTokenByCar[event.carId] == token) {
return;
}
// Tour-Wechsel bei bereits geladenem Auto: den alten In-Memory-Stand
// SOFORT synchron auf die Eintrittsphase zurücksetzen (nur „Sortieren"
// aktiv, keine Häkchen), damit während des asynchronen Nachladens aus
// den SharedPreferences nichts von der vorherigen Tour sichtbar bleibt.
// Ein evtl. persistierter Stand der neuen Tour (Rückkehr zu einem alten
// Tag) folgt direkt danach per PhaseLoaded.
if (current.phaseByCar.containsKey(event.carId)) {
final entry = _entryPhase();
emit(current.withLoaded(event.carId, entry, entry));
}
try {
final persisted = await phaseService.load(event.carId, token);
final persistedMax = await phaseService.loadMax(event.carId, token);
final phase = persisted ?? _entryPhase();
// Max ist mindestens die aktuelle Phase. Ein höheres persistiertes Max
// (Rücksprung am selben Tag) wird NUR übernommen, wenn für diese
// Tour-Version auch eine Phase persistiert ist. Eine frische
// Tour-Version (persisted == null) startet immer ohne ererbte Häkchen.
DeliveryPhase maxPhase = phase;
if (persisted != null &&
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, token, phase);
await phaseService.saveMax(event.carId, token, maxPhase);
} else if (persistedMax == null) {
// Migration: alte Tage ohne Max-Tracking → einmalig nachziehen.
await phaseService.saveMax(event.carId, token, maxPhase);
}
_loadedTokenByCar[event.carId] = token;
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();
_loadedTokenByCar[event.carId] = token;
add(PhaseLoaded(
carId: event.carId,
phase: fallback,
maxPhase: fallback,
));
}
}
void _applyLoaded(PhaseLoaded event, Emitter<PhaseState> emit) {
final current = _ensureReady();
emit(current.withLoaded(event.carId, event.phase, event.maxPhase));
}
Future<void> _set(PhaseSet event, Emitter<PhaseState> emit) async {
final current = _ensureReady();
final next = current.withPhase(event.carId, event.phase);
emit(next);
try {
final token = _token();
await phaseService.save(event.carId, token, 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, token, newMax);
}
} catch (e, st) {
debugPrint("PhaseBloc._set: $e $st");
// UI bleibt konsistent, Persistenz-Fehler ignorieren wir bewusst —
// beim nächsten Setzen wird erneut versucht.
}
}
}