From 2ea18503ef58296a61c6c2912c17bb6d99a0b8f9 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 07:35:34 +0200 Subject: [PATCH] feat: enhance routing logic and improve login screen validation; add guarded API call for error handling --- flutter/lib/core/api/guarded_api_call.dart | 18 +++ flutter/lib/core/router/app_router.dart | 31 +++-- .../auth/presentation/login_screen.dart | 113 +++++++++++------- .../recipes/data/recipe_providers.dart | 9 +- 4 files changed, 117 insertions(+), 54 deletions(-) create mode 100644 flutter/lib/core/api/guarded_api_call.dart diff --git a/flutter/lib/core/api/guarded_api_call.dart b/flutter/lib/core/api/guarded_api_call.dart new file mode 100644 index 00000000..89284503 --- /dev/null +++ b/flutter/lib/core/api/guarded_api_call.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_exception.dart'; +import '../../features/auth/data/auth_providers.dart'; + +/// Executes [call] and automatically logs out the user if the server +/// returns 401 Unauthorized. Re-throws the exception so the calling +/// widget/provider can still display an error if needed. +Future guardedApiCall(Ref ref, Future Function() call) async { + try { + return await call(); + } on ApiException catch (e) { + if (e.type == ApiErrorType.unauthorized) { + await ref.read(authStateProvider.notifier).logout(); + } + rethrow; + } +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index b25d0cd0..5542d557 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -1,12 +1,13 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../core/ui/app_shell.dart'; +import '../ui/app_shell.dart'; +import '../ui/async_state_views.dart'; import '../../features/auth/data/auth_providers.dart'; -import '../../features/recipes/presentation/recipes_screen.dart'; import '../../features/auth/presentation/login_screen.dart'; import '../../features/profile/presentation/profile_screen.dart'; +import '../../features/recipes/presentation/recipes_screen.dart'; final appRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); @@ -17,29 +18,39 @@ final appRouterProvider = Provider((ref) { final isLoading = authState.isLoading; final token = authState.valueOrNull; final isLoggedIn = token != null && token.isNotEmpty; - final isLoginRoute = state.matchedLocation == '/login'; - final isRootRoute = state.matchedLocation == '/'; + final location = state.matchedLocation; + final isSplash = location == '/'; + final isLogin = location == '/login'; + // Keep user on splash while auth state is being resolved from storage. if (isLoading) { - return null; + return isSplash ? null : '/'; } - if (isRootRoute) { + // Redirect away from splash once auth is known. + if (isSplash) { return isLoggedIn ? '/recipes' : '/login'; } - if (!isLoggedIn && !isLoginRoute) { + // Unauthenticated user trying to reach a protected route. + if (!isLoggedIn && !isLogin) { return '/login'; } - if (isLoggedIn && isLoginRoute) { + // Authenticated user landing on login. + if (isLoggedIn && isLogin) { return '/recipes'; } return null; }, routes: [ - GoRoute(path: '/', builder: (context, state) => const SizedBox.shrink()), + GoRoute( + path: '/', + builder: (context, state) => const Scaffold( + body: LoadingStateView(label: 'Startar...'), + ), + ), GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), diff --git a/flutter/lib/features/auth/presentation/login_screen.dart b/flutter/lib/features/auth/presentation/login_screen.dart index 7c28b2bf..fb95cfee 100644 --- a/flutter/lib/features/auth/presentation/login_screen.dart +++ b/flutter/lib/features/auth/presentation/login_screen.dart @@ -13,73 +13,102 @@ class LoginScreen extends ConsumerStatefulWidget { } class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); final _usernameCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); + final _passwordFocus = FocusNode(); @override void dispose() { _usernameCtrl.dispose(); _passwordCtrl.dispose(); + _passwordFocus.dispose(); super.dispose(); } Future _submit() async { - if (_usernameCtrl.text.trim().isEmpty || _passwordCtrl.text.isEmpty) { + if (!(_formKey.currentState?.validate() ?? false)) { return; } await ref.read(authStateProvider.notifier).login( _usernameCtrl.text.trim(), _passwordCtrl.text, ); - if (mounted) { - final state = ref.read(authStateProvider); - if (state is AsyncData && state.value != null) { - if (context.mounted) context.go('/recipes'); - } - } + // Router redirect handles navigation when authStateProvider updates. } @override Widget build(BuildContext context) { final authState = ref.watch(authStateProvider); + final isLoading = authState is AsyncLoading; + return Scaffold( appBar: AppBar(title: const Text('Logga in')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextField( - controller: _usernameCtrl, - decoration: const InputDecoration(labelText: 'Anvandarnamn'), - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - TextField( - controller: _passwordCtrl, - decoration: const InputDecoration(labelText: 'Lösenord'), - obscureText: true, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - ), - const SizedBox(height: 24), - if (authState is AsyncLoading) - const CircularProgressIndicator() - else - ElevatedButton( - onPressed: _submit, - child: const Text('Logga in'), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _usernameCtrl, + decoration: + const InputDecoration(labelText: 'Användarnamn'), + textInputAction: TextInputAction.next, + autofocus: true, + enabled: !isLoading, + onFieldSubmitted: (_) => + FocusScope.of(context).requestFocus(_passwordFocus), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Ange ditt användarnamn.'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordCtrl, + focusNode: _passwordFocus, + decoration: const InputDecoration(labelText: 'Lösenord'), + obscureText: true, + textInputAction: TextInputAction.done, + enabled: !isLoading, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Ange ditt lösenord.'; + } + return null; + }, + ), + const SizedBox(height: 28), + if (isLoading) + const Center(child: CircularProgressIndicator()) + else + FilledButton( + onPressed: _submit, + child: const Text('Logga in'), + ), + if (authState is AsyncError) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + mapErrorToUserMessage(authState.error!), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error), + ), + ), + ], ), - if (authState is AsyncError) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - mapErrorToUserMessage(authState.error!), - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - ], + ), + ), ), ), ); diff --git a/flutter/lib/features/recipes/data/recipe_providers.dart b/flutter/lib/features/recipes/data/recipe_providers.dart index 78e6d4a3..10339d5c 100644 --- a/flutter/lib/features/recipes/data/recipe_providers.dart +++ b/flutter/lib/features/recipes/data/recipe_providers.dart @@ -1,8 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../core/api/api_providers.dart'; +import '../../../core/api/guarded_api_call.dart'; import '../../../features/auth/data/auth_providers.dart'; -import 'recipe_repository.dart'; import '../domain/recipe.dart'; +import 'recipe_repository.dart'; final recipeRepositoryProvider = Provider((ref) { return RecipeRepository(ref.watch(apiClientProvider)); @@ -10,5 +12,8 @@ final recipeRepositoryProvider = Provider((ref) { final recipesProvider = FutureProvider>((ref) async { final token = await ref.watch(authStateProvider.future); - return ref.watch(recipeRepositoryProvider).fetchRecipes(token: token); + return guardedApiCall( + ref, + () => ref.read(recipeRepositoryProvider).fetchRecipes(token: token), + ); });