import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.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/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/main.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; /// AuthBloc bridge between der UI und dem [KeycloakOidcTokenProvider]. /// /// Eingehende UI-Events triggern den Provider (Login/Logout/Restore). /// Provider-Ereignisse kommen über den `events`-Stream rein, werden zu /// `ProviderSessionChanged` übersetzt und vom Bloc in Zustände überführt. class AuthBloc extends Bloc { AuthBloc({required this.tokenProvider, required this.operationBloc}) : super(AuthBootstrapping()) { on(_handleLogin); on(_handleLogout); on(_handleRestore); on(_handleProviderEvent); // Legacy: alte ERPframe-Repos rufen authBloc.add(SessionExpiredEvent()) // bei jedem 401, weil ihr Cookie-Login serverseitig nicht mehr // existiert. Solange Phase D diese Repos nicht ersetzt hat, wäre // ein echter Logout daraus fatal: der erste TourBloc-Load nach // erfolgreicher Keycloak-Anmeldung würde die Session sofort wieder // wegwerfen. Daher hier nur loggen, **nicht** ausloggen — die // legitime SessionExpired-Quelle ist der Provider-Stream // (AuthSessionExpired bei Refresh-Failure). on((event, emit) async { debugPrint( '[AuthBloc] SessionExpiredEvent aus Legacy-Repo ignoriert — ' 'echter SessionExpired kommt vom KeycloakOidcTokenProvider.', ); }); _subscription = tokenProvider.events.listen( (event) => add(_toBlocEvent(event)), ); } final KeycloakOidcTokenProvider tokenProvider; final OperationBloc operationBloc; late final StreamSubscription _subscription; Future _handleLogin( LoginRequested event, Emitter emit, ) async { try { emit(Authenticating()); await tokenProvider.login(); // Erfolg landet via Stream-Subscription als // ProviderSessionChanged.loggedIn → _handleProviderEvent. } catch (err, st) { debugPrint('Login fehlgeschlagen: $err\n$st'); emit(Unauthenticated()); operationBloc.add( FailOperation( message: 'Login war nicht erfolgreich. Probieren Sie es erneut.', ), ); } } Future _handleLogout( LogoutRequested event, Emitter emit, ) async { await tokenProvider.logout(); // Provider feuert AuthLoggedOut → _handleProviderEvent setzt den State. } Future _handleRestore( RestoreSessionRequested event, Emitter emit, ) async { try { // Timeout-Schutz: hängt der Restore (z. B. nativer flutter_appauth- // Token-Call nach Hot-Restart, nicht erreichbarer Issuer), darf der // Bootstrap NICHT ewig im Splash bleiben. Nach dem Timeout fallen wir // 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( const Duration(seconds: 15), onTimeout: () => false, ); 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()); } // Erfolg landet via Stream als ProviderSessionChanged.loggedIn, // der Handler emittiert Authenticated. } catch (err, st) { debugPrint('Restore-Session fehlgeschlagen: $err\n$st'); emit(Unauthenticated()); } } Future _handleProviderEvent( ProviderSessionChanged event, Emitter emit, ) async { switch (event.kind) { case ProviderEventKind.loggedIn: try { final state = Authenticated.fromClaims( claims: event.claims ?? const {}, accessToken: event.accessToken ?? '', ); if (locator.isRegistered()) { locator.unregister(); } locator.registerSingleton(state); emit(state); } catch (err, st) { debugPrint('Konnte Claims nicht in Authenticated-State mappen: $err\n$st'); emit(Unauthenticated()); operationBloc.add( FailOperation( message: 'Login-Antwort vom Server unvollständig. Bitte erneut versuchen.', ), ); } case ProviderEventKind.loggedOut: if (locator.isRegistered()) { locator.unregister(); } emit(Unauthenticated()); case ProviderEventKind.sessionExpired: if (locator.isRegistered()) { locator.unregister(); } emit(Unauthenticated(sessionExpired: true)); } } ProviderSessionChanged _toBlocEvent(AuthSessionEvent event) { return switch (event) { AuthLoggedIn(:final claims) => ProviderSessionChanged( ProviderEventKind.loggedIn, claims: claims, // Wir würden hier idealerweise den Access-Token mit übergeben. // Da der Provider den Token nicht im Stream-Event mitliefert, // bauen wir später eine Brücke. Für jetzt: leerer String — // Authenticated.sessionId wird mit Phase D ohnehin abgeschafft. accessToken: '', ), AuthLoggedOut() => const ProviderSessionChanged( ProviderEventKind.loggedOut, ), AuthSessionExpired() => const ProviderSessionChanged( ProviderEventKind.sessionExpired, ), }; } @override Future close() async { await _subscription.cancel(); return super.close(); } }