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

@ -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',
},
);
}

View File

@ -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',
},
);
}

View File

@ -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',
},
);
}