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:
40
lib/data/network/api_failure_text.dart
Normal file
40
lib/data/network/api_failure_text.dart
Normal file
@ -0,0 +1,40 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Übersetzt eine [DioException] in einen Fehlertext für den Fahrer.
|
||||
///
|
||||
/// Die `message` des Backends ist für Entwickler gedacht („validation:
|
||||
/// delivery is not active; cannot complete") und erreicht die UI deshalb nie
|
||||
/// direkt: [byServerText] ordnet bekannten Textbausteinen eine verständliche
|
||||
/// Meldung zu, alles andere bekommt einen allgemeinen Text. Das Detail landet
|
||||
/// im Log.
|
||||
String describeApiFailure(
|
||||
DioException e, {
|
||||
required String operation,
|
||||
Map<int, String> byStatus = const {},
|
||||
Map<String, String> byServerText = const {},
|
||||
}) {
|
||||
final status = e.response?.statusCode;
|
||||
final body = e.response?.data;
|
||||
final serverText = body is Map ? body['message']?.toString() : null;
|
||||
debugPrint('$operation fehlgeschlagen: HTTP $status, ${serverText ?? e.type}');
|
||||
|
||||
if (serverText != null) {
|
||||
for (final entry in byServerText.entries) {
|
||||
if (serverText.contains(entry.key)) return entry.value;
|
||||
}
|
||||
}
|
||||
final known = byStatus[status];
|
||||
if (known != null) return known;
|
||||
if (status == 401) return 'Sitzung abgelaufen';
|
||||
if (status == null) {
|
||||
return switch (e.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'Der Server antwortet nicht. Bitte Verbindung prüfen',
|
||||
_ => 'Server nicht erreichbar. Bitte Verbindung prüfen',
|
||||
};
|
||||
}
|
||||
return '$operation fehlgeschlagen. Bitte erneut versuchen';
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:holzleitner_api/holzleitner_api.dart' as api;
|
||||
|
||||
import 'package:hl_lieferservice/data/mapper/car_mapper.dart';
|
||||
import 'package:hl_lieferservice/data/network/api_failure_text.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/car.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
|
||||
|
||||
@ -25,7 +26,12 @@ class CarsRepositoryImpl implements CarsRepository {
|
||||
includeInactive: includeInactive,
|
||||
);
|
||||
final cars = response.data?.cars;
|
||||
if (cars == null) return const [];
|
||||
// Eine leere Antwort ist ein Fehler, nicht „keine Fahrzeuge".
|
||||
if (cars == null) {
|
||||
throw const CarsRepositoryException(
|
||||
'Fahrzeuge konnten nicht geladen werden. Bitte erneut versuchen',
|
||||
);
|
||||
}
|
||||
return cars.toDomainList();
|
||||
} on DioException catch (e) {
|
||||
throw CarsRepositoryException(_describe(e, 'Laden der Fahrzeuge'), e);
|
||||
@ -83,14 +89,13 @@ class CarsRepositoryImpl implements CarsRepository {
|
||||
|
||||
/// Übersetzt eine DioException in eine kompakte, UI-taugliche
|
||||
/// Begründung. Mehr Detail landet im Log, nicht in der Snackbar.
|
||||
String _describe(DioException e, String operation) {
|
||||
final status = e.response?.statusCode;
|
||||
final body = e.response?.data;
|
||||
if (status == 400 && body is Map && body['message'] != null) {
|
||||
return '$operation fehlgeschlagen: ${body['message']}';
|
||||
}
|
||||
if (status == 401) return 'Sitzung abgelaufen';
|
||||
if (status == 404) return 'Fahrzeug nicht gefunden';
|
||||
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
|
||||
}
|
||||
String _describe(DioException e, String operation) => describeApiFailure(
|
||||
e,
|
||||
operation: operation,
|
||||
byStatus: const {404: 'Fahrzeug nicht gefunden'},
|
||||
byServerText: const {
|
||||
'existiert bereits': 'Dieses Kennzeichen ist bereits vergeben',
|
||||
'plate darf nicht leer': 'Bitte ein Kennzeichen eingeben',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:holzleitner_api/holzleitner_api.dart' as api;
|
||||
|
||||
import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
|
||||
import 'package:hl_lieferservice/data/network/api_failure_text.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart';
|
||||
|
||||
@ -25,7 +26,12 @@ class PaymentMethodsRepositoryImpl implements PaymentMethodsRepository {
|
||||
.getPaymentMethodsApi()
|
||||
.listPaymentMethods(includeInactive: includeInactive);
|
||||
final methods = response.data?.methods;
|
||||
if (methods == null) return const [];
|
||||
// Eine leere Antwort ist ein Fehler, nicht „keine Zahlungsmethoden".
|
||||
if (methods == null) {
|
||||
throw const PaymentMethodsRepositoryException(
|
||||
'Zahlungsmethoden konnten nicht geladen werden. Bitte erneut versuchen',
|
||||
);
|
||||
}
|
||||
return methods.map((m) => m.toDomain()).toList(growable: false);
|
||||
} on DioException catch (e) {
|
||||
throw PaymentMethodsRepositoryException(
|
||||
@ -107,19 +113,15 @@ class PaymentMethodsRepositoryImpl implements PaymentMethodsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
String _describe(DioException e, String operation) {
|
||||
final status = e.response?.statusCode;
|
||||
final body = e.response?.data;
|
||||
if ((status == 400 || status == 409) &&
|
||||
body is Map &&
|
||||
body['message'] != null) {
|
||||
return body['message'].toString();
|
||||
}
|
||||
if (status == 409) {
|
||||
return 'Zahlungsmethode wird noch von Lieferungen verwendet';
|
||||
}
|
||||
if (status == 404) return 'Zahlungsmethode nicht gefunden';
|
||||
if (status == 401) return 'Sitzung abgelaufen';
|
||||
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
|
||||
}
|
||||
String _describe(DioException e, String operation) => describeApiFailure(
|
||||
e,
|
||||
operation: operation,
|
||||
byStatus: const {
|
||||
404: 'Zahlungsmethode nicht gefunden',
|
||||
409: 'Zahlungsmethode wird noch von Lieferungen verwendet',
|
||||
},
|
||||
byServerText: const {
|
||||
'already exists': 'Diese Zahlungsmethode gibt es bereits',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:holzleitner_api/holzleitner_api.dart' as api;
|
||||
|
||||
import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
|
||||
import 'package:hl_lieferservice/data/network/api_failure_text.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/address.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
|
||||
@ -30,7 +31,13 @@ class TourRepositoryImpl implements TourRepository {
|
||||
final response =
|
||||
await _api.getToursApi().listMyTours(date: _formatDate(date));
|
||||
final tours = response.data?.tours;
|
||||
if (tours == null || tours.isEmpty) return null;
|
||||
// Eine leere Antwort ist ein Fehler, nicht „heute keine Tour".
|
||||
if (tours == null) {
|
||||
throw const TourRepositoryException(
|
||||
'Tour konnte nicht geladen werden. Bitte erneut versuchen',
|
||||
);
|
||||
}
|
||||
if (tours.isEmpty) return null;
|
||||
// Backend liefert die Liste sortiert; wir nehmen die erste Tour.
|
||||
// Der Fahrer hat aktuell nur eine Tour pro Tag — falls sich das
|
||||
// ändert, wird hier eine Auswahl-UI nötig.
|
||||
@ -600,15 +607,22 @@ class TourRepositoryImpl implements TourRepository {
|
||||
}
|
||||
}
|
||||
|
||||
String _describe(DioException e, String operation) {
|
||||
final status = e.response?.statusCode;
|
||||
final body = e.response?.data;
|
||||
if (status == 400 && body is Map && body['message'] != null) {
|
||||
return '$operation fehlgeschlagen: ${body['message']}';
|
||||
}
|
||||
if (status == 401) return 'Sitzung abgelaufen';
|
||||
if (status == 403) return 'Keine Berechtigung';
|
||||
if (status == 404) return 'Tour oder Lieferung nicht gefunden';
|
||||
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
|
||||
}
|
||||
String _describe(DioException e, String operation) => describeApiFailure(
|
||||
e,
|
||||
operation: operation,
|
||||
byStatus: const {
|
||||
403: 'Keine Berechtigung',
|
||||
404: 'Tour oder Lieferung nicht gefunden',
|
||||
},
|
||||
byServerText: const {
|
||||
'delivery is not active': 'Die Lieferung ist nicht mehr aktiv',
|
||||
'delivery already completed': 'Die Lieferung ist bereits abgeschlossen',
|
||||
'not yet done': 'Es sind noch nicht alle Artikel gescannt',
|
||||
'offener Betrag hat sich geändert':
|
||||
'Der offene Betrag hat sich geändert. Bitte die Tour aktualisieren',
|
||||
'kein offener Betrag': 'Für diese Lieferung ist kein Betrag offen',
|
||||
'nicht als kassiert bestätigt':
|
||||
'Der offene Betrag wurde noch nicht als kassiert bestätigt',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user