fix: Fehlerbehandlung beim Laden und Meldungen überarbeiten

Reklamationen der Fahrer: Meldungen verdeckten Buttons, und nach dem
Abschluss stand „Lieferung nicht in der Tour gefunden" ohne Ausweg da.

Meldungen:
- Erfolgs-SnackBars entfernt, bei Erfolg bleibt der Spinner
- einheitliche, schwebende Meldungen statt Stau über den Buttons
- Server-Rohtexte durch verständliche Texte ersetzt (api_failure_text)

Laden:
- fehlt eine geöffnete Lieferung, wird die Tour einmal neu geholt; die
  Meldung kommt nur nach erfolgreichem Abruf ohne die Lieferung
- Endlos-Spinner auf Lieferdetail, Filial-Abholung und Beladen ersetzt
  durch Kopfzeile, Fehlergrund und „Erneut versuchen"
- Tour wird nach einem Tageswechsel nachgeladen, offene Seiten bleiben
  dabei unberührt
- Abschluss und Zuweisung überschreiben kein zwischenzeitliches Neuladen,
  alte Antworten drehen einen Abschluss nicht mehr zurück
- leere Server-Antworten gelten als Fehler statt als „keine Daten"

Anmeldung:
- Token-Erneuerung wartet höchstens 12 s, der native Aufruf läuft weiter
- App-Start ohne Netz zeigt einen Hinweis mit „Erneut versuchen"
- Fehlerseite der Fahrzeugauswahl bietet „Abmelden"

Außerdem Podfile.lock an die Plugins angeglichen. Tests für die neuen
Fehlerpfade ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-10-30 20:28:24 +01:00
parent 2ff331294e
commit bc95fe9577
40 changed files with 2063 additions and 365 deletions

View File

@ -10,6 +10,18 @@ import 'auth_session_event.dart';
import 'auth_token_provider.dart';
import 'backend_config.dart';
/// Ergebnis von [KeycloakOidcTokenProvider.restoreSession].
enum SessionRestoreResult {
restored,
/// Keine gespeicherte oder keine gültige Session: normaler Login.
noSession,
/// Eine Session existiert, ließ sich wegen Netz/VPN aber gerade nicht
/// erneuern. Ein neuer Versuch genügt, der Fahrer muss sich nicht anmelden.
temporarilyUnavailable,
}
/// OIDC-Token-Provider gegen Keycloak (PKCE Authorization Code Flow).
///
/// Verantwortlich für:
@ -34,6 +46,7 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
required this.redirectUrl,
FlutterAppAuth? appAuth,
FlutterSecureStorage? storage,
this.refreshTimeout = const Duration(seconds: 12),
}) : _config = config,
_appAuth = appAuth ?? const FlutterAppAuth(),
_storage = storage ?? const FlutterSecureStorage();
@ -51,6 +64,10 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// `holzleitner://oauth2redirect`.
final String redirectUrl;
/// So lange wartet ein Request höchstens auf einen Token-Refresh. Kürzer als
/// das Restore-Limit im AuthBloc, damit der Provider dort den Grund meldet.
final Duration refreshTimeout;
String? _accessToken;
DateTime? _expiresAt;
String? _refreshToken;
@ -174,14 +191,13 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
}
/// Versucht, eine vorhandene Session aus der Secure Storage zu
/// reaktivieren. Liefert `true`, wenn anschließend ein gültiger
/// Access-Token verfügbar ist.
/// reaktivieren.
///
/// Schluckt Fehler aus dem Storage-Plugin (z. B. nicht-registriertes
/// Native-Modul nach Plugin-Update ohne Cold-Restart) — der Caller
/// landet dann sauber im "kein Restore möglich"-Pfad statt mit einer
/// MissingPluginException auf dem AuthBloc-Stream.
Future<bool> restoreSession() async {
Future<SessionRestoreResult> restoreSession() async {
final String? stored;
try {
stored = await _storage.read(key: _refreshTokenStorageKey);
@ -189,9 +205,9 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
debugPrint(
'restoreSession: konnte Refresh-Token nicht lesen: $e\n$st',
);
return false;
return SessionRestoreResult.noSession;
}
if (stored == null || stored.isEmpty) return false;
if (stored == null || stored.isEmpty) return SessionRestoreResult.noSession;
_refreshToken = stored;
final String? token;
@ -199,14 +215,14 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
token = await currentAccessToken();
} on AuthTemporarilyUnavailableException catch (e) {
// Beim App-Start gerade kein Netz/VPN: Refresh-Token NICHT verwerfen —
// der nächste Start (oder Login) kann die Session wieder aufnehmen.
// der nächste Versuch (oder Login) kann die Session wieder aufnehmen.
debugPrint('restoreSession: Refresh vorübergehend nicht möglich: $e');
return false;
return SessionRestoreResult.temporarilyUnavailable;
}
if (token == null) return false;
if (token == null) return SessionRestoreResult.noSession;
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
return true;
return SessionRestoreResult.restored;
}
/// Bricht die Session ab — droppt alle Tokens lokal. Ein
@ -244,9 +260,27 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
// Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt
// einen zweiten `flutter_appauth.token()`-Call zu starten. `??=`
// evaluiert die rechte Seite nur, wenn noch kein Refresh läuft.
return _refreshInFlight ??= _performRefresh(rt).whenComplete(() {
final refresh = _refreshInFlight ??= _performRefresh(rt).whenComplete(() {
_refreshInFlight = null;
});
// Die Dio-Timeouts greifen erst nach diesem Aufruf; ohne eigene Grenze
// hinge jeder Request an einem hängenden Refresh. Nur das Warten endet
// hier: Der native Call läuft weiter und übernimmt seine Tokens, sonst
// ginge ein bereits rotierter Refresh-Token verloren.
try {
return await refresh.timeout(refreshTimeout);
} on TimeoutException catch (e) {
debugPrint('Token-Refresh dauert zu lange: $e');
final stillValid = _accessToken;
final validUntil = _expiresAt;
if (stillValid != null &&
validUntil != null &&
validUntil.isAfter(DateTime.now().toUtc())) {
return stillValid;
}
throw AuthTemporarilyUnavailableException(e);
}
}
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen