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

@ -8,8 +8,10 @@ import 'package:hl_lieferservice/feature/authentication/presentation/login_enfor
import 'package:hl_lieferservice/main.dart' show locator;
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_day_watcher.dart';
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:hl_lieferservice/widget/app_message.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
@ -74,6 +76,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
child: MaterialApp(
title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
theme: ThemeData(snackBarTheme: appSnackBarTheme),
// Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree.
@ -94,7 +97,9 @@ class _DeliveryAppState extends State<DeliveryApp> {
if (state is AppConfigLoaded) {
// Zahlungsmethoden lädt SessionScope nach jeder Anmeldung neu.
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
child: CarSelectionEnforcer(
child: TourDayWatcher(child: Home()),
),
);
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
/// Schwebende SnackBars ordnet der `Scaffold` über `bottomNavigationBar` und
/// `floatingActionButton` an. Feste Bedienelemente gehören deshalb in diese
/// Slots und nicht in den `body`, sonst liegt die Meldung über ihnen.
const SnackBarThemeData appSnackBarTheme = SnackBarThemeData(
behavior: SnackBarBehavior.floating,
);
extension AppMessage on BuildContext {
/// Zeigt [message] als einheitliche Kurzmeldung. Eine noch sichtbare Meldung
/// wird ersetzt, damit sich Meldungen nicht stauen und Buttons nicht
/// minutenlang verdecken.
void showAppMessage(
String message, {
Duration duration = const Duration(seconds: 4),
SnackBarAction? action,
}) {
ScaffoldMessenger.of(this)
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Text(message),
duration: duration,
action: action,
// Flutter ließe Meldungen mit Aktion sonst stehen, bis jemand sie
// antippt oder wegwischt.
persist: false,
),
);
}
}

View File

@ -40,18 +40,13 @@ class OperationBloc extends Bloc<OperationEvent, OperationState> {
) async {
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
// Spinner-Mindestdauer einhalten, BEVOR wir ihn schließen — auch wenn eine
// Erfolgsmeldung folgt. Sonst blitzt der Spinner bei schnellen Requests nur
// einen Frame lang auf (genau der Grund für den vorherigen „kein Spinner").
// Spinner-Mindestdauer einhalten, BEVOR wir ihn schließen. Sonst blitzt der
// Spinner bei schnellen Requests nur einen Frame lang auf (genau der Grund
// für den vorherigen „kein Spinner").
if (_inFlightCount == 0) {
await _awaitMinimumOverlayDuration();
}
if (event.message != null) {
emit(OperationFinished(message: event.message));
await Future.delayed(const Duration(seconds: 5));
}
if (_inFlightCount > 0) {
emit(OperationInProgress());
} else {

View File

@ -12,8 +12,4 @@ class FailOperation extends OperationEvent {
FailOperation({required this.message});
}
class FinishOperation extends OperationEvent {
String? message;
FinishOperation({this.message});
}
class FinishOperation extends OperationEvent {}

View File

@ -13,9 +13,3 @@ class OperationFailed extends OperationState {
OperationFailed({required this.message});
}
class OperationFinished extends OperationState {
String? message;
OperationFinished({this.message});
}

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/widget/app_message.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import '../bloc/operation_state.dart';
/// Listens to [OperationBloc] and shows:
/// - SnackBars for success and error messages.
/// - A SnackBar for error messages. Success needs no message: the changed
/// data is the feedback, and a SnackBar would only cover buttons.
/// - A blocking modal barrier with a spinner while a mutation is in flight,
/// so the user gets unambiguous "wait" feedback and cannot double-tap or
/// navigate away mid-request.
@ -18,16 +20,8 @@ class OperationViewEnforcer extends StatelessWidget {
Widget build(BuildContext context) {
return BlocConsumer<OperationBloc, OperationState>(
listener: (context, state) {
if (state is OperationFinished && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!)),
);
}
if (state is OperationFailed) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
context.showAppMessage(state.message);
}
},
builder: (context, state) {

View File

@ -10,6 +10,7 @@ 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_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/widget/app_message.dart';
/// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte
/// (Auswählen) → Sortieren → Beladen → Ausliefern.
@ -85,13 +86,9 @@ class PhaseStepper extends StatelessWidget {
// Vergleich über die natürliche Enum-Reihenfolge, weil "höchste erreichte
// Phase" cross-Team konsistent ist (1- vs. Mehr-Auto-Teams).
if (target.index > maxReached.index) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Schritt \"${target.displayName}\" ist noch nicht erreicht.",
),
duration: const Duration(seconds: 2),
),
context.showAppMessage(
"Schritt \"${target.displayName}\" ist noch nicht erreicht.",
duration: const Duration(seconds: 2),
);
return;
}
@ -499,17 +496,16 @@ class _ReloadButton extends StatelessWidget {
listener: (context, state) {
if (state is! TourLoaded || state.refreshError == null) return;
final bloc = context.read<TourBloc>();
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(state.refreshError!),
action: SnackBarAction(
label: 'Erneut versuchen',
onPressed: () => bloc.add(const RefreshTour()),
),
),
);
context.showAppMessage(
state.refreshError!,
// Länger als üblich, damit die Aktion erreichbar bleibt. Danach ist
// der Reload-Button im Header weiterhin da.
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: 'Erneut versuchen',
onPressed: () => bloc.add(const RefreshTour()),
),
);
},
// Nur neu rendern, wenn sich der Refresh-Status ändert — sonst
// läuft der Builder bei jedem Scan-Tick mit.