feat(auth): Login-Timeout (10s) mit Hinweisbanner

Haengt der interaktive Login (Browser-Tab/Token-Exchange) bei Verbindungsabbruch/Issuer-Hang, bricht er nach 10s ab; LoginPage zeigt 'Einloggen nicht moeglich. Spaeter erneut versuchen.' (Unauthenticated.loginTimedOut).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-18 13:08:18 +02:00
parent 7544760c34
commit 467f4b4ed2
4 changed files with 55 additions and 5 deletions

View File

@ -48,15 +48,32 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
late final StreamSubscription<AuthSessionEvent> _subscription; late final StreamSubscription<AuthSessionEvent> _subscription;
/// Timeout für den interaktiven Login. Schützt den Fahrer vor dem
/// „Spinner dreht ewig"-Fall, wenn der native `flutter_appauth`-Aufruf
/// (Browser-Tab + Token-Exchange) im Verbindungsabbruch / Issuer-Hang
/// stecken bleibt. Bewusst etwas knapper als der Restore-Timeout
/// (15 s im `_handleRestore`) — beim manuellen Login wartet der User
/// aktiv vor dem Bildschirm.
static const Duration _loginTimeout = Duration(seconds: 10);
Future<void> _handleLogin( Future<void> _handleLogin(
LoginRequested event, LoginRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
try { try {
emit(Authenticating()); emit(Authenticating());
await tokenProvider.login(); await tokenProvider.login().timeout(_loginTimeout);
// Erfolg landet via Stream-Subscription als // Erfolg landet via Stream-Subscription als
// ProviderSessionChanged.loggedIn → _handleProviderEvent. // ProviderSessionChanged.loggedIn → _handleProviderEvent.
} on TimeoutException {
// Native Login-Routine läuft im Hintergrund ggf. weiter. Sollte sie
// doch noch erfolgreich durchlaufen, feuert der Provider-Stream
// später ein `AuthLoggedIn` — der ProviderEvent-Handler hebt den
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
// Anmeldung". Bei späterer Exception passiert nichts; das Future
// ist hier nicht mehr awaited.
debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.');
emit(Unauthenticated(loginTimedOut: true));
} catch (err, st) { } catch (err, st) {
debugPrint('Login fehlgeschlagen: $err\n$st'); debugPrint('Login fehlgeschlagen: $err\n$st');
emit(Unauthenticated()); emit(Unauthenticated());

View File

@ -10,7 +10,14 @@ class AuthBootstrapping extends AuthState {}
class Unauthenticated extends AuthState { class Unauthenticated extends AuthState {
final bool sessionExpired; final bool sessionExpired;
Unauthenticated({this.sessionExpired = false});
/// `true`, wenn der letzte Login-Versuch in das 10-s-Timeout im
/// [AuthBloc] gelaufen ist (z. B. Verbindungsabbruch während
/// `tokenProvider.login()` oder hängender Issuer). Die [LoginPage]
/// blendet daraufhin einen Hinweis ein.
final bool loginTimedOut;
Unauthenticated({this.sessionExpired = false, this.loginTimedOut = false});
} }
/// Transient state während dem PKCE-Flow (Browser-Tab offen, /// Transient state während dem PKCE-Flow (Browser-Tab offen,

View File

@ -28,8 +28,11 @@ class LoginEnforcer extends StatelessWidget {
if (state is AuthBootstrapping) { if (state is AuthBootstrapping) {
return const _AuthBootstrapSplash(); return const _AuthBootstrapSplash();
} }
final expired = state is Unauthenticated && state.sessionExpired; final unauth = state is Unauthenticated ? state : null;
return LoginPage(sessionExpired: expired); return LoginPage(
sessionExpired: unauth?.sessionExpired ?? false,
loginTimedOut: unauth?.loginTimedOut ?? false,
);
}, },
); );
} }

View File

@ -12,10 +12,19 @@ import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
/// Browser-Tab, der RedirectURI `holzleitner://oauth2redirect` kommt /// Browser-Tab, der RedirectURI `holzleitner://oauth2redirect` kommt
/// zurück, der Code wird gegen Tokens getauscht. /// zurück, der Code wird gegen Tokens getauscht.
class LoginPage extends StatelessWidget { class LoginPage extends StatelessWidget {
const LoginPage({super.key, this.sessionExpired = false}); const LoginPage({
super.key,
this.sessionExpired = false,
this.loginTimedOut = false,
});
final bool sessionExpired; final bool sessionExpired;
/// 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.
final bool loginTimedOut;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -36,6 +45,20 @@ class LoginPage extends StatelessWidget {
), ),
actions: const [SizedBox.shrink()], actions: const [SizedBox.shrink()],
), ),
if (loginTimedOut)
MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
content: const Text(
'Einloggen nicht möglich. Später erneut versuchen.',
style: TextStyle(color: Colors.white),
),
backgroundColor: Colors.red.shade700,
leading: const Icon(
Icons.cloud_off,
color: Colors.white,
),
actions: const [SizedBox.shrink()],
),
Expanded( Expanded(
child: Center( child: Center(
child: Column( child: Column(