fix(phasen): ohne Tour nur erster Schritt aktiv - Stepper sperrt Beladen/Ausliefern

An einem Tag ohne Tour konnte man in der AppBar durch alle Stepper-Schritte
navigieren. Ursache: an leeren Tagen gilt der GETEILTE 'no-tour'-Persistenz-
Eimer; einmal dort aufgeschlossener Fortschritt (hohes Max) machte an JEDEM
leeren Tag Beladen/Ausliefern anklickbar.

- Stepper: ohne geladene Tour (TourEmpty/loading) ist maxReached hart der
  erste sichtbare Schritt -> weitere Schritte nicht anklickbar, keine Haekchen
- PhaseBloc._load: ohne Tour ausschliesslich Eintrittsphase, Persistenz wird
  weder gelesen noch geschrieben (kein Fortschritt im 'no-tour'-Eimer)
- PhaseBloc._set: ohne Tour jeden Vorwaerts-Wechsel verwerfen, nichts
  persistieren - unabhaengig vom ausloesenden Button/Tap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-09 01:32:14 +02:00
parent 3add6b498e
commit a027c93c62
2 changed files with 35 additions and 1 deletions

View File

@ -66,6 +66,14 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
/// bedeutungslos. /// bedeutungslos.
String _token() => tourTokenResolver?.call() ?? 'no-tour'; String _token() => tourTokenResolver?.call() ?? 'no-tour';
/// `true`, wenn aktuell eine Tour geladen ist (der Resolver liefert einen
/// Token). Ohne Resolver (z. B. in Tests) gilt „Tour vorhanden", damit
/// nichts unnötig gesperrt wird.
bool _hasTour() {
final resolve = tourTokenResolver;
return resolve == null || resolve() != null;
}
PhaseReady _ensureReady() { PhaseReady _ensureReady() {
final current = state; final current = state;
return current is PhaseReady return current is PhaseReady
@ -106,6 +114,17 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
emit(current.withLoaded(event.carId, entry, entry)); emit(current.withLoaded(event.carId, entry, entry));
} }
// Keine Tour geladen (Tag ohne Tour): ausschließlich die Eintrittsphase,
// ohne Persistenz zu lesen oder zu schreiben. Der geteilte
// 'no-tour'-Eimer darf keinen Fortschritt tragen — sonst wären an jedem
// leeren Tag Beladen/Ausliefern anklickbar.
if (!_hasTour()) {
final entry = _entryPhase();
_loadedTokenByCar[event.carId] = token;
add(PhaseLoaded(carId: event.carId, phase: entry, maxPhase: entry));
return;
}
try { try {
final persisted = await phaseService.load(event.carId, token); final persisted = await phaseService.load(event.carId, token);
final persistedMax = await phaseService.loadMax(event.carId, token); final persistedMax = await phaseService.loadMax(event.carId, token);
@ -156,9 +175,17 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
} }
Future<void> _set(PhaseSet event, Emitter<PhaseState> emit) async { Future<void> _set(PhaseSet event, Emitter<PhaseState> emit) async {
final hasTour = _hasTour();
// Ohne geladene Tour gibt es nichts zu beladen/auszuliefern: nur die
// Eintrittsphase ist erlaubt, jeder Vorwärts-Wechsel wird verworfen —
// egal, welcher Button oder Stepper-Tap ihn auslöst.
if (!hasTour && event.phase.index > _entryPhase().index) return;
final current = _ensureReady(); final current = _ensureReady();
final next = current.withPhase(event.carId, event.phase); final next = current.withPhase(event.carId, event.phase);
emit(next); emit(next);
// Ohne Tour nichts persistieren — der 'no-tour'-Eimer darf keinen
// Fortschritt tragen (s. _load).
if (!hasTour) return;
try { try {
final token = _token(); final token = _token();
await phaseService.save(event.carId, token, event.phase); await phaseService.save(event.carId, token, event.phase);

View File

@ -125,12 +125,19 @@ class PhaseStepper extends StatelessWidget {
final carCount = carsState is CarsLoaded ? carsState.cars.length : 0; final carCount = carsState is CarsLoaded ? carsState.cars.length : 0;
final phases = visiblePhases ?? _effectivePhases(carCount); final phases = visiblePhases ?? _effectivePhases(carCount);
// Ohne geladene Tour (Tag ohne Tour / noch ladend) ist ausschließlich
// der erste Schritt aktiv: es gibt nichts zu beladen oder auszuliefern,
// also dürfen die weiteren Schritte weder anklickbar noch abgehakt
// sein — unabhängig davon, was der PhaseBloc als Max kennt.
final hasTour = context.watch<TourBloc>().state is TourLoaded;
// Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche // Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche
// Vorwärts-Sprünge erlaubt sind. // Vorwärts-Sprünge erlaubt sind.
final phaseBlocState = context.watch<PhaseBloc>().state; final phaseBlocState = context.watch<PhaseBloc>().state;
final maxReached = phaseBlocState is PhaseReady final blocMax = phaseBlocState is PhaseReady
? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase) ? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase)
: currentPhase; : currentPhase;
final maxReached = hasTour ? blocMax : phases.first;
return Material( return Material(
color: theme.primaryColor, color: theme.primaryColor,