From dce4447fcce40be6620263402c64a83d3c2ab5dd Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 16:47:25 +0200 Subject: [PATCH] fix: fahrerbezogene Daten bei jeder Anmeldung neu laden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tour, Datum, Fahrzeuge, Fahrzeugauswahl und Phase lebten bisher pro Server-Umgebung, nicht pro Anmeldung. Nach Abmelden von 423 und Anmelden von 414 lieferte der gecachte CarsBloc weiter die Fahrzeuge von 423. Neu: SessionScope unter dem AuthBloc legt diese Blocs bei jeder Anmeldung (neuer Fahrer oder Re-Login) komplett neu an; alles wird frisch vom Server geholt. Token-Refresh desselben Fahrers zählt nicht als neue Anmeldung. Zahlungsmethoden werden ebenfalls nach jeder Anmeldung neu geladen. Co-Authored-By: Claude Opus 5.5 --- lib/widget/app.dart | 135 ++++++++-------------------------- lib/widget/session_scope.dart | 123 +++++++++++++++++++++++++++++++ test/session_scope_test.dart | 126 +++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 103 deletions(-) create mode 100644 lib/widget/session_scope.dart create mode 100644 test/session_scope_test.dart diff --git a/lib/widget/app.dart b/lib/widget/app.dart index 5aa3c31..3a0e133 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -1,32 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/bloc/app_bloc.dart'; -import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart'; import 'package:hl_lieferservice/main.dart' show locator; -import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart'; -import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; -import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart'; -import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; -import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.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:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; -import 'package:hl_lieferservice/data/repository/tour_repository_impl.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_state.dart'; import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart'; +import 'package:hl_lieferservice/widget/session_scope.dart'; import 'package:hl_lieferservice/bloc/app_states.dart'; import 'home/presentation/home.dart'; @@ -67,62 +56,6 @@ class _DeliveryAppState extends State { // und ggf. direkt einloggen. ..add(const RestoreSessionRequested()), ), - BlocProvider( - // Phase-C+D-2-Migration: produktive TourRepository-Impl - // gegen das generierte Rust-Backend-API. Account-Filter - // serverseitig aus dem JWT, deshalb braucht der Bloc - // keinen AuthBloc-Bezug mehr. - create: (context) => TourBloc( - tourRepository: TourRepositoryImpl(locator()), - opBloc: context.read(), - attachmentCache: locator(), - ), - ), - BlocProvider( - // Hält das im Header gewählte Tour-Datum (null = heute). - // Steuert, welchen Tag der TourBloc lädt. - create: (context) => TourDateCubit(), - ), - BlocProvider( - create: (context) => - CarSelectBloc( - repository: CarSelectionRepository( - environmentId: state.active.id, - ), - ), - ), - BlocProvider( - // Phase-D-Migration: produktive CarsRepository-Impl - // gegen das generierte Rust-Backend-API. Account-Filter - // serverseitig aus dem JWT, deshalb braucht der Bloc - // keinen AuthBloc-Bezug mehr. - create: (context) => CarsBloc( - repository: CarsRepositoryImpl(locator()), - opBloc: context.read(), - ), - ), - BlocProvider( - // PhaseBloc liest die Team-Fahrzeug-Anzahl jetzt direkt - // aus dem CarsBloc — der ist die alleinige Quelle der - // Fahrzeug-Stammdaten. Beim ersten Load eines Fahrzeugs - // bestimmt das die Eintrittsphase (Auswählen vs. Sortieren). - create: (context) => PhaseBloc( - carCountResolver: () { - final carsState = context.read().state; - return carsState is CarsLoaded - ? carsState.cars.length - : null; - }, - // Bindet die persistierten Phasen-Häkchen an die aktuelle - // Tour-Version (Tour.syncedAt). Ein erneuter Sync/Seed - // schreibt eine neue syncedAt → neuer Token → frische - // Phasen, ohne dass alte lokale Häkchen hängen bleiben. - // Ableitung zentral in `TourStatePhaseToken` (tour_state.dart), - // dieselbe Quelle wie `Home._ensurePhaseLoaded`. - tourTokenResolver: () => - context.read().state.phaseToken, - ), - ), BlocProvider( // Zahlungsmethoden sind firmenweite Stammdaten — wir laden // sie einmal beim App-Start und cachen sie im Cubit. Der @@ -134,46 +67,42 @@ class _DeliveryAppState extends State { )..load(), ), ], - child: MaterialApp( - title: 'Holzleitner Auslieferung', - debugShowCheckedModeBanner: false, - // 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. - builder: (context, child) => - OperationViewEnforcer(child: child ?? const SizedBox.shrink()), - home: BlocBuilder( - builder: (context, state) { - if (state is AppConfigLoading) { - return Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } + // Fahrerbezogene Blocs (Tour, Fahrzeuge, Auswahl, Phase) je + // Anmeldung neu — siehe SessionScope. + child: SessionScope( + environmentId: state.active.id, + child: MaterialApp( + title: 'Holzleitner Auslieferung', + debugShowCheckedModeBanner: false, + // 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. + builder: (context, child) => + OperationViewEnforcer(child: child ?? const SizedBox.shrink()), + home: BlocBuilder( + builder: (context, state) { + if (state is AppConfigLoading) { + return Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } - if (state is AppConfigLoadingFailed) { - return Scaffold(body: Center(child: Text(state.message))); - } + if (state is AppConfigLoadingFailed) { + return Scaffold(body: Center(child: Text(state.message))); + } - if (state is AppConfigLoaded) { - // Stammdaten nach JEDEM erfolgreichen (Re-)Login neu laden. - // Der Cubit lädt zwar beim App-Start — aber ohne Session - // endet das in 401 („Sitzung abgelaufen") und blieb bisher - // für immer im Fehlerzustand hängen, auch nach dem Login. - return BlocListener( - listenWhen: (prev, curr) => - curr is Authenticated && prev is! Authenticated, - listener: (context, _) => - context.read().load(), - child: LoginEnforcer( + if (state is AppConfigLoaded) { + // Zahlungsmethoden lädt SessionScope nach jeder Anmeldung neu. + return LoginEnforcer( child: CarSelectionEnforcer(child: Home()), - ), - ); - } + ); + } - return Container(); - }, + return Container(); + }, + ), + routes: {"/cars": (context) => CarManagementPage()}, ), - routes: {"/cars": (context) => CarManagementPage()}, ), ); } diff --git a/lib/widget/session_scope.dart b/lib/widget/session_scope.dart new file mode 100644 index 0000000..9d89b05 --- /dev/null +++ b/lib/widget/session_scope.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart'; +import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.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/repository/car_selection_repository.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.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_state.dart'; +import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; +import 'package:hl_lieferservice/main.dart' show locator; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; +import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; + +/// Fahrerbezogene Blocs — pro Anmeldung frisch. +/// +/// Tour, gewähltes Datum, Fahrzeuge, Fahrzeugauswahl und Phase gehören zum +/// angemeldeten Fahrer. Sie leben unterhalb des [AuthBloc] und werden bei +/// JEDER Anmeldung (neuer Fahrer oder derselbe nach Abmelden/Session-Ablauf) +/// komplett neu angelegt: Nichts vom vorherigen Fahrer bleibt im Speicher, +/// alles wird neu vom Server geholt. Ein bloßer Token-Refresh +/// (Authenticated → Authenticated, gleicher Fahrer) zählt nicht als neue +/// Anmeldung. +/// +/// [child] ist die `MaterialApp`, damit auch gepushte Routen (Lieferdetails, +/// Fahrzeugverwaltung) die Session-Blocs sehen. Eine neue Sitzung baut sie +/// deshalb mit neu auf — der Navigator startet wieder bei Home. +class SessionScope extends StatefulWidget { + const SessionScope({ + super.key, + required this.environmentId, + required this.child, + }); + + /// Aktive Server-Umgebung; die Fahrzeugauswahl wird je Umgebung gespeichert. + final String environmentId; + final Widget child; + + @override + State createState() => _SessionScopeState(); +} + +class _SessionScopeState extends State { + /// Zählt Anmeldungen; Teil des Keys, damit jede Anmeldung einen neuen + /// Bloc-Baum erzeugt. + int _session = 0; + int? _personalnummer; + + bool _startsNewSession(AuthState previous, AuthState current) { + if (current is! Authenticated) return false; + return previous is! Authenticated || + previous.personalnummer != current.personalnummer; + } + + void _onAuthChanged(BuildContext context, AuthState state) { + final authenticated = state as Authenticated; + setState(() { + _session++; + _personalnummer = authenticated.personalnummer; + }); + // Firmenweite Stammdaten liegen eine Ebene höher; ohne Session endet + // deren Start-Load in 401 — daher nach jeder Anmeldung neu laden. + context.read().load(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: _startsNewSession, + listener: _onAuthChanged, + child: MultiBlocProvider( + key: ValueKey('session-$_session-$_personalnummer'), + providers: [ + BlocProvider( + // Account-Filter serverseitig aus dem JWT. + create: (context) => TourBloc( + tourRepository: TourRepositoryImpl(locator()), + opBloc: context.read(), + attachmentCache: locator(), + ), + ), + BlocProvider( + // Im Header gewähltes Tour-Datum (null = heute). + create: (context) => TourDateCubit(), + ), + BlocProvider( + create: (context) => CarSelectBloc( + repository: CarSelectionRepository( + environmentId: widget.environmentId, + ), + ), + ), + BlocProvider( + // Fahrzeuge des angemeldeten Accounts (aus dem JWT). + create: (context) => CarsBloc( + repository: CarsRepositoryImpl(locator()), + opBloc: context.read(), + ), + ), + BlocProvider( + // Eintrittsphase hängt an der Fahrzeug-Anzahl (CarsBloc) und an + // der Tour-Version (Tour.syncedAt, siehe `TourStatePhaseToken`). + create: (context) => PhaseBloc( + carCountResolver: () { + final carsState = context.read().state; + return carsState is CarsLoaded ? carsState.cars.length : null; + }, + tourTokenResolver: () => + context.read().state.phaseToken, + ), + ), + ], + child: widget.child, + ), + ); + } +} diff --git a/test/session_scope_test.dart b/test/session_scope_test.dart new file mode 100644 index 0000000..c86fa3e --- /dev/null +++ b/test/session_scope_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:hl_lieferservice/data/network/auth_session_event.dart'; +import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; +import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; +import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; +import 'package:hl_lieferservice/widget/session_scope.dart'; + +/// Token-Provider ohne Keycloak: der Test steuert Login/Logout über [emit]. +class _FakeTokenProvider implements KeycloakOidcTokenProvider { + final _events = StreamController.broadcast(); + + void emit(AuthSessionEvent event) => _events.add(event); + + @override + Stream get events => _events.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _CountingPaymentMethods implements PaymentMethodsRepository { + int loads = 0; + + @override + Future> list({bool includeInactive = false}) async { + loads++; + return const []; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +AuthLoggedIn _login(int personalnummer) => + AuthLoggedIn({'personalnummer': personalnummer, 'name': 'Fahrer'}); + +void main() { + late _FakeTokenProvider provider; + late _CountingPaymentMethods payments; + late List carSelections; + late List tourDates; + + Future pumpApp(WidgetTester tester) async { + provider = _FakeTokenProvider(); + payments = _CountingPaymentMethods(); + carSelections = []; + tourDates = []; + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => OperationBloc()), + BlocProvider( + create: (context) => AuthBloc( + tokenProvider: provider, + operationBloc: context.read(), + ), + ), + BlocProvider( + create: (_) => PaymentMethodsCubit(repository: payments), + ), + ], + child: SessionScope( + environmentId: 'test', + child: MaterialApp( + home: Builder( + builder: (context) { + carSelections.add(context.read()); + tourDates.add(context.read()); + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + } + + Future send(WidgetTester tester, AuthSessionEvent event) async { + provider.emit(event); + await tester.pumpAndSettle(); + } + + testWidgets('jede Anmeldung bekommt frische Fahrer-Blocs', (tester) async { + await pumpApp(tester); + + await send(tester, _login(423)); + final fuer423 = carSelections.last; + expect(payments.loads, 1); + + // Abmelden und anderer Fahrer: nichts von 423 darf übrig bleiben. + await send(tester, const AuthLoggedOut()); + await send(tester, _login(414)); + expect(carSelections.last, isNot(same(fuer423))); + expect(tourDates.last, isNot(same(tourDates.first))); + expect(payments.loads, 2); + + // Auch derselbe Fahrer nach erneutem Login lädt neu. + final fuer414 = carSelections.last; + await send(tester, const AuthLoggedOut()); + await send(tester, _login(414)); + expect(carSelections.last, isNot(same(fuer414))); + expect(payments.loads, 3); + }); + + testWidgets('Token-Refresh desselben Fahrers behält die Blocs', + (tester) async { + await pumpApp(tester); + await send(tester, _login(423)); + final vorher = carSelections.last; + + await send(tester, _login(423)); + + expect(carSelections.last, same(vorher)); + expect(payments.loads, 1); + }); +}