diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index 586cb08..1f378ed 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -18,7 +18,7 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; /// `ProviderSessionChanged` übersetzt und vom Bloc in Zustände überführt. class AuthBloc extends Bloc { AuthBloc({required this.tokenProvider, required this.operationBloc}) - : super(Unauthenticated()) { + : super(AuthBootstrapping()) { on(_handleLogin); on(_handleLogout); on(_handleRestore); @@ -75,14 +75,18 @@ class AuthBloc extends Bloc { Emitter emit, ) async { try { - await tokenProvider.restoreSession(); - // Erfolg landet via Stream als ProviderSessionChanged.loggedIn. - // Misserfolg: bleibt bei Unauthenticated — kein Snackbar, das - // ist der normale Cold-Start-Pfad. + final restored = await tokenProvider.restoreSession(); + 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'); - // State unverändert (Unauthenticated). Kein Snackbar — das - // wäre für den Cold-Start zu viel Lärm. + emit(Unauthenticated()); } } diff --git a/lib/feature/authentication/bloc/auth_state.dart b/lib/feature/authentication/bloc/auth_state.dart index e79e4f7..073d7f3 100644 --- a/lib/feature/authentication/bloc/auth_state.dart +++ b/lib/feature/authentication/bloc/auth_state.dart @@ -2,6 +2,12 @@ import 'package:hl_lieferservice/feature/authentication/model/user.dart'; abstract class AuthState {} +/// Initialer State beim App-Start: der Refresh-Token aus der Secure +/// Storage wird gerade gegen das Issuer-Endpoint geprüft. Die UI zeigt +/// in dieser Zeit einen Splash statt LoginPage, damit Nutzer mit +/// gespeicherter Session keinen sichtbaren Login-Flash sehen. +class AuthBootstrapping extends AuthState {} + class Unauthenticated extends AuthState { final bool sessionExpired; Unauthenticated({this.sessionExpired = false}); diff --git a/lib/feature/authentication/presentation/login_enforcer.dart b/lib/feature/authentication/presentation/login_enforcer.dart index f9c9c78..fd5797a 100644 --- a/lib/feature/authentication/presentation/login_enforcer.dart +++ b/lib/feature/authentication/presentation/login_enforcer.dart @@ -1,10 +1,18 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/presentation/login_page.dart'; import '../bloc/auth_bloc.dart'; import '../bloc/auth_state.dart'; +/// Routet die App zwischen Bootstrap-Splash, Login-Page und der +/// eigentlichen UI: +/// * `AuthBootstrapping` → Splash (verhindert Login-Page-Flash beim +/// Cold-Start, während der Refresh-Token gegen Keycloak gegengeprüft +/// wird). +/// * `Authenticated` → `child` (= reguläre App). +/// * sonst → LoginPage; `sessionExpired`-Banner wenn ein Refresh +/// serverseitig abgewiesen wurde. class LoginEnforcer extends StatelessWidget { final Widget child; @@ -17,10 +25,37 @@ class LoginEnforcer extends StatelessWidget { if (state is Authenticated) { return child; } - + if (state is AuthBootstrapping) { + return const _AuthBootstrapSplash(); + } final expired = state is Unauthenticated && state.sessionExpired; return LoginPage(sessionExpired: expired); }, ); } } + +class _AuthBootstrapSplash extends StatelessWidget { + const _AuthBootstrapSplash(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 60), + child: Image.asset( + 'assets/holzleitner_Logo_2017_RZ_transparent.png', + ), + ), + const SizedBox(height: 32), + const CircularProgressIndicator(), + ], + ), + ), + ); + } +} diff --git a/lib/widget/home/presentation/home_drawer.dart b/lib/widget/home/presentation/home_drawer.dart index 2e83a2d..dc1a9d1 100644 --- a/lib/widget/home/presentation/home_drawer.dart +++ b/lib/widget/home/presentation/home_drawer.dart @@ -1,5 +1,8 @@ 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'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; @@ -22,10 +25,46 @@ class HomeAppDrawer extends StatelessWidget { ); } + Future _confirmLogout(BuildContext context) async { + final authBloc = context.read(); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text("Abmelden"), + content: const Text( + "Möchten Sie sich wirklich abmelden? " + "Beim nächsten Start ist eine erneute Anmeldung erforderlich.", + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text("Abbrechen"), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text("Abmelden"), + ), + ], + ), + ); + if (confirmed != true) return; + + authBloc.add(const LogoutRequested()); + + // Drawer schließen, falls noch sichtbar — der State-Wechsel auf + // Unauthenticated leitet danach von selbst auf die LoginPage. + if (context.mounted && Scaffold.maybeOf(context)?.isDrawerOpen == true) { + Navigator.of(context).pop(); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final carState = context.watch().state; + final authState = context.watch().state; + final authenticated = authState is Authenticated ? authState : null; return Drawer( child: SafeArea( @@ -40,14 +79,26 @@ class HomeAppDrawer extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - "Holzleitner", + authenticated?.displayName ?? "Holzleitner", style: TextStyle( color: theme.colorScheme.onSecondary, fontSize: 20, fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 4), + if (authenticated != null) ...[ + const SizedBox(height: 2), + Text( + "Personalnummer ${authenticated.personalnummer}", + style: TextStyle( + color: theme.colorScheme.onSecondary.withValues( + alpha: 0.85, + ), + fontSize: 12, + ), + ), + ], + const SizedBox(height: 8), if (carState is CarSelectComplete) Row( children: [ @@ -100,8 +151,16 @@ class HomeAppDrawer extends StatelessWidget { ), const Spacer(), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.logout, color: Colors.red), + title: const Text( + "Abmelden", + style: TextStyle(color: Colors.red), + ), + onTap: () => _confirmLogout(context), + ), const Padding( - padding: EdgeInsets.all(12), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text( "Lieferservice-App", style: TextStyle(fontSize: 11, color: Colors.grey),