Compare commits

...

2 Commits

Author SHA1 Message Date
633b7631aa fix(tours): Reload/Neustart behaelt gewaehltes Datum (kein Sprung auf heute)
Der Header-Kalender zeigte nach Neustart/Retry noch 'morgen', geladen wurde
aber 'heute' (leer). Ursache: Initial-Load und diverse Retry-Buttons
dispatchten LoadTour() ohne Datum -> immer heute.

- LoadTour(): reiner Reload, behaelt das gemerkte Datum
- LoadTour.forDate(date): setzt gezielt ein Datum (Kalender + Initial-Load)
- Initial-Load in home.dart liest das Datum aus dem TourDateCubit
- Retry-Buttons (Fail-/Sort-/Loading-Seite) laden dadurch den angezeigten Tag

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 16:06:58 +02:00
83d52364a5 fix(auth): erneuter Login-Tap oeffnet zuverlaessig wieder den Browser
Nach fehlgeschlagenem/timeout Login blieb der Browser beim naechsten Tap
zu: ein per UI-Timeout verwaister flutter_appauth-Flow blockierte nativ
('Connection already in progress').

- Provider dedupet login() (nur EIN authorizeAndExchangeCode gleichzeitig,
  race-sicher via identical-Guard) + discardPendingLogin() gibt die Sperre
  nach UI-Timeout frei, damit der naechste Tap frisch startet
- Best-Effort-Retry, falls der native Flow noch offen ist
- AuthBloc: discardPendingLogin() bei Timeout; kein zweiter Handler waehrend
  Authenticating

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 16:06:58 +02:00
6 changed files with 124 additions and 33 deletions

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -62,6 +63,12 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// können → App hängt nach Hot-Restart am Splash/Login). /// können → App hängt nach Hot-Restart am Splash/Login).
Future<String?>? _refreshInFlight; Future<String?>? _refreshInFlight;
/// Dedup-Sperre für den interaktiven Login: es darf zu jedem Zeitpunkt nur
/// EIN `authorizeAndExchangeCode` laufen. Ein zweiter Aufruf, während noch
/// einer offen ist, würde nativ mit „Connection already in progress"
/// abbrechen — dann bliebe der Browser zu. `null` = kein Login läuft.
Future<void>? _loginInFlight;
final StreamController<AuthSessionEvent> _events = final StreamController<AuthSessionEvent> _events =
StreamController<AuthSessionEvent>.broadcast(); StreamController<AuthSessionEvent>.broadcast();
@ -80,9 +87,64 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder /// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
/// Keycloak einen Fehler liefert. /// Keycloak einen Fehler liefert.
Future<void> login() async { ///
final result = await _appAuth.authorizeAndExchangeCode( /// Deduped: läuft bereits ein Login, wird dieselbe Future zurückgegeben
AuthorizationTokenRequest( /// statt ein zweites `authorizeAndExchangeCode` zu starten (das nativ mit
/// „Connection already in progress" bräche → Browser bliebe zu). Nach
/// einem UI-Timeout gibt der AuthBloc die Sperre via [discardPendingLogin]
/// frei, damit ein erneuter Tap wieder einen frischen Browser-Tab öffnet.
Future<void> login() {
final existing = _loginInFlight;
if (existing != null) return existing;
late final Future<void> flow;
flow = _login().whenComplete(() {
// Nur freigeben, wenn WIR noch die aktive Login-Future sind. Sonst
// würde die späte Completion eines per Timeout verworfenen Versuchs
// die Sperre eines inzwischen neu gestarteten Logins nullen.
if (identical(_loginInFlight, flow)) _loginInFlight = null;
});
_loginInFlight = flow;
return flow;
}
/// Gibt eine (typischerweise per UI-Timeout „aufgegebene") laufende
/// Login-Future frei. Der native Flow selbst ist nicht abbrechbar — wir
/// lösen nur unsere Dedup-Sperre, damit der nächste [login]-Aufruf einen
/// neuen Authorize-Request (frischer Browser-Tab) starten darf.
void discardPendingLogin() => _loginInFlight = null;
Future<void> _login() async {
final result = await _authorize();
_applyTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.idToken,
expiresAt: result.accessTokenExpirationDateTime,
);
await _persistRefreshToken();
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
}
/// Führt den nativen Authorize+Exchange aus. Blockiert ein vom vorigen
/// (verwaisten) Versuch nativ noch offener Flow den Aufruf mit
/// „Connection already in progress", warten wir kurz und versuchen es
/// EINMAL erneut — meist ist der alte Custom-Tab dann geschlossen.
Future<AuthorizationTokenResponse> _authorize() async {
try {
return await _appAuth.authorizeAndExchangeCode(_authRequest());
} on PlatformException catch (e) {
if (_looksLikeInProgress(e)) {
debugPrint('Login: nativer Flow noch offen — einmaliger Retry.');
await Future<void>.delayed(const Duration(milliseconds: 400));
return _appAuth.authorizeAndExchangeCode(_authRequest());
}
rethrow;
}
}
AuthorizationTokenRequest _authRequest() => AuthorizationTokenRequest(
_config.keycloakClientId, _config.keycloakClientId,
redirectUrl, redirectUrl,
discoveryUrl: _discoveryUrl, discoveryUrl: _discoveryUrl,
@ -99,18 +161,16 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
// "User cancelled flow". Erzwingen der Login-Maske → echter // "User cancelled flow". Erzwingen der Login-Maske → echter
// User-Click → sauberer Intent-Dispatch. // User-Click → sauberer Intent-Dispatch.
promptValues: const ['login'], promptValues: const ['login'],
),
); );
_applyTokens( /// Heuristik: erkennt den „ein Authorize läuft bereits"-Fehler von
accessToken: result.accessToken, /// AppAuth (Android). Kein stabiler Code über Plattformen hinweg, daher
refreshToken: result.refreshToken, /// Code + Message defensiv prüfen.
idToken: result.idToken, static bool _looksLikeInProgress(PlatformException e) {
expiresAt: result.accessTokenExpirationDateTime, final haystack = '${e.code} ${e.message}'.toLowerCase();
); return haystack.contains('already in progress') ||
await _persistRefreshToken(); haystack.contains('in progress') ||
haystack.contains('concurrent');
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
} }
/// Versucht, eine vorhandene Session aus der Secure Storage zu /// Versucht, eine vorhandene Session aus der Secure Storage zu

