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

@ -29,9 +29,12 @@ class TourBloc extends Bloc<TourEvent, TourState> {
required this.tourRepository,
required this.opBloc,
required this.attachmentCache,
}) : super(const TourInitial()) {
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now,
super(const TourInitial()) {
on<LoadTour>(_onLoad);
on<RefreshTour>(_onRefresh);
on<RefreshTourIfDayChanged>(_onRefreshIfDayChanged);
on<ReorderDeliveries>(_onReorder);
on<AssignCarToDelivery>(_onAssignCar);
on<AssignCarToDeliveries>(_onAssignCarBulk);
@ -67,6 +70,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final Uuid _uuid = const Uuid();
final DateTime Function() _clock;
/// Kalendertag der letzten erfolgreichen Server-Antwort. Absichtlich nicht
/// das Tour-Datum: Das darf vom Gerät abweichen (Server entscheidet über
/// „heute") und würde dann bei jeder Rückkehr neu laden.
DateTime? _loadedDay;
DateTime _today() {
final now = _clock();
return DateTime(now.year, now.month, now.day);
}
/// Aktuell angefragtes Tour-Datum. `null` = „heute" (Server entscheidet).
/// Wird vom [LoadTour]-Event gesetzt und von [RefreshTour] wiederverwendet,
/// damit ein Pull-to-refresh denselben Tag neu lädt (nicht heute).
@ -96,6 +111,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
try {
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
if (seq != _requestSeq) return; // überholt von neuerer Anfrage
_loadedDay = _today();
if (details == null) {
emit(const TourEmpty());
return;
@ -127,6 +143,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
try {
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
if (seq != _requestSeq) return; // überholt von neuerer Anfrage
_loadedDay = _today();
if (details == null) {
emit(const TourEmpty());
return;
@ -147,6 +164,20 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
Future<void> _onRefreshIfDayChanged(
RefreshTourIfDayChanged event,
Emitter<TourState> emit,
) async {
final loadedDay = _loadedDay;
if (loadedDay == null || _selectedDate != null) return;
if (loadedDay == _today()) return;
final current = state;
// Läuft schon ein Laden, liefert es den neuen Tag ohnehin.
if (current is TourLoading) return;
if (current is TourLoaded && current.isRefreshing) return;
await _onRefresh(const RefreshTour(), emit);
}
// ─── ReorderDeliveries ───────────────────────────────────────────────
Future<void> _onReorder(
@ -203,8 +234,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
AssignCarToDelivery event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
if (state is! TourLoaded) return;
opBloc.add(StartOperation());
try {
@ -217,15 +247,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
// Wir bauen die lokale Delivery so um, dass alle Stamm-Felder vom
// Server übernommen werden, Items und sortOrder aus dem lokalen
// Aggregat erhalten bleiben.
final localDelivery = current.details.deliveries
.firstWhere((d) => d.id == event.deliveryId, orElse: () => remoteDelivery);
final merged = remoteDelivery.copyWith(
sortOrder: localDelivery.sortOrder,
items: localDelivery.items,
);
emit(current.copyWith(details: current.details.replaceDelivery(merged)));
_applyRemoteDelivery(emit, remoteDelivery);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.AssignCarToDelivery fehlgeschlagen: $e\n$st');
@ -657,7 +679,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
details: latest.details.copyWith(paymentsByDeliveryId: next),
));
}
opBloc.add(FinishOperation(message: 'Zahlung protokolliert'));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.RecordDeliveryPayment fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
@ -778,7 +800,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
_replaceAmountCreditNotes(details, event.deliveryId, newNote);
emit(latest.copyWith(details: details));
}
opBloc.add(FinishOperation(message: 'Gutschrift gespeichert'));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.SetDeliveryCredit fehlgeschlagen: $e\n$st');
final latest = state;
@ -829,7 +851,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
),
));
}
opBloc.add(FinishOperation(message: 'Gutschrift entfernt'));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.RemoveDeliveryCredit fehlgeschlagen: $e\n$st');
final latest = state;
@ -912,7 +934,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
),
));
}
opBloc.add(FinishOperation(message: 'Checkliste-Eintrag gespeichert'));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.SetDeliveryServiceValue fehlgeschlagen: $e\n$st');
final latest = state;
@ -958,7 +980,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
deliveryId: event.deliveryId,
serviceId: event.serviceId,
);
opBloc.add(FinishOperation(message: 'Checkliste-Eintrag entfernt'));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.RemoveDeliveryServiceValue fehlgeschlagen: $e\n$st');
final latest = state;
@ -1059,7 +1081,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung abbrechen',
apply: () => tourRepository.cancelDelivery(
deliveryId: event.deliveryId,
@ -1074,7 +1095,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung pausieren',
apply: () => tourRepository.holdDelivery(
deliveryId: event.deliveryId,
@ -1089,7 +1109,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung fortsetzen',
apply: () =>
tourRepository.resumeDelivery(deliveryId: event.deliveryId),
@ -1102,7 +1121,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung abschließen',
apply: () => tourRepository.completeDelivery(
deliveryId: event.deliveryId,
@ -1119,6 +1137,26 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
}
/// Mergt die Server-Antwort in den **aktuellen** Stand, nicht in den von vor
/// dem Request: Sonst ginge ein währenddessen fertig gewordenes Neuladen
/// verloren. Ein noch laufendes Neuladen wird verworfen, denn seine Antwort
/// kennt diese Änderung eventuell noch nicht und würde sie zurückdrehen.
void _applyRemoteDelivery(Emitter<TourState> emit, Delivery remote) {
final latest = state;
if (latest is! TourLoaded) return;
++_requestSeq;
final local = latest.details.deliveries
.firstWhere((d) => d.id == remote.id, orElse: () => remote);
final merged = remote.copyWith(
sortOrder: local.sortOrder,
items: local.items,
);
emit(latest.copyWith(
isRefreshing: false,
details: latest.details.replaceDelivery(merged),
));
}
/// Gemeinsamer Pfad für die drei Lifecycle-Operationen: globaler
/// Loading-Overlay an, Server-Call, lokales Aggregat mit der neuen
/// Stamm-Delivery mergen (Items/sortOrder aus dem aktuellen State
@ -1127,25 +1165,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
/// Reason-Dialog reagiert.
Future<void> _runLifecycle({
required Emitter<TourState> emit,
required String deliveryId,
required String operationLabel,
required Future<Delivery> Function() apply,
}) async {
final current = state;
if (current is! TourLoaded) return;
if (state is! TourLoaded) return;
opBloc.add(StartOperation());
try {
final remote = await apply();
final local = current.details.deliveries
.firstWhere((d) => d.id == deliveryId, orElse: () => remote);
final merged = remote.copyWith(
sortOrder: local.sortOrder,
items: local.items,
);
emit(current.copyWith(
details: current.details.replaceDelivery(merged),
));
_applyRemoteDelivery(emit, await apply());
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.$operationLabel fehlgeschlagen: $e\n$st');

View File

@ -38,6 +38,15 @@ class RefreshTour extends TourEvent {
const RefreshTour();
}
/// Lädt die Tour neu, wenn seit dem letzten Laden der Tag gewechselt hat und
/// „heute" angezeigt wird. Eine bewusst im Kalender gewählte Tour bleibt.
///
/// Auslöser ist die Rückkehr in die Tour-Ansicht (siehe `TourDayWatcher`):
/// Die App bleibt oft über Nacht geöffnet und zeigte sonst die Tour von gestern.
class RefreshTourIfDayChanged extends TourEvent {
const RefreshTourIfDayChanged();
}
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour an das
/// Backend. Der Bloc fordert die UI nicht auf, die vollständige Reihenfolge
/// zu kennen — sie wird aus der Liste übergeben.

View File

@ -54,7 +54,8 @@ class TourLoaded extends TourState {
/// hängen und wird beim nächsten Reload geleert.
final String? refreshError;
/// Fehler des letzten Reorder-Persist-Versuchs.
/// Fehler des letzten Reorder-Persist-Versuchs. Steuert nur den Ablauf der
/// Sortier-Page; angezeigt wird er über `FailOperation`.
final String? reorderError;
TourLoaded copyWith({