From bc95fe9577774244154b487b2f00c6b41a5489f3 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 30 Oct 2026 20:28:24 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20Fehlerbehandlung=20beim=20Laden=20und=20?= =?UTF-8?q?Meldungen=20=C3=BCberarbeiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ios/Podfile.lock | 24 + lib/data/network/api_failure_text.dart | 40 ++ .../network/keycloak_oidc_token_provider.dart | 54 +- lib/data/repository/cars_repository_impl.dart | 27 +- .../payment_methods_repository_impl.dart | 34 +- lib/data/repository/tour_repository_impl.dart | 38 +- .../authentication/bloc/auth_bloc.dart | 23 +- .../authentication/bloc/auth_state.dart | 11 +- .../presentation/login_enforcer.dart | 1 + .../presentation/login_page.dart | 27 + .../presentation/car_selection_enforcer.dart | 13 +- .../presentation/car_selection_page.dart | 30 +- lib/feature/cars/bloc/cars_bloc.dart | 6 +- .../presentation/car_management_page.dart | 76 +-- lib/feature/delivery/bloc/tour_bloc.dart | 95 ++- lib/feature/delivery/bloc/tour_event.dart | 9 + lib/feature/delivery/bloc/tour_state.dart | 3 +- .../presentation/delivery_detail_page.dart | 92 ++- .../detail/presentation/steps/step_info.dart | 13 +- .../detail/presentation/steps/step_notes.dart | 110 +-- .../presentation/steps/step_payment.dart | 67 +- .../presentation/delivery_fail_page.dart | 12 + .../presentation/delivery_sort_page.dart | 16 +- .../presentation/tour_day_watcher.dart | 58 ++ .../tour_not_loaded_scaffold.dart | 117 ++++ .../widget/sortable_delivery_list.dart | 2 +- .../filiale_pickup_scan_page.dart | 30 +- .../presentation/loading_customer_page.dart | 13 +- .../settings/presentation/settings_page.dart | 7 +- lib/widget/app.dart | 7 +- lib/widget/app_message.dart | 32 + .../operations/bloc/operation_bloc.dart | 11 +- .../operations/bloc/operation_event.dart | 6 +- .../operations/bloc/operation_state.dart | 6 - .../presentation/operation_view_enforcer.dart | 14 +- lib/widget/phase_stepper/phase_stepper.dart | 32 +- test/api_failure_text_test.dart | 91 +++ test/app_message_test.dart | 325 +++++++++ test/load_failure_test.dart | 642 ++++++++++++++++++ test/tour_day_change_test.dart | 214 ++++++ 40 files changed, 2063 insertions(+), 365 deletions(-) create mode 100644 lib/data/network/api_failure_text.dart create mode 100644 lib/feature/delivery/overview/presentation/tour_day_watcher.dart create mode 100644 lib/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart create mode 100644 lib/widget/app_message.dart create mode 100644 test/api_failure_text_test.dart create mode 100644 test/app_message_test.dart create mode 100644 test/load_failure_test.dart create mode 100644 test/tour_day_change_test.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4b8d8ee..ae1cdf2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,7 +1,18 @@ PODS: - app_links (6.4.1): - Flutter + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core - Flutter (1.0.0) + - flutter_appauth (0.0.1): + - AppAuth (= 2.0.0) + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter - geolocator_apple (1.2.0): - Flutter - FlutterMacOS @@ -24,6 +35,8 @@ PODS: DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - Flutter (from `Flutter`) + - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) @@ -32,11 +45,19 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - AppAuth + EXTERNAL SOURCES: app_links: :path: ".symlinks/plugins/app_links/ios" Flutter: :path: Flutter + flutter_appauth: + :path: ".symlinks/plugins/flutter_appauth/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" image_picker_ios: @@ -54,7 +75,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 diff --git a/lib/data/network/api_failure_text.dart b/lib/data/network/api_failure_text.dart new file mode 100644 index 0000000..4421d82 --- /dev/null +++ b/lib/data/network/api_failure_text.dart @@ -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 byStatus = const {}, + Map 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'; +} diff --git a/lib/data/network/keycloak_oidc_token_provider.dart b/lib/data/network/keycloak_oidc_token_provider.dart index e866825..be04a4b 100644 --- a/lib/data/network/keycloak_oidc_token_provider.dart +++ b/lib/data/network/keycloak_oidc_token_provider.dart @@ -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 restoreSession() async { + Future 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 {})); - 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 diff --git a/lib/data/repository/cars_repository_impl.dart b/lib/data/repository/cars_repository_impl.dart index 0cdaebb..36c57aa 100644 --- a/lib/data/repository/cars_repository_impl.dart +++ b/lib/data/repository/cars_repository_impl.dart @@ -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', + }, + ); } diff --git a/lib/data/repository/payment_methods_repository_impl.dart b/lib/data/repository/payment_methods_repository_impl.dart index ecee445..1b60a8c 100644 --- a/lib/data/repository/payment_methods_repository_impl.dart +++ b/lib/data/repository/payment_methods_repository_impl.dart @@ -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', + }, + ); } diff --git a/lib/data/repository/tour_repository_impl.dart b/lib/data/repository/tour_repository_impl.dart index 7f3c5db..c87b8d2 100644 --- a/lib/data/repository/tour_repository_impl.dart +++ b/lib/data/repository/tour_repository_impl.dart @@ -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', + }, + ); } diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index 0730ccb..64be2d3 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -105,6 +105,8 @@ class AuthBloc extends Bloc { RestoreSessionRequested event, Emitter emit, ) async { + // Auch für „Erneut versuchen" von der LoginPage: wieder der Splash. + emit(AuthBootstrapping()); try { // Timeout-Schutz: hängt der Restore (z. B. nativer flutter_appauth- // Token-Call nach Hot-Restart, nicht erreichbarer Issuer), darf der @@ -112,18 +114,21 @@ class AuthBloc extends Bloc { // sauber auf die LoginPage zurück. Läuft der Restore später doch noch // erfolgreich durch, kommt der Login via Stream-Event (AuthLoggedIn) // nachträglich an und der State wird zu Authenticated. - final restored = await tokenProvider.restoreSession().timeout( + final result = await tokenProvider.restoreSession().timeout( const Duration(seconds: 15), - onTimeout: () => false, + onTimeout: () => SessionRestoreResult.temporarilyUnavailable, ); - if (!restored) { - // Kein gespeicherter Refresh-Token oder Refresh fehlgeschlagen: - // Vom Splash zur LoginPage übergehen. Kein Snackbar — das ist - // der normale Cold-Start-Pfad. - emit(Unauthenticated()); + switch (result) { + case SessionRestoreResult.restored: + // Erfolg landet via Stream als ProviderSessionChanged.loggedIn, + // der Handler emittiert Authenticated. + break; + case SessionRestoreResult.noSession: + // Normaler Cold-Start-Pfad, daher kein Hinweis. + emit(Unauthenticated()); + case SessionRestoreResult.temporarilyUnavailable: + emit(Unauthenticated(restoreUnavailable: true)); } - // Erfolg landet via Stream als ProviderSessionChanged.loggedIn, - // der Handler emittiert Authenticated. } catch (err, st) { debugPrint('Restore-Session fehlgeschlagen: $err\n$st'); emit(Unauthenticated()); diff --git a/lib/feature/authentication/bloc/auth_state.dart b/lib/feature/authentication/bloc/auth_state.dart index 4a91228..cd4596f 100644 --- a/lib/feature/authentication/bloc/auth_state.dart +++ b/lib/feature/authentication/bloc/auth_state.dart @@ -17,7 +17,16 @@ class Unauthenticated extends AuthState { /// blendet daraufhin einen Hinweis ein. final bool loginTimedOut; - Unauthenticated({this.sessionExpired = false, this.loginTimedOut = false}); + /// `true`, wenn beim App-Start eine Session gespeichert ist, sie sich wegen + /// Netz/VPN aber nicht erneuern ließ. Die [LoginPage] bietet dann „Erneut + /// versuchen" an, statt nach einem Login auszusehen, den es nicht braucht. + final bool restoreUnavailable; + + Unauthenticated({ + this.sessionExpired = false, + this.loginTimedOut = false, + this.restoreUnavailable = false, + }); } /// Transient state während dem PKCE-Flow (Browser-Tab offen, diff --git a/lib/feature/authentication/presentation/login_enforcer.dart b/lib/feature/authentication/presentation/login_enforcer.dart index 5fca2f5..48419aa 100644 --- a/lib/feature/authentication/presentation/login_enforcer.dart +++ b/lib/feature/authentication/presentation/login_enforcer.dart @@ -32,6 +32,7 @@ class LoginEnforcer extends StatelessWidget { return LoginPage( sessionExpired: unauth?.sessionExpired ?? false, loginTimedOut: unauth?.loginTimedOut ?? false, + restoreUnavailable: unauth?.restoreUnavailable ?? false, ); }, ); diff --git a/lib/feature/authentication/presentation/login_page.dart b/lib/feature/authentication/presentation/login_page.dart index a529ca7..efe5628 100644 --- a/lib/feature/authentication/presentation/login_page.dart +++ b/lib/feature/authentication/presentation/login_page.dart @@ -19,10 +19,16 @@ class LoginPage extends StatelessWidget { super.key, this.sessionExpired = false, this.loginTimedOut = false, + this.restoreUnavailable = false, }); final bool sessionExpired; + /// Hinweis, dass die gespeicherte Anmeldung wegen der Verbindung gerade + /// nicht erneuert werden konnte. Wird aus + /// `Unauthenticated.restoreUnavailable` gefüttert. + final bool restoreUnavailable; + /// Hinweis-Banner, dass der vorherige Login-Versuch ins 10-s-Timeout /// gelaufen ist (typisch: Verbindungsabbruch oder hängender Issuer). /// Wird vom `LoginEnforcer` aus `Unauthenticated.loginTimedOut` gefüttert. @@ -66,6 +72,27 @@ class LoginPage extends StatelessWidget { ), actions: const [SizedBox.shrink()], ), + if (restoreUnavailable) + MaterialBanner( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + content: const Text( + 'Keine Verbindung zum Server. Ihre Anmeldung bleibt gespeichert.', + style: TextStyle(color: Colors.white), + ), + backgroundColor: Colors.orange.shade800, + leading: const Icon(Icons.cloud_off, color: Colors.white), + actions: [ + TextButton( + onPressed: () => context.read().add( + const RestoreSessionRequested(), + ), + child: const Text( + 'Erneut versuchen', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), if (loginTimedOut) MaterialBanner( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), diff --git a/lib/feature/car_selection/presentation/car_selection_enforcer.dart b/lib/feature/car_selection/presentation/car_selection_enforcer.dart index 7022710..0f052de 100644 --- a/lib/feature/car_selection/presentation/car_selection_enforcer.dart +++ b/lib/feature/car_selection/presentation/car_selection_enforcer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart'; @@ -55,7 +56,8 @@ class _CarSelectionEnforcerState extends State { ), const SizedBox(height: 24), const Text( - "Fehler beim Laden der Fahrzeugauswahl.", + "Fehler beim Laden der Fahrzeugauswahl. " + "Bitte Verbindung prüfen.", textAlign: TextAlign.center, ), const SizedBox(height: 16), @@ -70,6 +72,15 @@ class _CarSelectionEnforcerState extends State { }, child: const Text("Erneut versuchen"), ), + const SizedBox(height: 8), + // Hier gibt es kein Menü: Ohne diesen Button käme ein + // Fahrer ohne Netz nicht mehr aus dem Konto heraus. + TextButton( + onPressed: () => context.read().add( + const LogoutRequested(), + ), + child: const Text("Abmelden"), + ), ], ), ), diff --git a/lib/feature/car_selection/presentation/car_selection_page.dart b/lib/feature/car_selection/presentation/car_selection_page.dart index 8d70778..1ec78df 100644 --- a/lib/feature/car_selection/presentation/car_selection_page.dart +++ b/lib/feature/car_selection/presentation/car_selection_page.dart @@ -12,6 +12,7 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_dialog.dart'; import 'package:hl_lieferservice/domain/entity/car.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; class CarSelectionPage extends StatefulWidget { /// When set, the page is in "change" mode: the car is pre-highlighted @@ -118,11 +119,7 @@ class _CarSelectionPageState extends State { return BlocListener( listener: (context, state) { if (state is CarSelectFailed) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Fehler beim Speichern der Fahrzeugauswahl."), - ), - ); + context.showAppMessage("Fehler beim Speichern der Fahrzeugauswahl."); } }, child: Scaffold( @@ -216,19 +213,22 @@ class _CarSelectionPageState extends State { }, ), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _selectedCar != null ? _onConfirm : null, - child: const Text("Auswählen"), - ), - ), - ), ], ), ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _selectedCar != null ? _onConfirm : null, + child: const Text("Auswählen"), + ), + ), + ), + ), ), ); } diff --git a/lib/feature/cars/bloc/cars_bloc.dart b/lib/feature/cars/bloc/cars_bloc.dart index b5c2c13..8c16458 100644 --- a/lib/feature/cars/bloc/cars_bloc.dart +++ b/lib/feature/cars/bloc/cars_bloc.dart @@ -49,7 +49,7 @@ class CarsBloc extends Bloc { if (current is CarsLoaded) { emit(current.copyWith(cars: [...current.cars, created])); } - opBloc.add(FinishOperation(message: "Fahrzeug hinzugefügt")); + opBloc.add(FinishOperation()); } on CarsRepositoryException catch (e) { opBloc.add(FailOperation(message: e.message)); } catch (_) { @@ -69,7 +69,7 @@ class CarsBloc extends Bloc { active: event.active, ); _emitReplaced(current, updated, emit); - opBloc.add(FinishOperation(message: "Fahrzeug aktualisiert")); + opBloc.add(FinishOperation()); } on CarsRepositoryException catch (e) { opBloc.add(FailOperation(message: e.message)); } catch (_) { @@ -98,7 +98,7 @@ class CarsBloc extends Bloc { ), ); } - opBloc.add(FinishOperation(message: "Fahrzeug deaktiviert")); + opBloc.add(FinishOperation()); } on CarsRepositoryException catch (e) { opBloc.add(FailOperation(message: e.message)); } catch (_) { diff --git a/lib/feature/cars/presentation/car_management_page.dart b/lib/feature/cars/presentation/car_management_page.dart index 39b66fe..83d61fc 100644 --- a/lib/feature/cars/presentation/car_management_page.dart +++ b/lib/feature/cars/presentation/car_management_page.dart @@ -8,6 +8,7 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_fail_page.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_management.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; class CarManagementPage extends StatefulWidget { const CarManagementPage({super.key}); @@ -39,15 +40,10 @@ class _CarManagementPageState extends State { final carSelectState = context.read().state; if (carSelectState is CarSelectComplete && carSelectState.selectedCar.id == carId) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Dieses Fahrzeug ist aktuell ausgewählt und kann nicht " - "deaktiviert werden. Bitte wähle zuerst ein anderes Fahrzeug " - "aus.", - ), - duration: Duration(seconds: 4), - ), + context.showAppMessage( + "Dieses Fahrzeug ist aktuell ausgewählt und kann nicht " + "deaktiviert werden. Bitte wähle zuerst ein anderes Fahrzeug " + "aus.", ); return; } @@ -65,36 +61,38 @@ class _CarManagementPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: BlocBuilder( - builder: (context, state) { - if (state is CarsLoading || state is CarsInitial) { - return const Center(child: CircularProgressIndicator()); - } - if (state is CarsLoaded) { - return BlocBuilder( - builder: (context, selectState) { - final String? selectedCarId = - selectState is CarSelectComplete - ? selectState.selectedCar.id - : null; - return CarManagementOverview( - cars: state.cars, - selectedCarId: selectedCarId, - onEdit: _edit, - onAdd: _add, - onDelete: _remove, - onRefresh: _refresh, - ); - }, - ); - } - if (state is CarsLoadingFailed) { - return const CarsLoadingFailedPage(); - } - return const SizedBox.shrink(); - }, - ), + return BlocBuilder( + builder: (context, state) { + if (state is CarsLoaded) { + // Bringt seinen eigenen Scaffold samt Plus-Button mit. Ein äußerer + // Scaffold würde die SnackBars anzeigen, ohne den Button zu kennen. + return BlocBuilder( + builder: (context, selectState) { + final String? selectedCarId = + selectState is CarSelectComplete + ? selectState.selectedCar.id + : null; + return CarManagementOverview( + cars: state.cars, + selectedCarId: selectedCarId, + onEdit: _edit, + onAdd: _add, + onDelete: _remove, + onRefresh: _refresh, + ); + }, + ); + } + if (state is CarsLoadingFailed) { + return const Scaffold(body: CarsLoadingFailedPage()); + } + if (state is CarsLoading || state is CarsInitial) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return const Scaffold(body: SizedBox.shrink()); + }, ); } } diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index 6662d53..57d377a 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -29,9 +29,12 @@ class TourBloc extends Bloc { required this.tourRepository, required this.opBloc, required this.attachmentCache, - }) : super(const TourInitial()) { + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now, + super(const TourInitial()) { on(_onLoad); on(_onRefresh); + on(_onRefreshIfDayChanged); on(_onReorder); on(_onAssignCar); on(_onAssignCarBulk); @@ -67,6 +70,18 @@ class TourBloc extends Bloc { 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 { 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 { 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 { } } + Future _onRefreshIfDayChanged( + RefreshTourIfDayChanged event, + Emitter 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 _onReorder( @@ -203,8 +234,7 @@ class TourBloc extends Bloc { AssignCarToDelivery event, Emitter 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 { // 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 { 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 { _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 { ), )); } - 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 { ), )); } - 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 { 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 { ) 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 { ) 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 { ) 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 { ) 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 { ); } + /// 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 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 { /// Reason-Dialog reagiert. Future _runLifecycle({ required Emitter emit, - required String deliveryId, required String operationLabel, required Future 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'); diff --git a/lib/feature/delivery/bloc/tour_event.dart b/lib/feature/delivery/bloc/tour_event.dart index 195e103..6154f4c 100644 --- a/lib/feature/delivery/bloc/tour_event.dart +++ b/lib/feature/delivery/bloc/tour_event.dart @@ -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. diff --git a/lib/feature/delivery/bloc/tour_state.dart b/lib/feature/delivery/bloc/tour_state.dart index c870ff1..8f5b073 100644 --- a/lib/feature/delivery/bloc/tour_state.dart +++ b/lib/feature/delivery/bloc/tour_state.dart @@ -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({ diff --git a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart index a08b097..a7f98ad 100644 --- a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart +++ b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart @@ -17,6 +17,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_payment.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_services.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart'; // ───────────────────────────────────────────────────────────────────────── // Backend-TODOs (siehe Roadmap unten in diesem File) @@ -83,7 +84,11 @@ class _DeliveryDetailScaffold extends StatefulWidget { _DeliveryDetailScaffoldState(); } -class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> { +class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> + with DeliveryPresenceGuard { + @override + String get guardedDeliveryId => widget.deliveryId; + /// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann /// poppen wir bei `completed` automatisch zurück zur Übersicht. Öffnet der /// Fahrer eine bereits abgeschlossene Lieferung, bleibt `_armed == false` @@ -110,9 +115,8 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> { /// Nach erfolgreichem Abschluss (aktiv → completed) zurück zur Übersicht. void _onTourState(BuildContext context, TourState state) { - if (_popped || state is! TourLoaded) return; - final d = _findDelivery(state.details); - if (d == null) return; + if (_popped || !deliveryStillPresent(context, state)) return; + final d = _findDelivery((state as TourLoaded).details)!; if (d.state == DeliveryState.active) { _armed = true; } else if (_armed && d.state == DeliveryState.completed) { @@ -129,41 +133,67 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> { child: BlocBuilder( builder: (context, tourState) { if (tourState is! TourLoaded) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return TourNotLoadedScaffold(title: 'Lieferung', state: tourState); } final details = tourState.details; final delivery = _findDelivery(details); if (delivery == null) { - return Scaffold( - appBar: AppBar(title: const Text('Lieferung')), - body: Center( - child: Text( - 'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.', - ), - ), - ); + // Der Guard lädt gerade neu oder das Neuladen ist gescheitert. + return TourNotLoadedScaffold(title: 'Lieferung', state: tourState); } // Titel = Empfänger vor Ort, wie in den Listen-Kacheln. final recipient = details.recipientOf(delivery); - return Scaffold( - appBar: AppBar( - backgroundColor: theme.primaryColor, - foregroundColor: theme.colorScheme.onPrimary, - title: Text(recipient.name), - ), - body: Column( - children: [ - _StepHeader(delivery: delivery, details: details), - const Divider(height: 1), - Expanded( - child: _StepBody(delivery: delivery, details: details), + // Feste Bedienelemente liegen in den Scaffold-Slots: Nur die kennt + // der Scaffold und zeigt SnackBars darüber statt darauf. + return BlocSelector< + DeliveryWorkflowBloc, + DeliveryWorkflowState, + WorkflowStep + >( + selector: (state) => state.step, + builder: (context, step) { + return Scaffold( + appBar: AppBar( + backgroundColor: theme.primaryColor, + foregroundColor: theme.colorScheme.onPrimary, + title: Text(recipient.name), ), - const Divider(height: 1), - _BottomNav(delivery: delivery, details: details), - ], - ), + body: Column( + children: [ + _StepHeader(delivery: delivery, details: details), + const Divider(height: 1), + Expanded( + child: _StepBody(delivery: delivery, details: details), + ), + ], + ), + // Nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt. + floatingActionButton: + step == WorkflowStep.notes && + delivery.state == DeliveryState.active + ? StepNotesAddButton(delivery: delivery) + : null, + // Das Padding hält die Leiste über der Tastatur (Eingabefelder + // im Step „Services"), was der Slot von sich aus nicht tut. + bottomNavigationBar: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (step == WorkflowStep.payment) + StepPaymentCollectPanel( + delivery: delivery, + details: details, + ), + const Divider(height: 1), + _BottomNav(delivery: delivery, details: details), + ], + ), + ), + ); + }, ); }, ), diff --git a/lib/feature/delivery/detail/presentation/steps/step_info.dart b/lib/feature/delivery/detail/presentation/steps/step_info.dart index 19ee519..5f07a80 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_info.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_info.dart @@ -15,6 +15,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart'; import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; /// Step 1 — Informationen zur Lieferung. /// @@ -342,9 +343,7 @@ class _QuickActions extends StatelessWidget { Future _call(BuildContext context, String phone) async { final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Anruf konnte nicht gestartet werden: $phone')), - ); + context.showAppMessage('Anruf konnte nicht gestartet werden: $phone'); } } @@ -666,9 +665,7 @@ class _CustomerCard extends StatelessWidget { Future _call(BuildContext context, String phone) async { final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Anruf konnte nicht gestartet werden: $phone')), - ); + context.showAppMessage('Anruf konnte nicht gestartet werden: $phone'); } } @@ -1075,9 +1072,7 @@ class _ArticleRow extends StatelessWidget { Future _launchContact(BuildContext context, Uri uri, String label) async { final ok = await launchUrl(uri); if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$label konnte nicht geöffnet werden')), - ); + context.showAppMessage('$label konnte nicht geöffnet werden'); } } diff --git a/lib/feature/delivery/detail/presentation/steps/step_notes.dart b/lib/feature/delivery/detail/presentation/steps/step_notes.dart index 9396ec3..6b095f8 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_notes.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_notes.dart @@ -29,6 +29,58 @@ class StepNotes extends StatelessWidget { final Delivery delivery; final TourDetails details; + @override + Widget build(BuildContext context) { + final notes = details.notesOf(delivery.id); + // Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die + // Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie + // sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen. + final active = delivery.state == DeliveryState.active; + // Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen. + final textNotes = + notes.where((n) => n.imageAttachment == null).toList(growable: false); + final photoNotes = + notes.where((n) => n.imageAttachment != null).toList(growable: false); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + children: [ + if (!active) ...[ + const _ReadOnlyBanner(), + const SizedBox(height: 16), + ], + _SectionHeader(text: 'Notizen (${textNotes.length})'), + const SizedBox(height: 8), + if (textNotes.isEmpty) + const _EmptyHint( + icon: Icons.notes, + text: 'Noch keine Notizen erfasst.', + ) + else + for (final n in textNotes) + _NoteCard(note: n, deliveryId: delivery.id, active: active), + const SizedBox(height: 24), + _SectionHeader(text: 'Fotos (${photoNotes.length})'), + const SizedBox(height: 8), + if (photoNotes.isEmpty) + const _EmptyHint( + icon: Icons.photo_camera_outlined, + text: 'Noch keine Fotos aufgenommen.', + ) + else + for (final n in photoNotes) + _PhotoCard(note: n, deliveryId: delivery.id, active: active), + ], + ); + } +} + +/// „Hinzufügen"-Button des Notizen-Steps. Sitzt im `floatingActionButton`-Slot +/// der Detail-Page, damit SnackBars ihn nicht verdecken. +class StepNotesAddButton extends StatelessWidget { + const StepNotesAddButton({super.key, required this.delivery}); + + final Delivery delivery; + void _openAddNoteDialog(BuildContext context) { final tourBloc = context.read(); showDialog( @@ -81,61 +133,9 @@ class StepNotes extends StatelessWidget { @override Widget build(BuildContext context) { - final notes = details.notesOf(delivery.id); - // Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die - // Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie - // sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen. - final active = delivery.state == DeliveryState.active; - // Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen. - final textNotes = - notes.where((n) => n.imageAttachment == null).toList(growable: false); - final photoNotes = - notes.where((n) => n.imageAttachment != null).toList(growable: false); - return Stack( - children: [ - ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - children: [ - if (!active) ...[ - const _ReadOnlyBanner(), - const SizedBox(height: 16), - ], - _SectionHeader(text: 'Notizen (${textNotes.length})'), - const SizedBox(height: 8), - if (textNotes.isEmpty) - const _EmptyHint( - icon: Icons.notes, - text: 'Noch keine Notizen erfasst.', - ) - else - for (final n in textNotes) - _NoteCard(note: n, deliveryId: delivery.id, active: active), - const SizedBox(height: 24), - _SectionHeader(text: 'Fotos (${photoNotes.length})'), - const SizedBox(height: 8), - if (photoNotes.isEmpty) - const _EmptyHint( - icon: Icons.photo_camera_outlined, - text: 'Noch keine Fotos aufgenommen.', - ) - else - for (final n in photoNotes) - _PhotoCard(note: n, deliveryId: delivery.id, active: active), - ], - ), - // FAB nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt. - if (active) - Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.all(16), - child: _AddMenu( - onAddNote: () => _openAddNoteDialog(context), - onAddImage: () => _pickImage(context), - ), - ), - ), - ], + return _AddMenu( + onAddNote: () => _openAddNoteDialog(context), + onAddImage: () => _pickImage(context), ); } } diff --git a/lib/feature/delivery/detail/presentation/steps/step_payment.dart b/lib/feature/delivery/detail/presentation/steps/step_payment.dart index e9edde6..5d0bdfc 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_payment.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_payment.dart @@ -31,38 +31,51 @@ class StepPayment extends StatelessWidget { @override Widget build(BuildContext context) { final status = DeliveryPaymentStatus.of(delivery, details); - final canCollect = - delivery.state == DeliveryState.active && status.hasOpenAmount; - return Column( + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - children: [ - const _SectionHeader(text: 'Ausgelieferte Artikel'), - const SizedBox(height: 8), - DeliveredItemsCard(delivery: delivery, details: details), - const SizedBox(height: 24), - const _SectionHeader(text: 'Betrag'), - const SizedBox(height: 8), - PaymentBreakdownCard( - status: status, - credit: details.creditOf(delivery.id), - ), - const SizedBox(height: 16), - PaymentRecordCard(status: status), - ], - ), + const _SectionHeader(text: 'Ausgelieferte Artikel'), + const SizedBox(height: 8), + DeliveredItemsCard(delivery: delivery, details: details), + const SizedBox(height: 24), + const _SectionHeader(text: 'Betrag'), + const SizedBox(height: 8), + PaymentBreakdownCard( + status: status, + credit: details.creditOf(delivery.id), ), - if (canCollect) - _CollectPanel( - status: status, - onCollect: () => _openPaymentSheet(context, status), - ), + const SizedBox(height: 16), + PaymentRecordCard(status: status), ], ); } +} + +/// Fester Bereich unten: offener Betrag + großer „Zahlung abwickeln"-Button. +/// Bleibt sichtbar, auch wenn die Artikelliste lang ist. Sitzt in der +/// Bottom-Bar der Detail-Page, damit SnackBars ihn nicht verdecken. +class StepPaymentCollectPanel extends StatelessWidget { + const StepPaymentCollectPanel({ + super.key, + required this.delivery, + required this.details, + }); + + final Delivery delivery; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final status = DeliveryPaymentStatus.of(delivery, details); + if (delivery.state != DeliveryState.active || !status.hasOpenAmount) { + return const SizedBox.shrink(); + } + return _CollectPanel( + status: status, + onCollect: () => _openPaymentSheet(context, status), + ); + } Future _openPaymentSheet( BuildContext context, @@ -104,8 +117,6 @@ class StepPayment extends StatelessWidget { } } -/// Fester Bereich unten: offener Betrag + großer „Zahlung abwickeln"-Button. -/// Bleibt sichtbar, auch wenn die Artikelliste lang ist. class _CollectPanel extends StatelessWidget { const _CollectPanel({required this.status, required this.onCollect}); diff --git a/lib/feature/delivery/overview/presentation/delivery_fail_page.dart b/lib/feature/delivery/overview/presentation/delivery_fail_page.dart index 9ff7857..cd9a2d6 100644 --- a/lib/feature/delivery/overview/presentation/delivery_fail_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_fail_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; /// Fallback-Page, die der Übersichts- und Beladen-Pfad anzeigt, wenn der /// Initial-Tour-Load gescheitert ist. Tap auf "Erneut versuchen" feuert @@ -16,6 +17,12 @@ class DeliveryLoadingFailedPage extends StatelessWidget { @override Widget build(BuildContext context) { + final state = context.watch().state; + final reason = switch (state) { + TourLoadFailed(:final message) => message, + TourLoaded(:final refreshError) => refreshError, + _ => null, + }; return Center( child: Padding( padding: const EdgeInsets.all(50), @@ -33,6 +40,11 @@ class DeliveryLoadingFailedPage extends StatelessWidget { 'Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.', ), ), + if (reason != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(reason, textAlign: TextAlign.center), + ), Padding( padding: const EdgeInsets.only(top: 30), child: FilledButton( diff --git a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart index a604f70..8905f1a 100644 --- a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart @@ -47,9 +47,6 @@ class _DeliverySortPageState extends State { final SortableDeliveryListController _listController = SortableDeliveryListController(); - /// Verhindert mehrfache SnackBars für denselben Fehler-State. - String? _lastShownErrorSignature; - /// True, sobald `_confirm` gefeuert wurde — wir nutzen das, um den /// Übergang persisting → fertig zuverlässig zu erkennen. bool _isAwaitingConfirm = false; @@ -222,19 +219,14 @@ class _DeliverySortPageState extends State { listener: (context, state) { if (state is! TourLoaded) return; - final err = state.reorderError; - if (err != null && err != _lastShownErrorSignature) { - _lastShownErrorSignature = err; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(err)), - ); + // Die Meldung zeigt der OperationViewEnforcer (FailOperation im + // TourBloc); hier geht es nur darum, nicht weiterzuschalten. + if (state.reorderError != null) { _isAwaitingConfirm = false; return; } - if (_isAwaitingConfirm && - !state.isPersistingReorder && - err == null) { + if (_isAwaitingConfirm && !state.isPersistingReorder) { _isAwaitingConfirm = false; _advanceToLoading(); } diff --git a/lib/feature/delivery/overview/presentation/tour_day_watcher.dart b/lib/feature/delivery/overview/presentation/tour_day_watcher.dart new file mode 100644 index 0000000..2727d14 --- /dev/null +++ b/lib/feature/delivery/overview/presentation/tour_day_watcher.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; + +/// Holt bei einem Tageswechsel die aktuelle Tour, sobald der Fahrer zur +/// Tour-Ansicht zurückkehrt: beim Öffnen der App und beim Schließen einer +/// darüberliegenden Seite. +/// +/// Solange eine Seite darüber liegt (Lieferung, Unterschrift, Zahlung), wird +/// nicht geladen: Die neue Tour enthielte die offene Lieferung nicht mehr, und +/// der Fahrer verlöre seine Eingaben, z. B. bei einer Tour über Mitternacht. +class TourDayWatcher extends StatefulWidget { + const TourDayWatcher({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _TourDayWatcherState(); +} + +class _TourDayWatcherState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _checkDay(); + } + + /// Läuft auch, wenn eine darüberliegende Seite geschlossen wird: `ModalRoute` + /// meldet dann, dass diese Route wieder die oberste ist. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _checkDay(); + } + + void _checkDay() { + if (ModalRoute.of(context)?.isCurrent ?? true) { + context.read().add(const RefreshTourIfDayChanged()); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart b/lib/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart new file mode 100644 index 0000000..fb989c5 --- /dev/null +++ b/lib/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.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/overview/presentation/delivery_fail_page.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; + +/// Rahmen für Unterseiten einer Lieferung, solange keine Tour-Daten vorliegen. +/// +/// Immer mit Kopfzeile: Scheitert das Laden, bleibt der Fahrer sonst ohne +/// Zurück-Button vor einem Spinner stehen. +class TourNotLoadedScaffold extends StatelessWidget { + const TourNotLoadedScaffold({ + super.key, + required this.title, + required this.state, + }); + + final String title; + final TourState state; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: SafeArea( + child: switch (state) { + TourLoadFailed() || + TourLoaded( + refreshError: String _, + ) => const DeliveryLoadingFailedPage(), + TourEmpty() => const _NoTour(), + _ => const Center(child: CircularProgressIndicator()), + }, + ), + ); + } +} + +class _NoTour extends StatelessWidget { + const _NoTour(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Für diesen Tag ist keine Tour zugewiesen.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => context.read().add(const LoadTour()), + icon: const Icon(Icons.refresh), + label: const Text('Neu laden'), + ), + ], + ), + ), + ); + } +} + +/// Für Seiten einer einzelnen Lieferung: Fehlt sie in den geladenen Daten, wird +/// die Tour erst einmal neu geholt. Die Meldung kommt nur, wenn sie auch nach +/// einem **erfolgreichen** Neuladen fehlt. Scheitert das Neuladen, ist nichts +/// bewiesen; die Seite zeigt dann den Fehler mit „Erneut versuchen". +mixin DeliveryPresenceGuard on State { + String get guardedDeliveryId; + + bool _refetched = false; + bool _left = false; + + /// Aus dem Listener auf den [TourBloc] aufrufen. Liefert `true`, wenn die + /// Lieferung in den geladenen Daten steht. + bool deliveryStillPresent(BuildContext context, TourState state) { + if (_left) return false; + if (state is TourLoaded) { + if (state.details.deliveries.any((d) => d.id == guardedDeliveryId)) { + _refetched = false; + return true; + } + if (state.isRefreshing) return false; + if (_refetched && state.refreshError != null) return false; + } else if (state is! TourEmpty) { + return false; + } + + // Hier steht fest: Eine Antwort des Servers enthält die Lieferung nicht. + if (!_refetched) { + _refetched = true; + context.read().add(const RefreshTour()); + return false; + } + _left = true; + _leaveUnavailableDelivery(context); + return false; + } +} + +/// Führt zurück zur Übersicht, wenn die geöffnete Lieferung nicht in der +/// geladenen Tour steht. Das sagt nichts darüber, ob es sie gibt: Sie kann zu +/// einem anderen Tag gehören. Schließt auch Seiten, die darüber liegen +/// (Unterschrift, Zahlung): Sie beziehen sich auf dieselbe Lieferung. +void _leaveUnavailableDelivery(BuildContext context) { + context.showAppMessage( + 'Diese Lieferung ist in der geladenen Tour nicht enthalten. ' + 'Bitte das Tour-Datum prüfen.', + ); + Navigator.of(context).popUntil((route) => route.isFirst); +} diff --git a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart index 41e4ef8..38c97ac 100644 --- a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart +++ b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart @@ -97,7 +97,7 @@ class _SortableDeliveryListState extends State { if (delivery == null) { return ListTile( key: Key('reorder-item-orphan-$id'), - title: Text('Lieferung $id nicht mehr in der Tour'), + title: const Text('Lieferung nicht mehr in der Tour'), ); } final customer = widget.details.customerOf(delivery); diff --git a/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart b/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart index f9f2ff0..509bc4a 100644 --- a/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart +++ b/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart @@ -12,8 +12,10 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart'; import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart'; import 'package:hl_lieferservice/widget/scanner/item_matcher.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart'; import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart'; /// Scan-Screen für die Filial-Abholung in der Auslieferungs-Phase. /// @@ -40,7 +42,11 @@ class FilialePickupScanPage extends StatefulWidget { State createState() => _FilialePickupScanPageState(); } -class _FilialePickupScanPageState extends State { +class _FilialePickupScanPageState extends State + with DeliveryPresenceGuard { + @override + String get guardedDeliveryId => widget.deliveryId; + static const String _notIntendedMessage = 'Dieser Artikel gehört nicht zu dieser Filial-Abholung'; @@ -51,9 +57,7 @@ class _FilialePickupScanPageState extends State { } void _showSnackbar(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), duration: const Duration(seconds: 2)), - ); + context.showAppMessage(message, duration: const Duration(seconds: 2)); } /// Nur scanbare, nicht-entfernte Filial-Items dieser Lieferung, @@ -172,22 +176,17 @@ class _FilialePickupScanPageState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + listener: deliveryStillPresent, builder: (context, state) { if (state is! TourLoaded) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return TourNotLoadedScaffold(title: 'Filial-Abholung', state: state); } final details = state.details; final delivery = _findDelivery(details); if (delivery == null) { - return Scaffold( - appBar: AppBar(title: const Text('Filial-Abholung')), - body: const Center( - child: Text('Lieferung nicht in der Tour gefunden.'), - ), - ); + // Der Guard lädt gerade neu oder das Neuladen ist gescheitert. + return TourNotLoadedScaffold(title: 'Filial-Abholung', state: state); } final recipient = details.recipientOf(delivery); @@ -242,9 +241,10 @@ class _FilialePickupScanPageState extends State { ], ), ), - if (allDone) _DoneBar(onConfirm: _openDelivery), ], ), + bottomNavigationBar: + allDone ? _DoneBar(onConfirm: _openDelivery) : null, ); }, ); diff --git a/lib/feature/loading/presentation/loading_customer_page.dart b/lib/feature/loading/presentation/loading_customer_page.dart index 37bc2f8..ce982d5 100644 --- a/lib/feature/loading/presentation/loading_customer_page.dart +++ b/lib/feature/loading/presentation/loading_customer_page.dart @@ -24,6 +24,8 @@ import 'package:hl_lieferservice/widget/scanner/item_matcher.dart'; import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart'; import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart'; import 'package:hl_lieferservice/widget/orderer_hint.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart'; /// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner. /// @@ -158,12 +160,7 @@ class _LoadingCustomerPageState extends State { 'Dieser Artikel ist für diese Lieferung nicht vorgesehen'; void _showScanSnackbar(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 2), - ), - ); + context.showAppMessage(message, duration: const Duration(seconds: 2)); } Future _openManualEntry({ @@ -413,9 +410,7 @@ class _LoadingCustomerPageState extends State { _maybePromptLoadingComplete(context, tourState, carId), builder: (context, tourState) { if (tourState is! TourLoaded) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return TourNotLoadedScaffold(title: 'Beladung', state: tourState); } final deliveries = _ownInLoadingOrder(context, tourState.details, carId); diff --git a/lib/feature/settings/presentation/settings_page.dart b/lib/feature/settings/presentation/settings_page.dart index 67f8b3b..bd11e05 100644 --- a/lib/feature/settings/presentation/settings_page.dart +++ b/lib/feature/settings/presentation/settings_page.dart @@ -10,6 +10,7 @@ import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_event.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; import 'package:hl_lieferservice/feature/settings/model/settings.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @@ -69,11 +70,7 @@ class _SettingsPage extends State { final uri = Uri.parse('$issuer/account/'); final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Kontoeinstellungen konnten nicht geöffnet werden: $uri'), - ), - ); + context.showAppMessage('Kontoeinstellungen konnten nicht geöffnet werden'); } } diff --git a/lib/widget/app.dart b/lib/widget/app.dart index 3a0e133..7de26ae 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -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 { 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 { if (state is AppConfigLoaded) { // Zahlungsmethoden lädt SessionScope nach jeder Anmeldung neu. return LoginEnforcer( - child: CarSelectionEnforcer(child: Home()), + child: CarSelectionEnforcer( + child: TourDayWatcher(child: Home()), + ), ); } diff --git a/lib/widget/app_message.dart b/lib/widget/app_message.dart new file mode 100644 index 0000000..94742e1 --- /dev/null +++ b/lib/widget/app_message.dart @@ -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, + ), + ); + } +} diff --git a/lib/widget/operations/bloc/operation_bloc.dart b/lib/widget/operations/bloc/operation_bloc.dart index 64b95b9..55bada2 100644 --- a/lib/widget/operations/bloc/operation_bloc.dart +++ b/lib/widget/operations/bloc/operation_bloc.dart @@ -40,18 +40,13 @@ class OperationBloc extends Bloc { ) 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 { diff --git a/lib/widget/operations/bloc/operation_event.dart b/lib/widget/operations/bloc/operation_event.dart index c624605..990ebaf 100644 --- a/lib/widget/operations/bloc/operation_event.dart +++ b/lib/widget/operations/bloc/operation_event.dart @@ -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 {} diff --git a/lib/widget/operations/bloc/operation_state.dart b/lib/widget/operations/bloc/operation_state.dart index a52ea19..21983ba 100644 --- a/lib/widget/operations/bloc/operation_state.dart +++ b/lib/widget/operations/bloc/operation_state.dart @@ -13,9 +13,3 @@ class OperationFailed extends OperationState { OperationFailed({required this.message}); } - -class OperationFinished extends OperationState { - String? message; - - OperationFinished({this.message}); -} diff --git a/lib/widget/operations/presentation/operation_view_enforcer.dart b/lib/widget/operations/presentation/operation_view_enforcer.dart index d3ab5bc..183b7bd 100644 --- a/lib/widget/operations/presentation/operation_view_enforcer.dart +++ b/lib/widget/operations/presentation/operation_view_enforcer.dart @@ -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( 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) { diff --git a/lib/widget/phase_stepper/phase_stepper.dart b/lib/widget/phase_stepper/phase_stepper.dart index fbd675a..4fef758 100644 --- a/lib/widget/phase_stepper/phase_stepper.dart +++ b/lib/widget/phase_stepper/phase_stepper.dart @@ -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(); - 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. diff --git a/test/api_failure_text_test.dart b/test/api_failure_text_test.dart new file mode 100644 index 0000000..f1ddbb7 --- /dev/null +++ b/test/api_failure_text_test.dart @@ -0,0 +1,91 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:hl_lieferservice/data/network/api_failure_text.dart'; + +DioException _fehler(int? status, [String? serverText]) { + final options = RequestOptions(path: '/x'); + return DioException( + requestOptions: options, + response: status == null + ? null + : Response( + requestOptions: options, + statusCode: status, + data: serverText == null ? null : {'message': serverText}, + ), + ); +} + +void main() { + const bekannt = {'existiert bereits': 'Dieses Kennzeichen ist bereits vergeben'}; + + test('bekannter Server-Text wird übersetzt, ohne Rohtext', () { + final text = describeApiFailure( + _fehler(400, "validation: kennzeichen 'test' existiert bereits"), + operation: 'Anlegen eines Fahrzeugs', + byServerText: bekannt, + ); + expect(text, 'Dieses Kennzeichen ist bereits vergeben'); + }); + + test('unbekannter Server-Text erreicht den Fahrer nicht', () { + final text = describeApiFailure( + _fehler(400, 'validation: numericValue 12 above max 10'), + operation: 'Service setzen', + byServerText: bekannt, + ); + expect(text, 'Service setzen fehlgeschlagen. Bitte erneut versuchen'); + }); + + test('Serverfehler nennt keinen HTTP-Code', () { + final text = describeApiFailure(_fehler(500), operation: 'Notiz anlegen'); + expect(text, 'Notiz anlegen fehlgeschlagen. Bitte erneut versuchen'); + }); + + test('Statuscodes der Fachseite und Sitzungsende', () { + expect( + describeApiFailure( + _fehler(404), + operation: 'x', + byStatus: const {404: 'Fahrzeug nicht gefunden'}, + ), + 'Fahrzeug nicht gefunden', + ); + expect(describeApiFailure(_fehler(401), operation: 'x'), 'Sitzung abgelaufen'); + }); + + test('ohne Antwort: Hinweis auf die Verbindung', () { + expect( + describeApiFailure(_fehler(null), operation: 'x'), + 'Server nicht erreichbar. Bitte Verbindung prüfen', + ); + }); + + test('Timeouts und Abbrüche werden unterschieden', () { + DioException ohneAntwort(DioExceptionType type) => DioException( + requestOptions: RequestOptions(path: '/x'), + type: type, + ); + + for (final type in const [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + ]) { + expect( + describeApiFailure(ohneAntwort(type), operation: 'x'), + 'Der Server antwortet nicht. Bitte Verbindung prüfen', + ); + } + for (final type in const [ + DioExceptionType.connectionError, + DioExceptionType.unknown, + ]) { + expect( + describeApiFailure(ohneAntwort(type), operation: 'x'), + 'Server nicht erreichbar. Bitte Verbindung prüfen', + ); + } + }); +} diff --git a/test/app_message_test.dart b/test/app_message_test.dart new file mode 100644 index 0000000..defe202 --- /dev/null +++ b/test/app_message_test.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/domain/entity/address.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; +import 'package:hl_lieferservice/domain/entity/customer.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; +import 'package:hl_lieferservice/domain/entity/scan_progress.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/repository/cars_repository.dart'; +import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart'; +import 'package:hl_lieferservice/domain/repository/tour_repository.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart'; +import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +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/detail/presentation/delivery_detail_page.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_sort_page.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/service/phase_service.dart'; +import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart'; +import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; +import 'package:hl_lieferservice/widget/app_message.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; +import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart'; + +const _fehler = 'Das hat nicht geklappt'; + +const _address = Address( + street: 'Konstantinplatz', + houseNumber: '3', + postalCode: '41849', + city: 'Wassenberg', + country: 'DE', +); + +Delivery _delivery(String id, int sortOrder) => Delivery( + id: id, + tourId: 't1', + customerId: 'c1', + contactPersonIds: const [], + deliveryAddressSnapshot: _address, + erpBelegartId: 24, + erpBelegnummer: 'V-$id', + state: DeliveryState.active, + sortOrder: sortOrder, + prepaidAmount: 20, + paymentMethodId: 'm-cash', + items: [ + DeliveryItem( + id: 'i-$id', + deliveryId: id, + articleId: 'a1', + warehouseId: 'w1', + belegzeilenNr: 1, + requiredQuantity: 2, + unitPrice: 50, + scanProgress: ScanProgress( + status: ScanStatus.done, + scannedQuantity: 2, + lastUpdatedAt: DateTime(2026), + ), + ), + ], +); + +TourDetails _details() => TourDetails( + tour: Tour( + id: 't1', + accountId: 414, + date: DateTime(2026, 9, 25), + syncedAt: DateTime(2026), + ), + deliveries: [_delivery('d1', 1), _delivery('d2', 2)], + customers: const { + 'c1': Customer( + id: 'c1', + name: 'Cremer', + erpCustomerId: 7843068, + address: _address, + ), + }, + contacts: const {}, + articles: const {}, + warehouses: const {}, + notesByDeliveryId: const {}, + creditsByDeliveryId: const {}, + paymentsByDeliveryId: const {}, + services: const [], + serviceValuesByDeliveryId: const {}, + contactSourcesByDeliveryId: const {}, + contactChannelsBySourceId: const {}, +); + +class _Tours implements TourRepository { + @override + Future getMyTourDetails({DateTime? date}) async => _details(); + + @override + Future> setDeliveryOrder({ + required String tourId, + required List orderedDeliveryIds, + }) async => throw const TourRepositoryException(_fehler); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Cars implements CarsRepository { + @override + Future> listMine({bool includeInactive = false}) async => const []; + + @override + Future create({required String plate}) async => + throw const CarsRepositoryException(_fehler); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Methods implements PaymentMethodsRepository { + @override + Future> list({bool includeInactive = false}) async => + const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _NoCache implements AttachmentCache { + @override + Future retainOnly(Set ids) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Baut [home] wie in der App: Theme, Enforcer und alle benötigten Blocs. +Future _pump( + WidgetTester tester, + Widget Function(BuildContext) home, +) async { + // Höhe eines iPhones — daran hängt die Überdeckung. Breiter als das Gerät, + // weil die Testschrift deutlich breiter läuft als die echte. + tester.view.physicalSize = const Size(2400, 2556); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + SharedPreferences.setMockInitialValues({}); + + final opBloc = OperationBloc(); + final tourBloc = TourBloc( + tourRepository: _Tours(), + opBloc: opBloc, + attachmentCache: _NoCache(), + )..add(const LoadTour()); + final carsBloc = CarsBloc(repository: _Cars(), opBloc: opBloc) + ..add(const CarLoad()); + + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + RepositoryProvider.value(value: const FeatureFlags({})), + BlocProvider.value(value: opBloc), + BlocProvider.value(value: tourBloc), + BlocProvider.value(value: carsBloc), + BlocProvider(create: (_) => TourDateCubit()), + BlocProvider(create: (_) => PhaseBloc(phaseService: PhaseService())), + BlocProvider( + create: (_) => CarSelectBloc( + repository: CarSelectionRepository(environmentId: 'test'), + carsRepository: _Cars(), + ), + ), + BlocProvider( + create: (_) => PaymentMethodsCubit(repository: _Methods()), + ), + ], + child: MaterialApp( + theme: ThemeData(snackBarTheme: appSnackBarTheme), + builder: (context, child) => OperationViewEnforcer(child: child!), + home: Builder(builder: home), + ), + ), + ); + await tester.pumpAndSettle(); + return opBloc; +} + +/// Lässt die 4-s-Anzeige und das Nachlaufen des OperationBloc verstreichen. +Future _meldungAblaufenLassen(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); +} + +void _erwarteFrei(WidgetTester tester, Finder bedienelement) { + expect(bedienelement, findsOneWidget); + final meldung = tester.getRect(find.byType(SnackBar)); + final rect = tester.getRect(bedienelement); + expect( + meldung.overlaps(rect), + isFalse, + reason: 'Meldung $meldung verdeckt Bedienelement $rect', + ); +} + +Future _zeigeFehler(WidgetTester tester, OperationBloc opBloc) async { + opBloc.add(FailOperation(message: _fehler)); + await tester.pumpAndSettle(); + expect(find.text(_fehler), findsOneWidget); +} + +void main() { + group('Meldungen verdecken keine Bedienelemente', () { + testWidgets('Fahrzeugverwaltung: Plus-Button', (tester) async { + final opBloc = await _pump(tester, (_) => const CarManagementPage()); + + await _zeigeFehler(tester, opBloc); + + _erwarteFrei(tester, find.byType(FloatingActionButton)); + await _meldungAblaufenLassen(tester); + }); + + testWidgets('Lieferdetail: Zurück, Weiter und die Step-Aktionen', ( + tester, + ) async { + final opBloc = await _pump( + tester, + (_) => const DeliveryDetail(deliveryId: 'd1'), + ); + + await _zeigeFehler(tester, opBloc); + _erwarteFrei(tester, find.text('Zurück')); + _erwarteFrei(tester, find.text('Weiter')); + await _meldungAblaufenLassen(tester); + + // Step „Notizen": „Hinzufügen" schwebt über der Leiste. + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + await _zeigeFehler(tester, opBloc); + _erwarteFrei(tester, find.text('Hinzufügen')); + _erwarteFrei(tester, find.text('Weiter')); + await _meldungAblaufenLassen(tester); + + // Über Artikel und Services zum Step „Zahlung". + for (var i = 0; i < 3; i++) { + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + } + await _zeigeFehler(tester, opBloc); + _erwarteFrei(tester, find.text('Zahlung abwickeln')); + await _meldungAblaufenLassen(tester); + }); + }); + + group('Meldungen stauen sich nicht', () { + testWidgets('eine neue Meldung ersetzt die alte, auch mehrere schnell', ( + tester, + ) async { + await _pump(tester, (_) => const Scaffold()); + final context = tester.element(find.byType(Scaffold)); + + context.showAppMessage('eins'); + await tester.pump(); + context.showAppMessage('zwei'); + context.showAppMessage('drei'); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + + expect(find.text('drei'), findsOneWidget); + expect(find.text('eins'), findsNothing); + expect(find.text('zwei'), findsNothing); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('auch eine Meldung mit Aktion verschwindet von selbst', ( + tester, + ) async { + await _pump(tester, (_) => const Scaffold()); + + tester + .element(find.byType(Scaffold)) + .showAppMessage( + _fehler, + action: SnackBarAction(label: 'Erneut', onPressed: () {}), + ); + await tester.pumpAndSettle(); + expect(find.text(_fehler), findsOneWidget); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); + }); + + testWidgets('Reihenfolge-Fehler erscheint genau einmal, auch beim zweiten Mal', + (tester) async { + await _pump( + tester, + (_) => const DeliverySortPage(selectedCarId: 'car-1'), + ); + + for (var versuch = 0; versuch < 2; versuch++) { + await tester.tap(find.text('Reihenfolge bestätigen')); + await tester.pumpAndSettle(); + expect(find.text(_fehler), findsOneWidget); + + // Eine zweite Meldung säße in der Warteschlange und käme jetzt. + await _meldungAblaufenLassen(tester); + expect(find.text(_fehler), findsNothing); + } + }); +} diff --git a/test/load_failure_test.dart b/test/load_failure_test.dart new file mode 100644 index 0000000..cfc2e7b --- /dev/null +++ b/test/load_failure_test.dart @@ -0,0 +1,642 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; + +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/data/network/auth_session_event.dart'; +import 'package:hl_lieferservice/data/network/auth_token_provider.dart'; +import 'package:hl_lieferservice/data/network/backend_config.dart'; +import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; +import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart'; +import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart'; +import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart'; +import 'package:hl_lieferservice/domain/entity/address.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/repository/cars_repository.dart'; +import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart'; +import 'package:hl_lieferservice/domain/repository/tour_repository.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.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/detail/presentation/delivery_detail_page.dart'; +import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart'; +import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; + +const _gone = + 'Diese Lieferung ist in der geladenen Tour nicht enthalten. ' + 'Bitte das Tour-Datum prüfen.'; +const _offline = 'Server nicht erreichbar. Bitte Verbindung prüfen'; + +Delivery _delivery(String id, {DeliveryState state = DeliveryState.active}) => + Delivery( + id: id, + tourId: 't1', + customerId: 'c1', + contactPersonIds: const [], + deliveryAddressSnapshot: const Address( + street: 'Konstantinplatz', + houseNumber: '3', + postalCode: '41849', + city: 'Wassenberg', + country: 'DE', + ), + erpBelegartId: 24, + erpBelegnummer: 'V-$id', + state: state, + sortOrder: 1, + prepaidAmount: 0, + paymentMethodId: 'm-cash', + items: const [], + ); + +TourDetails _tour(List deliveries) => TourDetails( + tour: Tour( + id: 't1', + accountId: 414, + date: DateTime(2026, 10, 30), + syncedAt: DateTime(2026, 10, 30), + ), + deliveries: deliveries, + customers: const {}, + contacts: const {}, + articles: const {}, + warehouses: const {}, + notesByDeliveryId: const {}, + creditsByDeliveryId: const {}, + paymentsByDeliveryId: const {}, + services: const [], + serviceValuesByDeliveryId: const {}, + contactSourcesByDeliveryId: const {}, + contactChannelsBySourceId: const {}, +); + +/// Antwortet der Reihe nach mit den vorbereiteten Ergebnissen. Ein +/// [Completer] bleibt offen, bis der Test ihn abschließt. +class _ScriptedTours implements TourRepository { + _ScriptedTours(this.answers); + + final List answers; + + @override + Future getMyTourDetails({DateTime? date}) { + final next = answers.removeAt(0); + if (next is Completer) return next.future; + if (next is Exception) return Future.error(next); + return Future.value(next as TourDetails?); + } + + @override + Future cancelDelivery({ + required String deliveryId, + required String reason, + }) async => _delivery(deliveryId, state: DeliveryState.canceled); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _NoCache implements AttachmentCache { + @override + Future retainOnly(Set ids) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +TourBloc _bloc(List answers) => TourBloc( + tourRepository: _ScriptedTours(answers), + opBloc: OperationBloc(), + attachmentCache: _NoCache(), +); + +const _networkDown = TourRepositoryException(_offline); + +/// Lässt die Bloc-Handler laufen, ohne auf ein festes Ereignis zu warten. +Future _settle() => Future.delayed(const Duration(milliseconds: 5)); + +void main() { + group('TourBloc bei Verbindungsfehlern', () { + test('Neuladen scheitert: die alte Tour bleibt mit Fehlertext stehen', + () async { + final bloc = _bloc([ + _tour([_delivery('d1')]), + _networkDown, + ]); + bloc.add(const LoadTour()); + await _settle(); + bloc.add(const RefreshTour()); + await _settle(); + + final state = bloc.state as TourLoaded; + expect(state.details.deliveries.single.id, 'd1'); + expect(state.refreshError, _offline); + expect(state.isRefreshing, isFalse); + }); + + test('erstes Laden scheitert: Fehlerzustand mit dem Grund', () async { + final bloc = _bloc([_networkDown]); + bloc.add(const LoadTour()); + await _settle(); + + expect((bloc.state as TourLoadFailed).message, _offline); + }); + + test('alte Antwort eines Neuladens dreht einen Abbruch nicht zurück', + () async { + final slow = Completer(); + final bloc = _bloc([ + _tour([_delivery('d1')]), + slow, + ]); + bloc.add(const LoadTour()); + await _settle(); + bloc.add(const RefreshTour()); + await _settle(); + bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt')); + await _settle(); + + // Die Antwort kam vom Server, bevor er den Abbruch kannte. + slow.complete(_tour([_delivery('d1')])); + await _settle(); + + final state = bloc.state as TourLoaded; + expect(state.details.deliveries.single.state, DeliveryState.canceled); + expect(state.isRefreshing, isFalse); + }); + + test('Abbruch überschreibt kein zwischenzeitlich fertiges Neuladen', + () async { + final repo = _SlowCancelTours( + _tour([_delivery('d1')]), + _tour([_delivery('d1'), _delivery('neu')]), + ); + final bloc = TourBloc( + tourRepository: repo, + opBloc: OperationBloc(), + attachmentCache: _NoCache(), + ); + bloc.add(const LoadTour()); + await _settle(); + bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt')); + await _settle(); + bloc.add(const RefreshTour()); + await _settle(); + repo.cancel.complete(_delivery('d1', state: DeliveryState.canceled)); + await _settle(); + + final deliveries = (bloc.state as TourLoaded).details.deliveries; + expect(deliveries.map((d) => d.id), ['d1', 'neu']); + expect(deliveries.first.state, DeliveryState.canceled); + }); + }); + + group('Unterseiten einer Lieferung', () { + /// Öffnet [page] über einer Startseite, wie aus der Übersicht heraus. + Future open( + WidgetTester tester, + TourBloc bloc, + Widget page, + ) async { + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => page), + ), + child: const Text('Übersicht'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Übersicht')); + await tester.pumpAndSettle(); + } + + /// `FailOperation` hält seinen Zustand fünf Sekunden; danach darf im Test + /// kein Timer mehr offen sein. + Future finish(WidgetTester tester) => + tester.pump(const Duration(seconds: 6)); + + for (final entry in { + 'Lieferdetail': const DeliveryDetail(deliveryId: 'd1'), + 'Filial-Abholung': const FilialePickupScanPage(deliveryId: 'd1'), + }.entries) { + testWidgets( + '${entry.key}: Fehlschlag zeigt Grund, Neuladen und Zurück, ' + 'und fehlt die Lieferung auch im zweiten Abruf, geht es zur Übersicht', + (tester) async { + final bloc = _bloc([ + _networkDown, + _tour(const []), + _tour(const []), + ]); + addTearDown(bloc.close); + + await tester.runAsync(() async { + bloc.add(const LoadTour()); + await _settle(); + }); + await open(tester, bloc, entry.value); + + expect(find.text(_offline), findsOneWidget); + expect(find.byType(BackButton), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tester.tap(find.text('Erneut versuchen')); + await tester.runAsync(_settle); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Übersicht'), findsOneWidget); + expect(find.text(_gone), findsOneWidget); + expect(find.textContaining('nicht in der Tour'), findsNothing); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await finish(tester); + }); + + testWidgets('${entry.key}: Server meldet keine Tour, zurück zur Übersicht', + (tester) async { + final bloc = _bloc([ + _networkDown, + null, + null, + ]); + addTearDown(bloc.close); + + await tester.runAsync(() async { + bloc.add(const LoadTour()); + await _settle(); + }); + await open(tester, bloc, entry.value); + + await tester.tap(find.text('Erneut versuchen')); + await tester.runAsync(_settle); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Übersicht'), findsOneWidget); + expect(find.text(_gone), findsOneWidget); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await finish(tester); + }); + } + }); + + group('fehlende Lieferung: erst neu laden, dann melden', () { + late _CountingTours tours; + late TourBloc bloc; + + Future pumpGuarded(WidgetTester tester) async { + tours = _CountingTours(); + bloc = TourBloc( + tourRepository: tours, + opBloc: OperationBloc(), + attachmentCache: _NoCache(), + ); + tours.answers.add(_tour([_delivery('d1')])); + await tester.runAsync(() async { + bloc.add(const LoadTour()); + await _settle(); + }); + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _GuardedPage()), + ), + child: const Text('Übersicht'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Übersicht')); + await tester.pumpAndSettle(); + expect(find.text('Lieferung sichtbar'), findsOneWidget); + } + + /// Ein Neuladen liefert die vorbereiteten Antworten der Reihe nach. + Future reload(WidgetTester tester) async { + bloc.add(const RefreshTour()); + await tester.runAsync(_settle); + await tester.pump(); + await tester.runAsync(_settle); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + testWidgets('zweiter Abruf enthält sie: keine Meldung, Seite bleibt', + (tester) async { + await pumpGuarded(tester); + tours.answers + ..add(_tour(const [])) + ..add(_tour([_delivery('d1')])); + + await reload(tester); + + expect(tours.loads, 3, reason: 'genau ein zusätzlicher Abruf'); + expect(find.text('Lieferung sichtbar'), findsOneWidget); + expect(find.text(_gone), findsNothing); + }); + + testWidgets('zweiter Abruf enthält sie nicht: erst dann die Meldung', + (tester) async { + await pumpGuarded(tester); + tours.answers + ..add(_tour(const [])) + ..add(_tour(const [])); + + await reload(tester); + + expect(tours.loads, 3, reason: 'kein dritter Versuch'); + expect(find.text(_gone), findsOneWidget); + expect(find.text('Übersicht'), findsOneWidget); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }); + + testWidgets('zweiter Abruf scheitert: keine Meldung, Fehler und Wiederholung', + (tester) async { + await pumpGuarded(tester); + tours.answers + ..add(_tour(const [])) + ..add(_networkDown); + + await reload(tester); + + expect(find.text(_gone), findsNothing); + expect(find.text(_offline), findsOneWidget); + expect(find.text('Erneut versuchen'), findsOneWidget); + + // Netz ist wieder da und die Lieferung gehört zur Tour. + tours.answers.add(_tour([_delivery('d1')])); + await tester.tap(find.text('Erneut versuchen')); + await tester.runAsync(_settle); + await tester.pump(); + + expect(find.text('Lieferung sichtbar'), findsOneWidget); + expect(find.text(_gone), findsNothing); + }); + + testWidgets('später erneut verschwunden: wieder ein zusätzlicher Abruf', + (tester) async { + await pumpGuarded(tester); + tours.answers + ..add(_tour(const [])) + ..add(_tour([_delivery('d1')])); + await reload(tester); + + tours.answers + ..add(_tour(const [])) + ..add(_tour([_delivery('d1')])); + await reload(tester); + + expect(tours.loads, 5); + expect(find.text('Lieferung sichtbar'), findsOneWidget); + }); + }); + + group('leere Server-Antworten sind Fehler', () { + HolzleitnerApi emptyApi() { + final dio = Dio() + ..interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) => handler.resolve( + Response(requestOptions: options, statusCode: 200), + ), + ), + ); + return HolzleitnerApi(dio: dio, interceptors: const []); + } + + test('Tour', () { + expect( + TourRepositoryImpl(emptyApi()).getMyTourSummary(), + throwsA(isA()), + ); + }); + + test('Fahrzeuge', () { + expect( + CarsRepositoryImpl(emptyApi()).listMine(), + throwsA(isA()), + ); + }); + + test('Zahlungsmethoden', () { + expect( + PaymentMethodsRepositoryImpl(emptyApi()).list(), + throwsA(isA()), + ); + }); + }); + + group('Token-Erneuerung', () { + KeycloakOidcTokenProvider provider(_HangingAppAuth appAuth) => + KeycloakOidcTokenProvider( + config: const BackendConfig( + apiBaseUrl: 'http://localhost', + keycloakIssuerUrl: 'http://localhost/realms/x', + keycloakClientId: 'app', + keycloakRedirectUrl: 'holzleitner://oauth2redirect', + ), + issuerUrl: 'http://localhost/realms/x', + redirectUrl: 'holzleitner://oauth2redirect', + appAuth: appAuth, + storage: _StoredRefreshToken(), + refreshTimeout: const Duration(milliseconds: 10), + ); + + test('hängender Refresh hält den Request nicht auf', () async { + final auth = provider(_HangingAppAuth()); + expect( + await auth.restoreSession(), + SessionRestoreResult.temporarilyUnavailable, + ); + await expectLater( + auth.currentAccessToken(), + throwsA(isA()), + ); + }); + + test('später Abschluss wird übernommen, ohne zweiten Refresh', () async { + final appAuth = _HangingAppAuth(); + final auth = provider(appAuth); + await auth.restoreSession(); + expect(appAuth.calls, 1); + + appAuth.finish( + TokenResponse( + 'access-neu', + 'refresh-neu', + DateTime.now().add(const Duration(hours: 1)), + null, + null, + null, + null, + ), + ); + await _settle(); + + expect(await auth.currentAccessToken(), 'access-neu'); + expect(appAuth.calls, 1); + }); + }); + + group('Sitzung beim App-Start', () { + AuthBloc authBloc(_ScriptedRestore provider) => + AuthBloc(tokenProvider: provider, operationBloc: OperationBloc()); + + test('ohne Verbindung: Login-Seite mit Hinweis, Session bleibt', () async { + final bloc = authBloc( + _ScriptedRestore([SessionRestoreResult.temporarilyUnavailable]), + ); + bloc.add(const RestoreSessionRequested()); + await _settle(); + + expect((bloc.state as Unauthenticated).restoreUnavailable, isTrue); + }); + + test('ohne Session: normale Login-Seite ohne Hinweis', () async { + final bloc = authBloc(_ScriptedRestore([SessionRestoreResult.noSession])); + bloc.add(const RestoreSessionRequested()); + await _settle(); + + expect((bloc.state as Unauthenticated).restoreUnavailable, isFalse); + }); + + test('erneuter Versuch zeigt wieder den Start-Bildschirm', () async { + final bloc = authBloc( + _ScriptedRestore([ + SessionRestoreResult.temporarilyUnavailable, + SessionRestoreResult.restored, + ]), + ); + bloc.add(const RestoreSessionRequested()); + await _settle(); + bloc.add(const RestoreSessionRequested()); + await _settle(); + + expect(bloc.state, isA()); + }); + }); +} + +class _CountingTours extends _ScriptedTours { + _CountingTours() : super([]); + + int loads = 0; + + @override + Future getMyTourDetails({DateTime? date}) { + loads++; + return super.getMyTourDetails(date: date); + } +} + +/// Kleinstmögliche Seite mit dem Guard. Die echten Seiten brauchen für eine +/// vorhandene Lieferung den ganzen Bloc-Baum. +class _GuardedPage extends StatefulWidget { + const _GuardedPage(); + + @override + State<_GuardedPage> createState() => _GuardedPageState(); +} + +class _GuardedPageState extends State<_GuardedPage> with DeliveryPresenceGuard { + @override + String get guardedDeliveryId => 'd1'; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: deliveryStillPresent, + builder: (context, state) { + final present = state is TourLoaded && + state.details.deliveries.any((d) => d.id == guardedDeliveryId); + if (!present) { + return TourNotLoadedScaffold(title: 'Lieferung', state: state); + } + return const Scaffold(body: Text('Lieferung sichtbar')); + }, + ); + } +} + +class _SlowCancelTours extends _ScriptedTours { + _SlowCancelTours(TourDetails first, TourDetails second) + : super([first, second]); + + final cancel = Completer(); + + @override + Future cancelDelivery({ + required String deliveryId, + required String reason, + }) => cancel.future; +} + +class _HangingAppAuth implements FlutterAppAuth { + final _result = Completer(); + int calls = 0; + + void finish(TokenResponse response) => _result.complete(response); + + @override + Future token(TokenRequest request) { + calls++; + return _result.future; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _StoredRefreshToken implements FlutterSecureStorage { + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.memberName == #read) return Future.value('rt'); + if (invocation.memberName == #write) return Future.value(); + return super.noSuchMethod(invocation); + } +} + +class _ScriptedRestore implements KeycloakOidcTokenProvider { + _ScriptedRestore(this.results); + + final List results; + + @override + Stream get events => const Stream.empty(); + + @override + Future restoreSession() async => results.removeAt(0); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/tour_day_change_test.dart b/test/tour_day_change_test.dart new file mode 100644 index 0000000..29d7094 --- /dev/null +++ b/test/tour_day_change_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/repository/tour_repository.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.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/overview/presentation/tour_day_watcher.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; + +final _day1 = DateTime(2026, 10, 30, 22); +final _day2 = DateTime(2026, 10, 31, 7); + +TourDetails _tourAm(DateTime date) => TourDetails( + tour: Tour(id: 't-$date', accountId: 414, date: date, syncedAt: date), + deliveries: const [], + customers: const {}, + contacts: const {}, + articles: const {}, + warehouses: const {}, + notesByDeliveryId: const {}, + creditsByDeliveryId: const {}, + paymentsByDeliveryId: const {}, + services: const [], + serviceValuesByDeliveryId: const {}, + contactSourcesByDeliveryId: const {}, + contactChannelsBySourceId: const {}, +); + +/// Liefert die Tour zum aktuellen Testtag und zählt die Anfragen. +class _Tours implements TourRepository { + _Tours(this.now); + + final DateTime Function() now; + int loads = 0; + Object? failure; + + @override + Future getMyTourDetails({DateTime? date}) async { + loads++; + final error = failure; + if (error != null) throw error; + return _tourAm(date ?? now()); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _NoCache implements AttachmentCache { + @override + Future retainOnly(Set ids) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late DateTime now; + late _Tours tours; + late TourBloc bloc; + + setUp(() { + now = _day1; + tours = _Tours(() => now); + bloc = TourBloc( + tourRepository: tours, + opBloc: OperationBloc(), + attachmentCache: _NoCache(), + clock: () => now, + ); + }); + + tearDown(() => bloc.close()); + + Future settle() => + Future.delayed(const Duration(milliseconds: 5)); + + DateTime loadedDate() => (bloc.state as TourLoaded).details.tour.date; + + group('Bloc', () { + test('neuer Tag: lädt die Tour des neuen Tages', () async { + bloc.add(const LoadTour()); + await settle(); + now = _day2; + bloc.add(const RefreshTourIfDayChanged()); + await settle(); + + expect(loadedDate(), _day2); + expect(tours.loads, 2); + }); + + test('derselbe Tag: kein Request', () async { + bloc.add(const LoadTour()); + await settle(); + now = _day1.add(const Duration(hours: 1)); + bloc.add(const RefreshTourIfDayChanged()); + await settle(); + + expect(tours.loads, 1); + }); + + test('im Kalender gewählte Tour bleibt', () async { + final gewaehlt = DateTime(2026, 10, 20); + bloc.add(LoadTour.forDate(gewaehlt)); + await settle(); + now = _day2; + bloc.add(const RefreshTourIfDayChanged()); + await settle(); + + expect(loadedDate(), gewaehlt); + expect(tours.loads, 1); + }); + + test('kein Netz: alte Tour bleibt, nächster Versuch lädt nach', () async { + bloc.add(const LoadTour()); + await settle(); + now = _day2; + tours.failure = const TourRepositoryException('Server nicht erreichbar'); + bloc.add(const RefreshTourIfDayChanged()); + await settle(); + + expect(loadedDate(), _day1); + expect((bloc.state as TourLoaded).refreshError, isNotNull); + + tours.failure = null; + bloc.add(const RefreshTourIfDayChanged()); + await settle(); + + expect(loadedDate(), _day2); + }); + + test('vor dem ersten Laden passiert nichts', () async { + bloc.add(const RefreshTourIfDayChanged()); + await settle(); + + expect(tours.loads, 0); + expect(bloc.state, isA()); + }); + }); + + group('TourDayWatcher', () { + Future pumpWatcher(WidgetTester tester) async { + await tester.runAsync(() async { + bloc.add(const LoadTour()); + await settle(); + }); + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: MaterialApp( + home: TourDayWatcher( + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Lieferung')), + ), + ), + ), + child: const Text('öffnen'), + ), + ), + ), + ), + ), + ), + ); + } + + Future resume(WidgetTester tester) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.runAsync(settle); + } + + testWidgets('Rückkehr in die App am neuen Tag lädt nach', (tester) async { + await pumpWatcher(tester); + // Der Test startet die App im Zustand „nicht aktiv", wie ein Wechsel. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + now = _day2; + + await resume(tester); + + expect(tours.loads, 2); + expect(loadedDate(), _day2); + }); + + testWidgets('offene Seite: erst beim Schließen wird nachgeladen', + (tester) async { + await pumpWatcher(tester); + await tester.tap(find.text('öffnen')); + await tester.pumpAndSettle(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + now = _day2; + + await resume(tester); + expect(tours.loads, 1, reason: 'Eingaben der offenen Seite schützen'); + expect(loadedDate(), _day1); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + await tester.runAsync(settle); + + expect(tours.loads, 2); + expect(loadedDate(), _day2); + }); + }); +}