View File

@ -60,6 +60,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
LoginRequested event, LoginRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
// Läuft bereits ein interaktiver Login (Spinner sichtbar), keinen
// zweiten Flow starten. Der Provider dedupet zusätzlich nativ.
if (state is Authenticating) return;
try { try {
emit(Authenticating()); emit(Authenticating());
await tokenProvider.login().timeout(_loginTimeout); await tokenProvider.login().timeout(_loginTimeout);
@ -72,7 +75,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche // State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
// Anmeldung". Bei späterer Exception passiert nichts; das Future // Anmeldung". Bei späterer Exception passiert nichts; das Future
// ist hier nicht mehr awaited. // ist hier nicht mehr awaited.
//
// Wichtig: die Dedup-Sperre im Provider freigeben, sonst würde der
// nächste „Anmelden"-Tap denselben (aufgegebenen) Flow wiederver-
// wenden statt einen frischen Browser-Tab zu öffnen.
debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.'); debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.');
tokenProvider.discardPendingLogin();
emit(Unauthenticated(loginTimedOut: true)); emit(Unauthenticated(loginTimedOut: true));
} catch (err, st) { } catch (err, st) {
debugPrint('Login fehlgeschlagen: $err\n$st'); debugPrint('Login fehlgeschlagen: $err\n$st');

View File

@ -80,7 +80,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
// ─── LoadTour ──────────────────────────────────────────────────────── // ─── LoadTour ────────────────────────────────────────────────────────
Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async { Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async {
_selectedDate = event.date; // Nur bei explizit gesetztem Datum die Wahl aktualisieren; ein reiner
// Reload (LoadTour()) behält den aktuell angezeigten Tag.
if (event.hasDate) _selectedDate = event.date;
emit(const TourLoading()); emit(const TourLoading());
try { try {
final details = await tourRepository.getMyTourDetails(date: _selectedDate); final details = await tourRepository.getMyTourDetails(date: _selectedDate);
@ -103,9 +105,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Future<void> _onRefresh(RefreshTour event, Emitter<TourState> emit) async { Future<void> _onRefresh(RefreshTour event, Emitter<TourState> emit) async {
final current = state; final current = state;
if (current is! TourLoaded) { if (current is! TourLoaded) {
// Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour // Keine sichtbare Tour zum „weichen" Refreshen — wie ein Reload
// behandeln. Das aktuell gewählte Datum beibehalten. // behandeln. `const LoadTour()` behält das aktuell gewählte Datum.
await _onLoad(LoadTour(date: _selectedDate), emit); await _onLoad(const LoadTour(), emit);
return; return;
} }

View File

@ -7,16 +7,28 @@ sealed class TourEvent {
const TourEvent(); const TourEvent();
} }
/// Initial-Load bzw. Laden der Tour des angemeldeten Fahrers für ein /// Lädt die Tour des angemeldeten Fahrers. Account-Filter sitzt im JWT.
/// bestimmtes Datum. Account-Filter sitzt im JWT.
/// ///
/// [date] `null` = „heute" (Server entscheidet). Wählt der Fahrer im /// Zwei Spielarten:
/// Kopf-Kalender ein Datum, wird es hier übergeben und der Bloc merkt es /// * [LoadTour] (ohne Datum): **Reload/Retry** — lädt den aktuell im Bloc
/// sich für nachfolgende [RefreshTour]-Events. /// gemerkten Tag erneut und ändert die Datumswahl NICHT. Für Retry-Buttons
/// und Fehler-Seiten, die sonst versehentlich auf „heute" zurückspringen.
/// * [LoadTour.forDate]: setzt gezielt ein Datum (`null` = heute) und merkt
/// es sich als aktuelles Datum für nachfolgende Reloads/Refreshes. Für den
/// Kopf-Kalender und den Initial-Load (aus dem [TourDateCubit]).
class LoadTour extends TourEvent { class LoadTour extends TourEvent {
const LoadTour({this.date}); const LoadTour()
: date = null,
hasDate = false;
const LoadTour.forDate(this.date) : hasDate = true;
/// Ziel-Datum, nur relevant wenn [hasDate] `true` ist. `null` = heute.
final DateTime? date; final DateTime? date;
/// `true` = [date] wurde bewusst gesetzt; `false` = „aktuelles Datum
/// behalten" (reiner Reload).
final bool hasDate;
} }
/// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener /// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener

View File

@ -9,6 +9,7 @@ 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_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.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_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.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/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
@ -52,7 +53,15 @@ class _HomeState extends State<Home> {
// Tour beim ersten Aufbau laden. Account-Filter sitzt jetzt // Tour beim ersten Aufbau laden. Account-Filter sitzt jetzt
// serverseitig im JWT — kein Personalnummer-Argument mehr nötig. // serverseitig im JWT — kein Personalnummer-Argument mehr nötig.
context.read<TourBloc>().add(const LoadTour()); //
// Das im Kopf-Kalender gewählte Datum berücksichtigen: bleibt der
// TourDateCubit über einen (Warm-)Neustart bestehen (Header zeigt z. B.
// noch „Morgen"), muss der Initial-Load DENSELBEN Tag laden — sonst
// klaffen Anzeige (Morgen) und geladene Daten (Heute) auseinander.
// `null` = heute (Server entscheidet), also unverändertes Default.
context
.read<TourBloc>()
.add(LoadTour.forDate(context.read<TourDateCubit>().state));
// CarsBloc auch hier triggern: wenn der CarSelectBloc beim App-Start // CarsBloc auch hier triggern: wenn der CarSelectBloc beim App-Start
// eine valide Tages-Auswahl aus den SharedPreferences fand, wurde die // eine valide Tages-Auswahl aus den SharedPreferences fand, wurde die

View File

@ -441,7 +441,7 @@ class _SelectedDatePill extends StatelessWidget {
); );
if (picked == null || !context.mounted) return; if (picked == null || !context.mounted) return;
context.read<TourDateCubit>().pick(picked); context.read<TourDateCubit>().pick(picked);
context.read<TourBloc>().add(LoadTour(date: picked)); context.read<TourBloc>().add(LoadTour.forDate(picked));
} }
} }