From b1fe53eddaabe20be1d185fad0ad9955dff89e50 Mon Sep 17 00:00:00 2001 From: honguyenminh Date: Fri, 15 May 2026 22:29:46 +0700 Subject: [PATCH] Add nav, auth global state and basic flow --- .gitignore | 1 + lib/core/converter/date_time_converter.dart | 4 +- lib/core/env/env.dart | 5 +- lib/core/network/auth_path_policy.dart | 40 +++++ .../interceptors/auth_interceptor.dart | 83 +++++++-- .../interceptors/logger_interceptor.dart | 4 +- .../network/providers/network_provider.dart | 36 ++-- .../network/providers/network_provider.g.dart | 65 ++++++- .../storage/providers/storage_provider.dart | 1 - .../storage/providers/token_provider.dart | 6 +- lib/core/storage/token_storage.dart | 101 +++++++++-- lib/features/auth/api/auth_authed_api.dart | 14 ++ lib/features/auth/api/auth_public_api.dart | 19 ++ lib/features/auth/api/auth_repository.dart | 28 +++ lib/features/auth/api/dto/login_request.dart | 16 ++ lib/features/auth/api/dto/login_response.dart | 23 +++ lib/features/auth/api/dto/logout_request.dart | 15 ++ .../auth/api/dto/refresh_request.dart | 15 ++ .../api/providers/auth_api_providers.dart | 16 ++ .../providers/auth_repository_provider.dart | 13 ++ lib/features/auth/auth_routes.dart | 14 ++ lib/features/auth/data/api/auth_api.dart | 16 -- lib/features/auth/data/api/auth_api.g.dart | 78 -------- lib/features/auth/data/dto/login_request.dart | 22 --- .../auth/data/dto/login_request.g.dart | 15 -- .../auth/data/dto/login_response.dart | 22 --- .../auth/data/dto/login_response.g.dart | 19 -- .../auth/data/providers/api_provider.dart | 10 -- .../auth/data/providers/api_provider.g.dart | 51 ------ .../providers/auth_repository_provider.dart | 11 -- .../providers/auth_repository_provider.g.dart | 51 ------ .../auth/data/repository/auth_repository.dart | 23 --- lib/features/auth/login/login_controller.dart | 27 +++ lib/features/auth/login/login_screen.dart | 96 ++++++++++ lib/features/auth/login/login_state.dart | 13 ++ lib/features/auth/model/app_role.dart | 56 ++++++ lib/features/auth/model/auth_session.dart | 45 +++++ .../auth/model/auth_user_snapshot.dart | 30 ++++ lib/features/auth/model/jwt_payload.dart | 62 +++++++ .../auth/onboarding/onboarding_screen.dart | 94 ++++++++++ .../auth/presentation/login_screen.dart | 119 ------------ lib/features/auth/service/auth_service.dart | 20 --- .../providers/auth_service_provider.dart | 14 -- .../providers/auth_service_provider.g.dart | 51 ------ .../auth/session/auth_session_controller.dart | 170 ++++++++++++++++++ .../session/auth_session_persistence.dart | 77 ++++++++ .../auth/session/auth_session_state.dart | 28 +++ lib/features/auth/states/login_state.dart | 26 --- .../auth/viewmodel/login_viewmodel.dart | 42 ----- .../auth/viewmodel/login_viewmodel.g.dart | 62 ------- lib/features/debug/debug_screen.dart | 57 ++++++ .../example/data/api/example_api.dart | 16 -- .../example/data/api/example_api.g.dart | 78 -------- .../example/data/dto/create_post_request.dart | 22 --- .../data/dto/create_post_request.g.dart | 19 -- .../data/dto/create_post_response.dart | 22 --- .../data/dto/create_post_response.g.dart | 19 -- .../data/providers/example_api_provider.dart | 10 -- .../providers/example_api_provider.g.dart | 51 ------ .../example_repository_provider.dart | 10 -- .../example_repository_provider.g.dart | 57 ------ .../data/repository/example_repository.dart | 17 -- .../example/presentation/example_screen.dart | 70 -------- .../example/states/example_state.dart | 32 ---- .../example/viewmodel/example_viewmodel.dart | 46 ----- .../viewmodel/example_viewmodel.g.dart | 62 ------- lib/main.dart | 136 ++++---------- lib/nav/app_router.dart | 122 +++++++++++++ lib/nav/platform_back.dart | 44 +++++ lib/theme/app_theme.dart | 7 + pubspec.lock | 24 +++ pubspec.yaml | 3 + 72 files changed, 1446 insertions(+), 1347 deletions(-) create mode 100644 lib/core/network/auth_path_policy.dart create mode 100644 lib/features/auth/api/auth_authed_api.dart create mode 100644 lib/features/auth/api/auth_public_api.dart create mode 100644 lib/features/auth/api/auth_repository.dart create mode 100644 lib/features/auth/api/dto/login_request.dart create mode 100644 lib/features/auth/api/dto/login_response.dart create mode 100644 lib/features/auth/api/dto/logout_request.dart create mode 100644 lib/features/auth/api/dto/refresh_request.dart create mode 100644 lib/features/auth/api/providers/auth_api_providers.dart create mode 100644 lib/features/auth/api/providers/auth_repository_provider.dart create mode 100644 lib/features/auth/auth_routes.dart delete mode 100644 lib/features/auth/data/api/auth_api.dart delete mode 100644 lib/features/auth/data/api/auth_api.g.dart delete mode 100644 lib/features/auth/data/dto/login_request.dart delete mode 100644 lib/features/auth/data/dto/login_request.g.dart delete mode 100644 lib/features/auth/data/dto/login_response.dart delete mode 100644 lib/features/auth/data/dto/login_response.g.dart delete mode 100644 lib/features/auth/data/providers/api_provider.dart delete mode 100644 lib/features/auth/data/providers/api_provider.g.dart delete mode 100644 lib/features/auth/data/providers/auth_repository_provider.dart delete mode 100644 lib/features/auth/data/providers/auth_repository_provider.g.dart delete mode 100644 lib/features/auth/data/repository/auth_repository.dart create mode 100644 lib/features/auth/login/login_controller.dart create mode 100644 lib/features/auth/login/login_screen.dart create mode 100644 lib/features/auth/login/login_state.dart create mode 100644 lib/features/auth/model/app_role.dart create mode 100644 lib/features/auth/model/auth_session.dart create mode 100644 lib/features/auth/model/auth_user_snapshot.dart create mode 100644 lib/features/auth/model/jwt_payload.dart create mode 100644 lib/features/auth/onboarding/onboarding_screen.dart delete mode 100644 lib/features/auth/presentation/login_screen.dart delete mode 100644 lib/features/auth/service/auth_service.dart delete mode 100644 lib/features/auth/service/providers/auth_service_provider.dart delete mode 100644 lib/features/auth/service/providers/auth_service_provider.g.dart create mode 100644 lib/features/auth/session/auth_session_controller.dart create mode 100644 lib/features/auth/session/auth_session_persistence.dart create mode 100644 lib/features/auth/session/auth_session_state.dart delete mode 100644 lib/features/auth/states/login_state.dart delete mode 100644 lib/features/auth/viewmodel/login_viewmodel.dart delete mode 100644 lib/features/auth/viewmodel/login_viewmodel.g.dart create mode 100644 lib/features/debug/debug_screen.dart delete mode 100644 lib/features/example/data/api/example_api.dart delete mode 100644 lib/features/example/data/api/example_api.g.dart delete mode 100644 lib/features/example/data/dto/create_post_request.dart delete mode 100644 lib/features/example/data/dto/create_post_request.g.dart delete mode 100644 lib/features/example/data/dto/create_post_response.dart delete mode 100644 lib/features/example/data/dto/create_post_response.g.dart delete mode 100644 lib/features/example/data/providers/example_api_provider.dart delete mode 100644 lib/features/example/data/providers/example_api_provider.g.dart delete mode 100644 lib/features/example/data/providers/example_repository_provider.dart delete mode 100644 lib/features/example/data/providers/example_repository_provider.g.dart delete mode 100644 lib/features/example/data/repository/example_repository.dart delete mode 100644 lib/features/example/presentation/example_screen.dart delete mode 100644 lib/features/example/states/example_state.dart delete mode 100644 lib/features/example/viewmodel/example_viewmodel.dart delete mode 100644 lib/features/example/viewmodel/example_viewmodel.g.dart create mode 100644 lib/nav/app_router.dart create mode 100644 lib/nav/platform_back.dart diff --git a/.gitignore b/.gitignore index 11eb94d..612bda2 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ app.*.map.json /android/app/release .env +*.g.dart \ No newline at end of file diff --git a/lib/core/converter/date_time_converter.dart b/lib/core/converter/date_time_converter.dart index fe59cdb..81f97b2 100644 --- a/lib/core/converter/date_time_converter.dart +++ b/lib/core/converter/date_time_converter.dart @@ -1,7 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; class UtcDateTimeConverter implements JsonConverter { - const UtcDateTimeConverter(); @override @@ -13,5 +12,4 @@ class UtcDateTimeConverter implements JsonConverter { String toJson(DateTime object) { return object.toUtc().toIso8601String(); } - -} \ No newline at end of file +} diff --git a/lib/core/env/env.dart b/lib/core/env/env.dart index 77281f2..ee0e8cf 100644 --- a/lib/core/env/env.dart +++ b/lib/core/env/env.dart @@ -1,6 +1,5 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; class Env { - static String get baseUrl => - dotenv.env["BASE_URL"] ?? 'http://10.0.2.2:8080'; -} \ No newline at end of file + static String get baseUrl => dotenv.env["BASE_URL"] ?? 'http://10.0.2.2:8080'; +} diff --git a/lib/core/network/auth_path_policy.dart b/lib/core/network/auth_path_policy.dart new file mode 100644 index 0000000..014340a --- /dev/null +++ b/lib/core/network/auth_path_policy.dart @@ -0,0 +1,40 @@ +/// Auth route classification for [AuthInterceptor] (path only, no query). +class AuthPathPolicy { + const AuthPathPolicy._(); + + /// No bearer; no refresh-on-401 retry. + static const anonymousPaths = { + '/auth/login', + '/auth/register', + '/auth/refresh', + '/auth/external', + }; + + /// Bearer required; lives under `/auth/*` but is not anonymous. + static const protectedPaths = { + '/auth/logout', + '/auth/credentials', + '/auth/link-external', + '/auth/unlink-external', + '/auth/password', + }; + + static String normalize(String path) => Uri.parse(path).path; + + static bool isAnonymous(String path) => + anonymousPaths.contains(normalize(path)); + + static bool isProtectedAuth(String path) => + protectedPaths.contains(normalize(path)); + + static bool requiresBearer(String path) { + final normalized = normalize(path); + if (isAnonymous(normalized)) { + return false; + } + if (normalized.startsWith('/auth/')) { + return isProtectedAuth(normalized); + } + return true; + } +} diff --git a/lib/core/network/interceptors/auth_interceptor.dart b/lib/core/network/interceptors/auth_interceptor.dart index 5cfa609..713b9f5 100644 --- a/lib/core/network/interceptors/auth_interceptor.dart +++ b/lib/core/network/interceptors/auth_interceptor.dart @@ -1,34 +1,91 @@ import 'package:dio/dio.dart'; +import 'package:food4student_next/core/network/auth_path_policy.dart'; import 'package:food4student_next/core/storage/token_storage.dart'; class AuthInterceptor extends Interceptor { + static const _retriedKey = 'auth_retried'; - static const _ignoreRequestPattern = "/auth/"; // Replace with real auth endpoints pattern - + final Dio dio; final TokenStorage tokenStorage; + final Future Function() tryRefresh; + final Future Function() onRefreshFailed; + Future? _ongoingRefresh; AuthInterceptor({ - required this.tokenStorage + required this.tokenStorage, + required this.dio, + required this.tryRefresh, + required this.onRefreshFailed, }); @override - Future onRequest (RequestOptions options, RequestInterceptorHandler handler) async{ + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final path = AuthPathPolicy.normalize(options.path); final token = await tokenStorage.getAccessToken(); - if (token != null && _isRequireAuthorizationHeader(options)) { - options.headers['Authorization'] = "Bearer $token"; + if (token != null && AuthPathPolicy.requiresBearer(path)) { + options.headers['Authorization'] = 'Bearer $token'; } - super.onRequest(options, handler); + handler.next(options); } @override - Future onError(DioException err, ErrorInterceptorHandler handler) async { - // TODO: implement onError to handle refresh token flow - super.onError(err, handler); + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final request = err.requestOptions; + final statusCode = err.response?.statusCode; + final alreadyRetried = request.extra[_retriedKey] == true; + final path = AuthPathPolicy.normalize(request.path); + + final shouldAttemptRefresh = + statusCode == 401 && + AuthPathPolicy.requiresBearer(path) && + !AuthPathPolicy.isProtectedAuth(path) && + !alreadyRetried; + if (!shouldAttemptRefresh) { + handler.next(err); + return; + } + + final refreshed = await _refreshSingleFlight(); + if (!refreshed) { + await onRefreshFailed(); + handler.next(err); + return; + } + + try { + final retryRequest = _cloneForRetry(request); + final response = await dio.fetch(retryRequest); + handler.resolve(response); + } on DioException catch (retryErr) { + handler.next(retryErr); + } + } + + Future _refreshSingleFlight() async { + if (_ongoingRefresh != null) { + return _ongoingRefresh!; + } + final pending = tryRefresh(); + _ongoingRefresh = pending; + try { + return await pending; + } finally { + _ongoingRefresh = null; + } } - bool _isRequireAuthorizationHeader(RequestOptions options) { - return !options.path.contains(_ignoreRequestPattern); + RequestOptions _cloneForRetry(RequestOptions request) { + return request.copyWith( + headers: Map.from(request.headers), + extra: {...request.extra, _retriedKey: true}, + ); } -} \ No newline at end of file +} diff --git a/lib/core/network/interceptors/logger_interceptor.dart b/lib/core/network/interceptors/logger_interceptor.dart index c7de609..b3e9aee 100644 --- a/lib/core/network/interceptors/logger_interceptor.dart +++ b/lib/core/network/interceptors/logger_interceptor.dart @@ -9,7 +9,7 @@ class LoggerInterceptor extends Interceptor { lineLength: 75, colors: true, printEmojis: true, - dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, ), ); @@ -34,4 +34,4 @@ class LoggerInterceptor extends Interceptor { logger.d('StatusCode: ${response.statusCode}, Data: ${response.data}'); return super.onResponse(response, handler); } -} \ No newline at end of file +} diff --git a/lib/core/network/providers/network_provider.dart b/lib/core/network/providers/network_provider.dart index cf49ad2..9cbae56 100644 --- a/lib/core/network/providers/network_provider.dart +++ b/lib/core/network/providers/network_provider.dart @@ -3,30 +3,44 @@ import 'package:food4student_next/core/env/env.dart'; import 'package:food4student_next/core/network/interceptors/auth_interceptor.dart'; import 'package:food4student_next/core/network/interceptors/logger_interceptor.dart'; import 'package:food4student_next/core/storage/providers/token_provider.dart'; +import 'package:food4student_next/features/auth/session/auth_session_controller.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; - part 'network_provider.g.dart'; +/// Anonymous API client: no bearer, no refresh-on-401 (login, refresh, etc.). @Riverpod(keepAlive: true) -Dio dio(Ref ref) { +Dio anonDio(Ref ref) { const timeoutDuration = 10; - final dio = Dio( + final client = Dio( BaseOptions( baseUrl: Env.baseUrl, connectTimeout: const Duration(seconds: timeoutDuration), receiveTimeout: const Duration(seconds: timeoutDuration), - headers: { - 'Content-Type': 'application/json', - }, + headers: {'Content-Type': 'application/json'}, ), ); - dio.interceptors.addAll([ + client.interceptors.addAll([LoggerInterceptor()]); + + return client; +} + +/// Authenticated API client: bearer + 401 refresh + retry. +@Riverpod(keepAlive: true) +Dio authedDio(Ref ref) { + final client = Dio(ref.watch(anonDioProvider).options); + client.interceptors.addAll([ LoggerInterceptor(), - AuthInterceptor(tokenStorage: ref.watch(tokenStorageProvider)), + AuthInterceptor( + tokenStorage: ref.watch(tokenStorageProvider), + dio: client, + tryRefresh: ref.read(authSessionControllerProvider.notifier).refresh, + onRefreshFailed: ref + .read(authSessionControllerProvider.notifier) + .forceLogoutLocal, + ), ]); - - return dio; -} \ No newline at end of file + return client; +} diff --git a/lib/core/network/providers/network_provider.g.dart b/lib/core/network/providers/network_provider.g.dart index f4123c1..7ba83c4 100644 --- a/lib/core/network/providers/network_provider.g.dart +++ b/lib/core/network/providers/network_provider.g.dart @@ -8,25 +8,74 @@ part of 'network_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Anonymous API client: no bearer, no refresh-on-401 (login, refresh, etc.). -@ProviderFor(dio) -final dioProvider = DioProvider._(); +@ProviderFor(anonDio) +final anonDioProvider = AnonDioProvider._(); -final class DioProvider extends $FunctionalProvider +/// Anonymous API client: no bearer, no refresh-on-401 (login, refresh, etc.). + +final class AnonDioProvider extends $FunctionalProvider + with $Provider { + /// Anonymous API client: no bearer, no refresh-on-401 (login, refresh, etc.). + AnonDioProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'anonDioProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$anonDioHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Dio create(Ref ref) { + return anonDio(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Dio value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$anonDioHash() => r'253247127d828c6ff742ca64ea21efce742edb2c'; + +/// Authenticated API client: bearer + 401 refresh + retry. + +@ProviderFor(authedDio) +final authedDioProvider = AuthedDioProvider._(); + +/// Authenticated API client: bearer + 401 refresh + retry. + +final class AuthedDioProvider extends $FunctionalProvider with $Provider { - DioProvider._() + /// Authenticated API client: bearer + 401 refresh + retry. + AuthedDioProvider._() : super( from: null, argument: null, retry: null, - name: r'dioProvider', + name: r'authedDioProvider', isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$dioHash(); + String debugGetCreateSourceHash() => _$authedDioHash(); @$internal @override @@ -35,7 +84,7 @@ final class DioProvider extends $FunctionalProvider @override Dio create(Ref ref) { - return dio(ref); + return authedDio(ref); } /// {@macro riverpod.override_with_value} @@ -47,4 +96,4 @@ final class DioProvider extends $FunctionalProvider } } -String _$dioHash() => r'a6c72820679848291715623ed380398f11572945'; +String _$authedDioHash() => r'b7a7801413eba85303992d1838323c2f2855e538'; diff --git a/lib/core/storage/providers/storage_provider.dart b/lib/core/storage/providers/storage_provider.dart index dd1c8c8..16ff113 100644 --- a/lib/core/storage/providers/storage_provider.dart +++ b/lib/core/storage/providers/storage_provider.dart @@ -7,4 +7,3 @@ part 'storage_provider.g.dart'; FlutterSecureStorage secureStorage(Ref ref) { return const FlutterSecureStorage(); } - diff --git a/lib/core/storage/providers/token_provider.dart b/lib/core/storage/providers/token_provider.dart index fef40b1..c63e534 100644 --- a/lib/core/storage/providers/token_provider.dart +++ b/lib/core/storage/providers/token_provider.dart @@ -6,7 +6,5 @@ part 'token_provider.g.dart'; @Riverpod(keepAlive: true) TokenStorage tokenStorage(Ref ref) { - return TokenStorage( - ref.watch(secureStorageProvider), - ); -} \ No newline at end of file + return TokenStorage(ref.watch(secureStorageProvider)); +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index abcd69d..77255ec 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,37 +1,108 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/model/auth_session.dart'; +import 'package:food4student_next/features/auth/model/auth_user_snapshot.dart'; +import 'dart:convert'; class TokenStorage { static const _accessTokenKey = 'access_token'; + static const _accessTokenExpiresAtUtcKey = 'access_token_expires_at_utc'; static const _refreshTokenKey = 'refresh_token'; + static const _refreshTokenExpiresAtUtcKey = 'refresh_token_expires_at_utc'; + static const _userSnapshotJsonKey = 'user_snapshot_json'; final FlutterSecureStorage storage; TokenStorage(this.storage); - Future saveAccessToken(String token) async { + Future saveSession(AuthSession session) async { + await storage.write(key: _accessTokenKey, value: session.accessToken); await storage.write( - key: _accessTokenKey, - value: token, + key: _accessTokenExpiresAtUtcKey, + value: session.accessTokenExpiresAtUtc.toUtc().toIso8601String(), ); - } - - Future saveRefreshToken(String token) async { + await storage.write(key: _refreshTokenKey, value: session.refreshToken); await storage.write( - key: _refreshTokenKey, - value: token, + key: _refreshTokenExpiresAtUtcKey, + value: session.refreshTokenExpiresAtUtc.toUtc().toIso8601String(), ); + final user = session.user; + if (user == null) { + await storage.delete(key: _userSnapshotJsonKey); + return; + } + final userJson = { + 'userId': user.userId, + 'email': user.email, + 'roleClaims': user.roleClaims, + 'primaryRole': user.primaryRole?.name, + }; + await storage.write(key: _userSnapshotJsonKey, value: jsonEncode(userJson)); } - Future getAccessToken() { - return storage.read(key: _accessTokenKey); - } + Future readSession() async { + final accessToken = await storage.read(key: _accessTokenKey); + final refreshToken = await storage.read(key: _refreshTokenKey); + final accessTokenExpiresAtRaw = await storage.read( + key: _accessTokenExpiresAtUtcKey, + ); + final refreshTokenExpiresAtRaw = await storage.read( + key: _refreshTokenExpiresAtUtcKey, + ); + + if (accessToken == null || + refreshToken == null || + accessTokenExpiresAtRaw == null || + refreshTokenExpiresAtRaw == null) { + return null; + } - Future getRefreshToken() { - return storage.read(key: _refreshTokenKey); + final accessTokenExpiresAtUtc = DateTime.parse( + accessTokenExpiresAtRaw, + ).toUtc(); + final refreshTokenExpiresAtUtc = DateTime.parse( + refreshTokenExpiresAtRaw, + ).toUtc(); + + final userSnapshotRaw = await storage.read(key: _userSnapshotJsonKey); + AuthUserSnapshot? user; + if (userSnapshotRaw != null) { + try { + final parsed = jsonDecode(userSnapshotRaw) as Map; + final roleClaims = (parsed['roleClaims'] as List? ?? []) + .map((e) => e.toString()) + .toList(growable: false); + final roleName = parsed['primaryRole']?.toString(); + final primaryRole = AppRole.values.where((r) => r.name == roleName); + user = AuthUserSnapshot( + userId: parsed['userId']?.toString() ?? '', + email: parsed['email']?.toString() ?? '', + roleClaims: roleClaims, + primaryRole: primaryRole.isEmpty ? null : primaryRole.first, + ); + } on Object { + user = null; + } + } + + return AuthSession( + accessToken: accessToken, + accessTokenExpiresAtUtc: accessTokenExpiresAtUtc, + refreshToken: refreshToken, + refreshTokenExpiresAtUtc: refreshTokenExpiresAtUtc, + user: user, + ); } - Future clearTokens() async { + Future clearSession() async { await storage.delete(key: _accessTokenKey); + await storage.delete(key: _accessTokenExpiresAtUtcKey); await storage.delete(key: _refreshTokenKey); + await storage.delete(key: _refreshTokenExpiresAtUtcKey); + await storage.delete(key: _userSnapshotJsonKey); + } + + Future getAccessToken() { + return storage.read(key: _accessTokenKey); } -} \ No newline at end of file +} diff --git a/lib/features/auth/api/auth_authed_api.dart b/lib/features/auth/api/auth_authed_api.dart new file mode 100644 index 0000000..4e1c056 --- /dev/null +++ b/lib/features/auth/api/auth_authed_api.dart @@ -0,0 +1,14 @@ +import 'package:dio/dio.dart'; +import 'package:food4student_next/features/auth/api/dto/logout_request.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'auth_authed_api.g.dart'; + +/// Bearer-protected auth endpoints (uses [authedDio]). +@RestApi() +abstract class AuthAuthedApi { + factory AuthAuthedApi(Dio dio, {String? baseUrl}) = _AuthAuthedApi; + + @POST('/auth/logout') + Future logout(@Body() LogoutRequest request); +} diff --git a/lib/features/auth/api/auth_public_api.dart b/lib/features/auth/api/auth_public_api.dart new file mode 100644 index 0000000..7a3c282 --- /dev/null +++ b/lib/features/auth/api/auth_public_api.dart @@ -0,0 +1,19 @@ +import 'package:dio/dio.dart'; +import 'package:food4student_next/features/auth/api/dto/login_request.dart'; +import 'package:food4student_next/features/auth/api/dto/login_response.dart'; +import 'package:food4student_next/features/auth/api/dto/refresh_request.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'auth_public_api.g.dart'; + +/// Anonymous auth endpoints (uses [anonDio]). +@RestApi() +abstract class AuthPublicApi { + factory AuthPublicApi(Dio dio, {String? baseUrl}) = _AuthPublicApi; + + @POST('/auth/login') + Future login(@Body() LoginRequest request); + + @POST('/auth/refresh') + Future refresh(@Body() RefreshRequest request); +} diff --git a/lib/features/auth/api/auth_repository.dart b/lib/features/auth/api/auth_repository.dart new file mode 100644 index 0000000..6423dfc --- /dev/null +++ b/lib/features/auth/api/auth_repository.dart @@ -0,0 +1,28 @@ +import 'package:food4student_next/features/auth/api/auth_authed_api.dart'; +import 'package:food4student_next/features/auth/api/auth_public_api.dart'; +import 'package:food4student_next/features/auth/api/dto/logout_request.dart'; +import 'package:food4student_next/features/auth/api/dto/login_request.dart'; +import 'package:food4student_next/features/auth/api/dto/login_response.dart'; +import 'package:food4student_next/features/auth/api/dto/refresh_request.dart'; + +class AuthRepository { + AuthRepository(this.publicApi, this.authedApi); + + final AuthPublicApi publicApi; + final AuthAuthedApi authedApi; + + Future login({ + required String email, + required String password, + }) { + return publicApi.login(LoginRequest(email: email, password: password)); + } + + Future refresh({required String refreshToken}) { + return publicApi.refresh(RefreshRequest(refreshToken: refreshToken)); + } + + Future logout({required String refreshToken}) { + return authedApi.logout(LogoutRequest(refreshToken: refreshToken)); + } +} diff --git a/lib/features/auth/api/dto/login_request.dart b/lib/features/auth/api/dto/login_request.dart new file mode 100644 index 0000000..054021f --- /dev/null +++ b/lib/features/auth/api/dto/login_request.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'login_request.g.dart'; + +@JsonSerializable() +class LoginRequest { + final String email; + final String password; + + LoginRequest({required this.email, required this.password}); + + factory LoginRequest.fromJson(Map json) => + _$LoginRequestFromJson(json); + + Map toJson() => _$LoginRequestToJson(this); +} diff --git a/lib/features/auth/api/dto/login_response.dart b/lib/features/auth/api/dto/login_response.dart new file mode 100644 index 0000000..43c7d22 --- /dev/null +++ b/lib/features/auth/api/dto/login_response.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'login_response.g.dart'; + +@JsonSerializable() +class LoginResponse { + const LoginResponse({ + required this.accessToken, + required this.accessTokenExpiresAtUtc, + required this.refreshToken, + required this.refreshTokenExpiresAtUtc, + }); + + final String accessToken; + final DateTime accessTokenExpiresAtUtc; + final String refreshToken; + final DateTime refreshTokenExpiresAtUtc; + + factory LoginResponse.fromJson(Map json) => + _$LoginResponseFromJson(json); + + Map toJson() => _$LoginResponseToJson(this); +} diff --git a/lib/features/auth/api/dto/logout_request.dart b/lib/features/auth/api/dto/logout_request.dart new file mode 100644 index 0000000..5bc4731 --- /dev/null +++ b/lib/features/auth/api/dto/logout_request.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'logout_request.g.dart'; + +@JsonSerializable() +class LogoutRequest { + const LogoutRequest({required this.refreshToken}); + + final String refreshToken; + + factory LogoutRequest.fromJson(Map json) => + _$LogoutRequestFromJson(json); + + Map toJson() => _$LogoutRequestToJson(this); +} diff --git a/lib/features/auth/api/dto/refresh_request.dart b/lib/features/auth/api/dto/refresh_request.dart new file mode 100644 index 0000000..ca6b979 --- /dev/null +++ b/lib/features/auth/api/dto/refresh_request.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'refresh_request.g.dart'; + +@JsonSerializable() +class RefreshRequest { + const RefreshRequest({required this.refreshToken}); + + final String refreshToken; + + factory RefreshRequest.fromJson(Map json) => + _$RefreshRequestFromJson(json); + + Map toJson() => _$RefreshRequestToJson(this); +} diff --git a/lib/features/auth/api/providers/auth_api_providers.dart b/lib/features/auth/api/providers/auth_api_providers.dart new file mode 100644 index 0000000..f9665bc --- /dev/null +++ b/lib/features/auth/api/providers/auth_api_providers.dart @@ -0,0 +1,16 @@ +import 'package:food4student_next/core/network/providers/network_provider.dart'; +import 'package:food4student_next/features/auth/api/auth_authed_api.dart'; +import 'package:food4student_next/features/auth/api/auth_public_api.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_api_providers.g.dart'; + +@Riverpod(keepAlive: true) +AuthPublicApi authPublicApi(Ref ref) { + return AuthPublicApi(ref.watch(anonDioProvider)); +} + +@Riverpod(keepAlive: true) +AuthAuthedApi authAuthedApi(Ref ref) { + return AuthAuthedApi(ref.watch(authedDioProvider)); +} diff --git a/lib/features/auth/api/providers/auth_repository_provider.dart b/lib/features/auth/api/providers/auth_repository_provider.dart new file mode 100644 index 0000000..0d140b9 --- /dev/null +++ b/lib/features/auth/api/providers/auth_repository_provider.dart @@ -0,0 +1,13 @@ +import 'package:food4student_next/features/auth/api/auth_repository.dart'; +import 'package:food4student_next/features/auth/api/providers/auth_api_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_repository_provider.g.dart'; + +@Riverpod(keepAlive: true) +AuthRepository authRepository(Ref ref) { + return AuthRepository( + ref.watch(authPublicApiProvider), + ref.watch(authAuthedApiProvider), + ); +} diff --git a/lib/features/auth/auth_routes.dart b/lib/features/auth/auth_routes.dart new file mode 100644 index 0000000..0543ee0 --- /dev/null +++ b/lib/features/auth/auth_routes.dart @@ -0,0 +1,14 @@ +import 'package:food4student_next/features/auth/login/login_screen.dart'; +import 'package:food4student_next/features/auth/onboarding/onboarding_screen.dart'; +import 'package:go_router/go_router.dart'; + +const authLoginPath = '/login'; +const authOnboardingPath = '/onboarding'; + +GoRoute get authLoginRoute => + GoRoute(path: authLoginPath, builder: (context, state) => const LoginScreen()); + +GoRoute get authOnboardingRoute => GoRoute( + path: authOnboardingPath, + builder: (context, state) => const OnboardingScreen(), +); diff --git a/lib/features/auth/data/api/auth_api.dart b/lib/features/auth/data/api/auth_api.dart deleted file mode 100644 index 6539a84..0000000 --- a/lib/features/auth/data/api/auth_api.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:food4student_next/features/auth/data/dto/login_request.dart'; -import 'package:food4student_next/features/auth/data/dto/login_response.dart'; -import 'package:retrofit/retrofit.dart'; - -part 'auth_api.g.dart'; - -@RestApi() -abstract class AuthApi { - factory AuthApi(Dio dio) = _AuthApi; - - @POST('/auth/login') - Future login( - @Body() LoginRequest request, - ); -} \ No newline at end of file diff --git a/lib/features/auth/data/api/auth_api.g.dart b/lib/features/auth/data/api/auth_api.g.dart deleted file mode 100644 index 0d3961b..0000000 --- a/lib/features/auth/data/api/auth_api.g.dart +++ /dev/null @@ -1,78 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'auth_api.dart'; - -// dart format off - -// ************************************************************************** -// RetrofitGenerator -// ************************************************************************** - -// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter,avoid_unused_constructor_parameters,unreachable_from_main,avoid_redundant_argument_values - -class _AuthApi implements AuthApi { - _AuthApi(this._dio, {this.baseUrl, this.errorLogger}); - - final Dio _dio; - - String? baseUrl; - - final ParseErrorLogger? errorLogger; - - @override - Future login(LoginRequest request) async { - final _extra = {}; - final queryParameters = {}; - final _headers = {}; - final _data = {}; - _data.addAll(request.toJson()); - final _options = _setStreamType( - Options(method: 'POST', headers: _headers, extra: _extra) - .compose( - _dio.options, - '/auth/login', - queryParameters: queryParameters, - data: _data, - ) - .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), - ); - final _result = await _dio.fetch>(_options); - late LoginResponse _value; - try { - _value = LoginResponse.fromJson(_result.data!); - } on Object catch (e, s) { - errorLogger?.logError(e, s, _options, response: _result); - rethrow; - } - return _value; - } - - RequestOptions _setStreamType(RequestOptions requestOptions) { - if (T != dynamic && - !(requestOptions.responseType == ResponseType.bytes || - requestOptions.responseType == ResponseType.stream)) { - if (T == String) { - requestOptions.responseType = ResponseType.plain; - } else { - requestOptions.responseType = ResponseType.json; - } - } - return requestOptions; - } - - String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { - if (baseUrl == null || baseUrl.trim().isEmpty) { - return dioBaseUrl; - } - - final url = Uri.parse(baseUrl); - - if (url.isAbsolute) { - return url.toString(); - } - - return Uri.parse(dioBaseUrl).resolveUri(url).toString(); - } -} - -// dart format on diff --git a/lib/features/auth/data/dto/login_request.dart b/lib/features/auth/data/dto/login_request.dart deleted file mode 100644 index 5485255..0000000 --- a/lib/features/auth/data/dto/login_request.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'login_request.g.dart'; - -@JsonSerializable() -class LoginRequest { - final String email; - final String password; - - LoginRequest({ - required this.email, - required this.password, - }); - - factory LoginRequest.fromJson( - Map json, - ) => - _$LoginRequestFromJson(json); - - Map toJson() => - _$LoginRequestToJson(this); -} \ No newline at end of file diff --git a/lib/features/auth/data/dto/login_request.g.dart b/lib/features/auth/data/dto/login_request.g.dart deleted file mode 100644 index 0282188..0000000 --- a/lib/features/auth/data/dto/login_request.g.dart +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'login_request.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LoginRequest _$LoginRequestFromJson(Map json) => LoginRequest( - email: json['email'] as String, - password: json['password'] as String, -); - -Map _$LoginRequestToJson(LoginRequest instance) => - {'email': instance.email, 'password': instance.password}; diff --git a/lib/features/auth/data/dto/login_response.dart b/lib/features/auth/data/dto/login_response.dart deleted file mode 100644 index f0a7228..0000000 --- a/lib/features/auth/data/dto/login_response.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'login_response.g.dart'; - -@JsonSerializable() -class LoginResponse { - final String accessToken; - final String refreshToken; - - LoginResponse({ - required this.accessToken, - required this.refreshToken - }); - - factory LoginResponse.fromJson( - Map json, - ) => - _$LoginResponseFromJson(json); - - Map toJson() => - _$LoginResponseToJson(this); -} \ No newline at end of file diff --git a/lib/features/auth/data/dto/login_response.g.dart b/lib/features/auth/data/dto/login_response.g.dart deleted file mode 100644 index 2bcfec4..0000000 --- a/lib/features/auth/data/dto/login_response.g.dart +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'login_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LoginResponse _$LoginResponseFromJson(Map json) => - LoginResponse( - accessToken: json['accessToken'] as String, - refreshToken: json['refreshToken'] as String, - ); - -Map _$LoginResponseToJson(LoginResponse instance) => - { - 'accessToken': instance.accessToken, - 'refreshToken': instance.refreshToken, - }; diff --git a/lib/features/auth/data/providers/api_provider.dart b/lib/features/auth/data/providers/api_provider.dart deleted file mode 100644 index 279a9dc..0000000 --- a/lib/features/auth/data/providers/api_provider.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:food4student_next/core/network/providers/network_provider.dart'; -import 'package:food4student_next/features/auth/data/api/auth_api.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'api_provider.g.dart'; - -@riverpod -AuthApi authApi(Ref ref) { - return AuthApi(ref.watch(dioProvider)); -} \ No newline at end of file diff --git a/lib/features/auth/data/providers/api_provider.g.dart b/lib/features/auth/data/providers/api_provider.g.dart deleted file mode 100644 index 854c2e6..0000000 --- a/lib/features/auth/data/providers/api_provider.g.dart +++ /dev/null @@ -1,51 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'api_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(authApi) -final authApiProvider = AuthApiProvider._(); - -final class AuthApiProvider - extends $FunctionalProvider - with $Provider { - AuthApiProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'authApiProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$authApiHash(); - - @$internal - @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - AuthApi create(Ref ref) { - return authApi(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(AuthApi value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$authApiHash() => r'ef5f42290a3d905475d136b0964f80d666cb011f'; diff --git a/lib/features/auth/data/providers/auth_repository_provider.dart b/lib/features/auth/data/providers/auth_repository_provider.dart deleted file mode 100644 index 74f1cd0..0000000 --- a/lib/features/auth/data/providers/auth_repository_provider.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:food4student_next/core/storage/providers/token_provider.dart'; -import 'package:food4student_next/features/auth/data/providers/api_provider.dart'; -import 'package:food4student_next/features/auth/data/repository/auth_repository.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'auth_repository_provider.g.dart'; - -@riverpod -AuthRepository authRepository(Ref ref){ - return AuthRepository(ref.watch(authApiProvider), ref.watch(tokenStorageProvider)); -} \ No newline at end of file diff --git a/lib/features/auth/data/providers/auth_repository_provider.g.dart b/lib/features/auth/data/providers/auth_repository_provider.g.dart deleted file mode 100644 index 64d1226..0000000 --- a/lib/features/auth/data/providers/auth_repository_provider.g.dart +++ /dev/null @@ -1,51 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'auth_repository_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(authRepository) -final authRepositoryProvider = AuthRepositoryProvider._(); - -final class AuthRepositoryProvider - extends $FunctionalProvider - with $Provider { - AuthRepositoryProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'authRepositoryProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$authRepositoryHash(); - - @$internal - @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - AuthRepository create(Ref ref) { - return authRepository(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(AuthRepository value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$authRepositoryHash() => r'65b1d1cc773d246efdeff1cf8989bd9eae86130a'; diff --git a/lib/features/auth/data/repository/auth_repository.dart b/lib/features/auth/data/repository/auth_repository.dart deleted file mode 100644 index e6e5e96..0000000 --- a/lib/features/auth/data/repository/auth_repository.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:food4student_next/core/storage/token_storage.dart'; -import 'package:food4student_next/features/auth/data/api/auth_api.dart'; -import 'package:food4student_next/features/auth/data/dto/login_request.dart'; -import 'package:food4student_next/features/auth/data/dto/login_response.dart'; - -class AuthRepository { - final AuthApi api; - final TokenStorage tokenStorage; - - AuthRepository(this.api, this.tokenStorage); - - Future login({ - required String email, - required String password, - }) async { - return api.login( - LoginRequest( - email: email, - password: password, - ), - ); - } -} \ No newline at end of file diff --git a/lib/features/auth/login/login_controller.dart b/lib/features/auth/login/login_controller.dart new file mode 100644 index 0000000..9927222 --- /dev/null +++ b/lib/features/auth/login/login_controller.dart @@ -0,0 +1,27 @@ +import 'package:food4student_next/features/auth/login/login_state.dart'; +import 'package:food4student_next/features/auth/session/auth_session_controller.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'login_controller.g.dart'; + +@riverpod +class Login extends _$Login { + @override + LoginState build() => const LoginState(); + + Future submit({required String email, required String password}) async { + state = state.copyWith(isSubmitting: true, errorMessage: null); + try { + await ref + .read(authSessionControllerProvider.notifier) + .login(email: email, password: password); + } on Object catch (error) { + state = state.copyWith( + isSubmitting: false, + errorMessage: 'Sign in failed: $error', + ); + return; + } + state = state.copyWith(isSubmitting: false); + } +} diff --git a/lib/features/auth/login/login_screen.dart b/lib/features/auth/login/login_screen.dart new file mode 100644 index 0000000..3abb6bd --- /dev/null +++ b/lib/features/auth/login/login_screen.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:food4student_next/features/auth/login/login_controller.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Temporary email/password login for local testing. Replace with real UI. +class LoginScreen extends HookConsumerWidget { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(GlobalKey.new); + final emailController = useTextEditingController(); + final passwordController = useTextEditingController(); + + final login = ref.watch(loginProvider); + + ref.listen(loginProvider, (previous, next) { + final message = next.errorMessage; + if (message == null || message == previous?.errorMessage) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }); + + Future submit() async { + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + await ref + .read(loginProvider.notifier) + .submit( + email: emailController.text.trim(), + password: passwordController.text, + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Sign in (demo)')), + body: SafeArea( + child: Form( + key: formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Placeholder login — swap this screen for OAuth / real UI.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: emailController, + decoration: const InputDecoration(labelText: 'Email'), + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email is required'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: passwordController, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + autofillHints: const [AutofillHints.password], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Password is required'; + } + return null; + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: login.isSubmitting ? null : submit, + child: login.isSubmitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign in'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/login/login_state.dart b/lib/features/auth/login/login_state.dart new file mode 100644 index 0000000..f0fd969 --- /dev/null +++ b/lib/features/auth/login/login_state.dart @@ -0,0 +1,13 @@ +class LoginState { + const LoginState({this.isSubmitting = false, this.errorMessage}); + + final bool isSubmitting; + final String? errorMessage; + + LoginState copyWith({bool? isSubmitting, String? errorMessage}) { + return LoginState( + isSubmitting: isSubmitting ?? this.isSubmitting, + errorMessage: errorMessage, + ); + } +} diff --git a/lib/features/auth/model/app_role.dart b/lib/features/auth/model/app_role.dart new file mode 100644 index 0000000..227dd1f --- /dev/null +++ b/lib/features/auth/model/app_role.dart @@ -0,0 +1,56 @@ +/// Normalized app roles (must match backend [AppRoleNames]). +enum AppRole { user, restaurantOwner, moderator, admin } + +/// Backend Identity role claim string for [AppRole]. +extension AppRoleClaim on AppRole { + String get claimName => switch (this) { + AppRole.user => 'User', + AppRole.restaurantOwner => 'RestaurantOwner', + AppRole.moderator => 'Moderator', + AppRole.admin => 'Admin', + }; +} + +/// Parses backend Identity role name strings into [AppRole], or null if unknown. +AppRole? appRoleFromClaim(String name) { + switch (name) { + case 'User': + return AppRole.user; + case 'RestaurantOwner': + return AppRole.restaurantOwner; + case 'Moderator': + return AppRole.moderator; + case 'Admin': + return AppRole.admin; + default: + return null; + } +} + +/// Highest privilege first when choosing a single [AppRole] from multiple claims. +const List _rolePrecedence = [ + AppRole.admin, + AppRole.moderator, + AppRole.restaurantOwner, + AppRole.user, +]; + +/// Picks one role from JWT role claims; null if none match known names. +AppRole? primaryAppRoleFromClaims(Iterable roleClaims) { + final matched = {}; + for (final c in roleClaims) { + final r = appRoleFromClaim(c); + if (r != null) { + matched.add(r); + } + } + if (matched.isEmpty) { + return null; + } + for (final r in _rolePrecedence) { + if (matched.contains(r)) { + return r; + } + } + return null; +} diff --git a/lib/features/auth/model/auth_session.dart b/lib/features/auth/model/auth_session.dart new file mode 100644 index 0000000..69808e4 --- /dev/null +++ b/lib/features/auth/model/auth_session.dart @@ -0,0 +1,45 @@ +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/model/auth_user_snapshot.dart'; + +/// Persisted auth session: tokens, server-reported expiries, optional user cache. +class AuthSession { + const AuthSession({ + required this.accessToken, + required this.accessTokenExpiresAtUtc, + required this.refreshToken, + required this.refreshTokenExpiresAtUtc, + this.user, + }); + + final String accessToken; + final DateTime accessTokenExpiresAtUtc; + final String refreshToken; + final DateTime refreshTokenExpiresAtUtc; + final AuthUserSnapshot? user; + + bool get isAccessExpired => + DateTime.now().toUtc().isAfter(accessTokenExpiresAtUtc); + + bool get isRefreshExpired => + DateTime.now().toUtc().isAfter(refreshTokenExpiresAtUtc); + + AppRole? get primaryRole => user?.primaryRole; + + AuthSession copyWith({ + String? accessToken, + DateTime? accessTokenExpiresAtUtc, + String? refreshToken, + DateTime? refreshTokenExpiresAtUtc, + AuthUserSnapshot? user, + }) { + return AuthSession( + accessToken: accessToken ?? this.accessToken, + accessTokenExpiresAtUtc: + accessTokenExpiresAtUtc ?? this.accessTokenExpiresAtUtc, + refreshToken: refreshToken ?? this.refreshToken, + refreshTokenExpiresAtUtc: + refreshTokenExpiresAtUtc ?? this.refreshTokenExpiresAtUtc, + user: user ?? this.user, + ); + } +} diff --git a/lib/features/auth/model/auth_user_snapshot.dart b/lib/features/auth/model/auth_user_snapshot.dart new file mode 100644 index 0000000..a0569ab --- /dev/null +++ b/lib/features/auth/model/auth_user_snapshot.dart @@ -0,0 +1,30 @@ +import 'package:food4student_next/features/auth/model/app_role.dart'; + +/// Cached identity from JWT (informational only; server authorizes requests). +class AuthUserSnapshot { + const AuthUserSnapshot({ + required this.userId, + required this.email, + required this.roleClaims, + this.primaryRole, + }); + + final String userId; + final String email; + final List roleClaims; + final AppRole? primaryRole; + + AuthUserSnapshot copyWith({ + String? userId, + String? email, + List? roleClaims, + AppRole? primaryRole, + }) { + return AuthUserSnapshot( + userId: userId ?? this.userId, + email: email ?? this.email, + roleClaims: roleClaims ?? this.roleClaims, + primaryRole: primaryRole ?? this.primaryRole, + ); + } +} diff --git a/lib/features/auth/model/jwt_payload.dart b/lib/features/auth/model/jwt_payload.dart new file mode 100644 index 0000000..115dab1 --- /dev/null +++ b/lib/features/auth/model/jwt_payload.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/model/auth_user_snapshot.dart'; + +/// Decodes JWT payload (middle segment) without verifying signature. +/// Used only for UI/routing hints; authorization remains on the server. +AuthUserSnapshot? decodeAuthUserFromAccessJwt(String jwt) { + try { + final parts = jwt.split('.'); + if (parts.length != 3) { + return null; + } + final normalized = base64Url.normalize(parts[1]); + final payload = + jsonDecode(utf8.decode(base64Url.decode(normalized))) + as Map; + + final sub = payload['sub']?.toString(); + if (sub == null || sub.isEmpty) { + return null; + } + + final email = payload['email']?.toString() ?? ''; + + final roles = []; + final role = payload['role']; + if (role is String) { + roles.add(role); + } else if (role is List) { + for (final e in role) { + if (e != null) { + roles.add(e.toString()); + } + } + } + // ASP.NET Core ClaimTypes.Role often serializes as full URI in JWT; + // some stacks emit short "role" key — also check common claim names. + final httpSchema = + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role'; + final extra = payload[httpSchema]; + if (extra is String) { + roles.add(extra); + } else if (extra is List) { + for (final e in extra) { + if (e != null) { + roles.add(e.toString()); + } + } + } + + final primary = primaryAppRoleFromClaims(roles); + return AuthUserSnapshot( + userId: sub, + email: email, + roleClaims: roles, + primaryRole: primary, + ); + } on Object { + return null; + } +} diff --git a/lib/features/auth/onboarding/onboarding_screen.dart b/lib/features/auth/onboarding/onboarding_screen.dart new file mode 100644 index 0000000..fc17b31 --- /dev/null +++ b/lib/features/auth/onboarding/onboarding_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/session/auth_session_controller.dart'; +import 'package:go_router/go_router.dart'; + +class OnboardingScreen extends ConsumerWidget { + const OnboardingScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authSessionControllerProvider); + final authNotifier = ref.read(authSessionControllerProvider.notifier); + final email = auth.session?.user?.email; + + return Scaffold( + appBar: AppBar(title: const Text('Onboarding')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'No role assigned yet.', + style: Theme.of(context).textTheme.titleMedium, + ), + if (email != null && email.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('Signed in as $email'), + ], + const SizedBox(height: 8), + Text('Current role: ${auth.primaryRole?.name ?? "none"}'), + if (kDebugMode) ...[ + const SizedBox(height: 24), + Text('Debug', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Text( + 'Assume role updates local session only (not the server). ' + 'Router will redirect to the role home.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final role in AppRole.values) + FilledButton.tonal( + onPressed: () => authNotifier.setDebugRole(role), + child: Text('Assume ${role.name}'), + ), + OutlinedButton( + onPressed: authNotifier.clearDebugRole, + child: const Text('Clear role'), + ), + ], + ), + const SizedBox(height: 24), + Text('Navigate', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: () => context.go('/login'), + child: const Text('Login'), + ), + OutlinedButton( + onPressed: () => context.push('/debug'), + child: const Text('Debug hub'), + ), + for (final route in _debugMockRoutes) + OutlinedButton( + onPressed: () => context.push(route.path), + child: Text(route.label), + ), + FilledButton( + onPressed: authNotifier.logout, + child: const Text('Logout'), + ), + ], + ), + ], + ], + ), + ); + } +} + +const _debugMockRoutes = [ + (label: 'Customer', path: '/customer'), + (label: 'Restaurant owner', path: '/resowner'), + (label: 'Admin', path: '/admin'), +]; diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart deleted file mode 100644 index 7608dce..0000000 --- a/lib/features/auth/presentation/login_screen.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:food4student_next/features/auth/viewmodel/login_viewmodel.dart'; -import 'package:food4student_next/features/example/presentation/example_screen.dart'; - -class LoginScreen extends ConsumerStatefulWidget { - const LoginScreen({super.key}); - - @override - ConsumerState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends ConsumerState { - final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - ref.listen(loginViewModelProvider, (previous, next) { - if (next.errorMessage != null && next.errorMessage != previous?.errorMessage) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Login failed: ${next.errorMessage}'), - ), - ); - return; - } - - if (previous?.isSuccess != true && next.isSuccess) { - if (!mounted) { - return; - } - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => const ExampleScreen(), - ), - ); - } - }); - - final loginState = ref.watch(loginViewModelProvider); - final isLoading = loginState.isLoading; - - return Scaffold( - appBar: AppBar( - title: const Text('Login'), - ), - body: SafeArea( - child: Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - TextFormField( - controller: _emailController, - decoration: const InputDecoration( - labelText: 'Email', - ), - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.username], - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Email is required'; - } - return null; - }, - ), - const SizedBox(height: 12), - TextFormField( - controller: _passwordController, - decoration: const InputDecoration( - labelText: 'Password', - ), - obscureText: true, - autofillHints: const [AutofillHints.password], - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Password is required'; - } - return null; - }, - ), - const SizedBox(height: 24), - FilledButton( - onPressed: isLoading ? null : _submit, - child: isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Login'), - ), - ], - ), - ), - ), - ); - } - - void _submit() { - if (!(_formKey.currentState?.validate() ?? false)) { - return; - } - - ref.read(loginViewModelProvider.notifier).login( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - ); - } -} diff --git a/lib/features/auth/service/auth_service.dart b/lib/features/auth/service/auth_service.dart deleted file mode 100644 index 697c657..0000000 --- a/lib/features/auth/service/auth_service.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:food4student_next/core/storage/token_storage.dart'; -import 'package:food4student_next/features/auth/data/dto/login_response.dart'; -import 'package:food4student_next/features/auth/data/repository/auth_repository.dart'; - -class AuthService { - final AuthRepository authRepository; - final TokenStorage tokenStorage; - - AuthService(this.authRepository, this.tokenStorage); - - Future login({ - required String email, - required String password, - }) async { - LoginResponse response = await authRepository.login(email: email, password: password); - - tokenStorage.saveAccessToken(response.accessToken); - tokenStorage.saveRefreshToken(response.refreshToken); - } -} \ No newline at end of file diff --git a/lib/features/auth/service/providers/auth_service_provider.dart b/lib/features/auth/service/providers/auth_service_provider.dart deleted file mode 100644 index baa87ed..0000000 --- a/lib/features/auth/service/providers/auth_service_provider.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:food4student_next/core/storage/providers/token_provider.dart'; -import 'package:food4student_next/features/auth/data/providers/auth_repository_provider.dart'; -import 'package:food4student_next/features/auth/service/auth_service.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'auth_service_provider.g.dart'; - -@riverpod -AuthService authService(Ref ref) { - return AuthService( - ref.watch(authRepositoryProvider), - ref.watch(tokenStorageProvider), - ); -} \ No newline at end of file diff --git a/lib/features/auth/service/providers/auth_service_provider.g.dart b/lib/features/auth/service/providers/auth_service_provider.g.dart deleted file mode 100644 index 3090a33..0000000 --- a/lib/features/auth/service/providers/auth_service_provider.g.dart +++ /dev/null @@ -1,51 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'auth_service_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(authService) -final authServiceProvider = AuthServiceProvider._(); - -final class AuthServiceProvider - extends $FunctionalProvider - with $Provider { - AuthServiceProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'authServiceProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$authServiceHash(); - - @$internal - @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - AuthService create(Ref ref) { - return authService(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(AuthService value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$authServiceHash() => r'de6284c2ab22ee6235c1b10498a4ca439a792314'; diff --git a/lib/features/auth/session/auth_session_controller.dart b/lib/features/auth/session/auth_session_controller.dart new file mode 100644 index 0000000..7e4bd06 --- /dev/null +++ b/lib/features/auth/session/auth_session_controller.dart @@ -0,0 +1,170 @@ +// ignore_for_file: only_use_keep_alive_inside_keep_alive + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/model/auth_user_snapshot.dart'; +import 'package:food4student_next/features/auth/session/auth_session_persistence.dart'; +import 'package:food4student_next/features/auth/session/auth_session_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_session_controller.g.dart'; + +@Riverpod(keepAlive: true) +class AuthSessionController extends _$AuthSessionController + implements Listenable { + VoidCallback? _routerListener; + Future? _ongoingRefresh; + + @override + AuthSessionState build() { + // Session restore runs in main() before the first frame (native splash stays up). + return const AuthSessionState.unauthenticated(); + } + + @override + void addListener(VoidCallback listener) { + _routerListener = listener; + } + + @override + void removeListener(VoidCallback listener) { + if (_routerListener == listener) { + _routerListener = null; + } + } + + void _setState(AuthSessionState next) { + state = next; + _routerListener?.call(); + } + + Future restoreFromStorage() async { + final persistence = ref.read(authSessionPersistenceProvider); + final stored = await persistence.restoreSession(); + if (stored == null) { + _setState(const AuthSessionState.unauthenticated()); + return; + } + + if (stored.isRefreshExpired) { + await persistence.clearSession(); + _setState(const AuthSessionState.unauthenticated()); + return; + } + + if (stored.isAccessExpired) { + _setState( + AuthSessionState(status: AuthStatus.refreshing, session: stored), + ); + final refreshed = await refresh(); + if (!refreshed) { + _setState(const AuthSessionState.unauthenticated()); + } + return; + } + + _setState(AuthSessionState.authenticated(stored)); + } + + Future login({required String email, required String password}) async { + final persistence = ref.read(authSessionPersistenceProvider); + final session = await persistence.login(email: email, password: password); + _setState(AuthSessionState.authenticated(session)); + } + + Future refresh() async { + if (_ongoingRefresh != null) { + return _ongoingRefresh!; + } + + final completer = Completer(); + _ongoingRefresh = completer.future; + + final persistence = ref.read(authSessionPersistenceProvider); + final current = state.session ?? await persistence.restoreSession(); + if (current == null || current.isRefreshExpired) { + await persistence.clearSession(); + _setState(const AuthSessionState.unauthenticated()); + completer.complete(false); + _ongoingRefresh = null; + return false; + } + + try { + final refreshed = await persistence.refresh( + refreshToken: current.refreshToken, + ); + _setState(AuthSessionState.authenticated(refreshed)); + completer.complete(true); + return true; + } on Object { + await persistence.clearSession(); + _setState(const AuthSessionState.unauthenticated()); + completer.complete(false); + return false; + } finally { + _ongoingRefresh = null; + } + } + + Future logout() async { + final persistence = ref.read(authSessionPersistenceProvider); + final session = state.session; + if (session != null) { + await persistence.logout(refreshToken: session.refreshToken); + } else { + await persistence.clearSession(); + } + _setState(const AuthSessionState.unauthenticated()); + } + + Future forceLogoutLocal() async { + final persistence = ref.read(authSessionPersistenceProvider); + await persistence.clearSession(); + _setState(const AuthSessionState.unauthenticated()); + } + + /// Debug only: sets cached role locally (not on server). Router redirects after. + Future setDebugRole(AppRole role) async { + assert(kDebugMode); + final session = state.session; + if (session == null) { + return; + } + + final existing = session.user; + final user = AuthUserSnapshot( + userId: existing?.userId ?? 'debug-user', + email: existing?.email ?? '', + roleClaims: [role.claimName], + primaryRole: role, + ); + final updated = session.copyWith(user: user); + await ref.read(authSessionPersistenceProvider).persistSession(updated); + _setState(AuthSessionState.authenticated(updated)); + } + + /// Debug only: clears cached role so onboarding redirect applies again. + Future clearDebugRole() async { + assert(kDebugMode); + final session = state.session; + if (session == null) { + return; + } + + final existing = session.user; + final user = existing == null + ? null + : AuthUserSnapshot( + userId: existing.userId, + email: existing.email, + roleClaims: const [], + primaryRole: null, + ); + final updated = session.copyWith(user: user); + await ref.read(authSessionPersistenceProvider).persistSession(updated); + _setState(AuthSessionState.authenticated(updated)); + } +} diff --git a/lib/features/auth/session/auth_session_persistence.dart b/lib/features/auth/session/auth_session_persistence.dart new file mode 100644 index 0000000..8efca43 --- /dev/null +++ b/lib/features/auth/session/auth_session_persistence.dart @@ -0,0 +1,77 @@ +import 'package:food4student_next/core/storage/providers/token_provider.dart'; +import 'package:food4student_next/core/storage/token_storage.dart'; +import 'package:food4student_next/features/auth/api/auth_repository.dart'; +import 'package:food4student_next/features/auth/api/dto/login_response.dart'; +import 'package:food4student_next/features/auth/api/providers/auth_repository_provider.dart'; +import 'package:food4student_next/features/auth/model/auth_session.dart'; +import 'package:food4student_next/features/auth/model/jwt_payload.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_session_persistence.g.dart'; + +class AuthSessionPersistence { + AuthSessionPersistence(this._authRepository, this._tokenStorage); + + final AuthRepository _authRepository; + final TokenStorage _tokenStorage; + + Future login({ + required String email, + required String password, + }) async { + final response = await _authRepository.login( + email: email, + password: password, + ); + final session = _sessionFromResponse(response); + await _tokenStorage.saveSession(session); + return session; + } + + Future refresh({required String refreshToken}) async { + final response = await _authRepository.refresh( + refreshToken: refreshToken, + ); + final session = _sessionFromResponse(response); + await _tokenStorage.saveSession(session); + return session; + } + + Future logout({required String refreshToken}) async { + try { + await _authRepository.logout(refreshToken: refreshToken); + } finally { + await _tokenStorage.clearSession(); + } + } + + Future restoreSession() { + return _tokenStorage.readSession(); + } + + Future clearSession() { + return _tokenStorage.clearSession(); + } + + Future persistSession(AuthSession session) { + return _tokenStorage.saveSession(session); + } + + AuthSession _sessionFromResponse(LoginResponse response) { + return AuthSession( + accessToken: response.accessToken, + accessTokenExpiresAtUtc: response.accessTokenExpiresAtUtc.toUtc(), + refreshToken: response.refreshToken, + refreshTokenExpiresAtUtc: response.refreshTokenExpiresAtUtc.toUtc(), + user: decodeAuthUserFromAccessJwt(response.accessToken), + ); + } +} + +@Riverpod(keepAlive: true) +AuthSessionPersistence authSessionPersistence(Ref ref) { + return AuthSessionPersistence( + ref.watch(authRepositoryProvider), + ref.watch(tokenStorageProvider), + ); +} diff --git a/lib/features/auth/session/auth_session_state.dart b/lib/features/auth/session/auth_session_state.dart new file mode 100644 index 0000000..760643a --- /dev/null +++ b/lib/features/auth/session/auth_session_state.dart @@ -0,0 +1,28 @@ +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/model/auth_session.dart'; + +enum AuthStatus { initializing, refreshing, authenticated, unauthenticated } + +class AuthSessionState { + const AuthSessionState({ + required this.status, + this.session, + this.errorMessage, + }); + + const AuthSessionState.initializing() : this(status: AuthStatus.initializing); + + const AuthSessionState.authenticated(AuthSession session) + : this(status: AuthStatus.authenticated, session: session); + + const AuthSessionState.unauthenticated({String? errorMessage}) + : this(status: AuthStatus.unauthenticated, errorMessage: errorMessage); + + final AuthStatus status; + final AuthSession? session; + final String? errorMessage; + + bool get isLoggedIn => status == AuthStatus.authenticated && session != null; + bool get isRefreshing => status == AuthStatus.refreshing; + AppRole? get primaryRole => session?.primaryRole; +} diff --git a/lib/features/auth/states/login_state.dart b/lib/features/auth/states/login_state.dart deleted file mode 100644 index d908a28..0000000 --- a/lib/features/auth/states/login_state.dart +++ /dev/null @@ -1,26 +0,0 @@ -class LoginState { - static const _error = Object(); - - final bool isLoading; - final String? errorMessage; - final bool isSuccess; - - const LoginState({ - this.isLoading = false, - this.errorMessage, - this.isSuccess = false, - }); - - LoginState copyWith({ - bool? isLoading, - Object? errorMessage = _error, - bool? isSuccess, - }) { - return LoginState( - isLoading: isLoading ?? this.isLoading, - errorMessage: - errorMessage == _error ? this.errorMessage : errorMessage as String?, - isSuccess: isSuccess ?? this.isSuccess, - ); - } -} diff --git a/lib/features/auth/viewmodel/login_viewmodel.dart b/lib/features/auth/viewmodel/login_viewmodel.dart deleted file mode 100644 index 9c7c680..0000000 --- a/lib/features/auth/viewmodel/login_viewmodel.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:food4student_next/features/auth/service/providers/auth_service_provider.dart'; -import 'package:food4student_next/features/auth/states/login_state.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'login_viewmodel.g.dart'; - -@riverpod -class LoginViewModel extends _$LoginViewModel { - @override - LoginState build() { - return const LoginState(); - } - - Future login({ - required String email, - required String password, - }) async { - state = state.copyWith( - isLoading: true, - errorMessage: null, - isSuccess: false, - ); - - try { - await ref.read(authServiceProvider).login( - email: email, - password: password, - ); - state = state.copyWith( - isLoading: false, - errorMessage: null, - isSuccess: true, - ); - } catch (error) { - state = state.copyWith( - isLoading: false, - errorMessage: error.toString(), - isSuccess: false, - ); - } - } -} diff --git a/lib/features/auth/viewmodel/login_viewmodel.g.dart b/lib/features/auth/viewmodel/login_viewmodel.g.dart deleted file mode 100644 index da4ddfa..0000000 --- a/lib/features/auth/viewmodel/login_viewmodel.g.dart +++ /dev/null @@ -1,62 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'login_viewmodel.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(LoginViewModel) -final loginViewModelProvider = LoginViewModelProvider._(); - -final class LoginViewModelProvider - extends $NotifierProvider { - LoginViewModelProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'loginViewModelProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$loginViewModelHash(); - - @$internal - @override - LoginViewModel create() => LoginViewModel(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(LoginState value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$loginViewModelHash() => r'41c2abeb718668ff517ed3574694e17d87125ff4'; - -abstract class _$LoginViewModel extends $Notifier { - LoginState build(); - @$mustCallSuper - @override - void runBuild() { - final ref = this.ref as $Ref; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, - LoginState, - Object?, - Object? - >; - element.handleCreate(ref, build); - } -} diff --git a/lib/features/debug/debug_screen.dart b/lib/features/debug/debug_screen.dart new file mode 100644 index 0000000..8365df0 --- /dev/null +++ b/lib/features/debug/debug_screen.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:food4student_next/features/auth/session/auth_session_controller.dart'; +import 'package:go_router/go_router.dart'; + +/// Dev-only navigation hub. Not linked from production routes; open via `/debug` in debug builds. +class DebugScreen extends ConsumerWidget { + const DebugScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authSessionControllerProvider); + final user = auth.session?.user; + + return Scaffold( + appBar: AppBar( + title: const Text('Debug hub'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.pop(), + ), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'User: ${user?.email.isNotEmpty == true ? user!.email : "Unknown"}', + ), + Text('Role: ${auth.primaryRole?.name ?? "none"}'), + const SizedBox(height: 24), + Text('Mock routes', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + for (final route in _mockRoutes) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: OutlinedButton( + onPressed: () => context.push(route.path), + child: Text(route.label), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => + ref.read(authSessionControllerProvider.notifier).logout(), + child: const Text('Logout'), + ), + ], + ), + ); + } +} + +const _mockRoutes = [ + (label: 'Customer', path: '/customer'), + (label: 'Restaurant owner', path: '/resowner'), + (label: 'Admin', path: '/admin'), +]; diff --git a/lib/features/example/data/api/example_api.dart b/lib/features/example/data/api/example_api.dart deleted file mode 100644 index 06e2fd2..0000000 --- a/lib/features/example/data/api/example_api.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:food4student_next/features/example/data/dto/create_post_request.dart'; -import 'package:food4student_next/features/example/data/dto/create_post_response.dart'; -import 'package:retrofit/retrofit.dart'; - -part 'example_api.g.dart'; - -@RestApi() -abstract class ExampleApi { - factory ExampleApi(Dio dio) = _ExampleApi; - - @POST('/posts') - Future createPost( - @Body() CreatePostRequest request, - ); -} diff --git a/lib/features/example/data/api/example_api.g.dart b/lib/features/example/data/api/example_api.g.dart deleted file mode 100644 index e54a5a7..0000000 --- a/lib/features/example/data/api/example_api.g.dart +++ /dev/null @@ -1,78 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'example_api.dart'; - -// dart format off - -// ************************************************************************** -// RetrofitGenerator -// ************************************************************************** - -// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter,avoid_unused_constructor_parameters,unreachable_from_main,avoid_redundant_argument_values - -class _ExampleApi implements ExampleApi { - _ExampleApi(this._dio, {this.baseUrl, this.errorLogger}); - - final Dio _dio; - - String? baseUrl; - - final ParseErrorLogger? errorLogger; - - @override - Future createPost(CreatePostRequest request) async { - final _extra = {}; - final queryParameters = {}; - final _headers = {}; - final _data = {}; - _data.addAll(request.toJson()); - final _options = _setStreamType( - Options(method: 'POST', headers: _headers, extra: _extra) - .compose( - _dio.options, - '/posts', - queryParameters: queryParameters, - data: _data, - ) - .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), - ); - final _result = await _dio.fetch>(_options); - late CreatePostResponse _value; - try { - _value = CreatePostResponse.fromJson(_result.data!); - } on Object catch (e, s) { - errorLogger?.logError(e, s, _options, response: _result); - rethrow; - } - return _value; - } - - RequestOptions _setStreamType(RequestOptions requestOptions) { - if (T != dynamic && - !(requestOptions.responseType == ResponseType.bytes || - requestOptions.responseType == ResponseType.stream)) { - if (T == String) { - requestOptions.responseType = ResponseType.plain; - } else { - requestOptions.responseType = ResponseType.json; - } - } - return requestOptions; - } - - String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { - if (baseUrl == null || baseUrl.trim().isEmpty) { - return dioBaseUrl; - } - - final url = Uri.parse(baseUrl); - - if (url.isAbsolute) { - return url.toString(); - } - - return Uri.parse(dioBaseUrl).resolveUri(url).toString(); - } -} - -// dart format on diff --git a/lib/features/example/data/dto/create_post_request.dart b/lib/features/example/data/dto/create_post_request.dart deleted file mode 100644 index 9939e32..0000000 --- a/lib/features/example/data/dto/create_post_request.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:food4student_next/core/converter/date_time_converter.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'create_post_request.g.dart'; - -@JsonSerializable() -@UtcDateTimeConverter() -class CreatePostRequest { - final DateTime createdAt; - - CreatePostRequest({ - required this.createdAt, - }); - - factory CreatePostRequest.fromJson( - Map json, - ) => - _$CreatePostRequestFromJson(json); - - Map toJson() => - _$CreatePostRequestToJson(this); -} diff --git a/lib/features/example/data/dto/create_post_request.g.dart b/lib/features/example/data/dto/create_post_request.g.dart deleted file mode 100644 index b9a8304..0000000 --- a/lib/features/example/data/dto/create_post_request.g.dart +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'create_post_request.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CreatePostRequest _$CreatePostRequestFromJson(Map json) => - CreatePostRequest( - createdAt: const UtcDateTimeConverter().fromJson( - json['createdAt'] as String, - ), - ); - -Map _$CreatePostRequestToJson(CreatePostRequest instance) => - { - 'createdAt': const UtcDateTimeConverter().toJson(instance.createdAt), - }; diff --git a/lib/features/example/data/dto/create_post_response.dart b/lib/features/example/data/dto/create_post_response.dart deleted file mode 100644 index 708afb5..0000000 --- a/lib/features/example/data/dto/create_post_response.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:food4student_next/core/converter/date_time_converter.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'create_post_response.g.dart'; - -@JsonSerializable() -@UtcDateTimeConverter() -class CreatePostResponse { - final DateTime createdAt; - - CreatePostResponse({ - required this.createdAt, - }); - - factory CreatePostResponse.fromJson( - Map json, - ) => - _$CreatePostResponseFromJson(json); - - Map toJson() => - _$CreatePostResponseToJson(this); -} diff --git a/lib/features/example/data/dto/create_post_response.g.dart b/lib/features/example/data/dto/create_post_response.g.dart deleted file mode 100644 index 2e8dff0..0000000 --- a/lib/features/example/data/dto/create_post_response.g.dart +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'create_post_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CreatePostResponse _$CreatePostResponseFromJson(Map json) => - CreatePostResponse( - createdAt: const UtcDateTimeConverter().fromJson( - json['createdAt'] as String, - ), - ); - -Map _$CreatePostResponseToJson(CreatePostResponse instance) => - { - 'createdAt': const UtcDateTimeConverter().toJson(instance.createdAt), - }; diff --git a/lib/features/example/data/providers/example_api_provider.dart b/lib/features/example/data/providers/example_api_provider.dart deleted file mode 100644 index 0756859..0000000 --- a/lib/features/example/data/providers/example_api_provider.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:food4student_next/core/network/providers/network_provider.dart'; -import 'package:food4student_next/features/example/data/api/example_api.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'example_api_provider.g.dart'; - -@riverpod -ExampleApi exampleApi(Ref ref) { - return ExampleApi(ref.watch(dioProvider)); -} diff --git a/lib/features/example/data/providers/example_api_provider.g.dart b/lib/features/example/data/providers/example_api_provider.g.dart deleted file mode 100644 index 74ab4b6..0000000 --- a/lib/features/example/data/providers/example_api_provider.g.dart +++ /dev/null @@ -1,51 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'example_api_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(exampleApi) -final exampleApiProvider = ExampleApiProvider._(); - -final class ExampleApiProvider - extends $FunctionalProvider - with $Provider { - ExampleApiProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'exampleApiProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$exampleApiHash(); - - @$internal - @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - ExampleApi create(Ref ref) { - return exampleApi(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(ExampleApi value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$exampleApiHash() => r'793cd83055896ad6915054c0d9854b1b199d4db1'; diff --git a/lib/features/example/data/providers/example_repository_provider.dart b/lib/features/example/data/providers/example_repository_provider.dart deleted file mode 100644 index 3995d02..0000000 --- a/lib/features/example/data/providers/example_repository_provider.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:food4student_next/features/example/data/providers/example_api_provider.dart'; -import 'package:food4student_next/features/example/data/repository/example_repository.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'example_repository_provider.g.dart'; - -@riverpod -ExampleRepository exampleRepository(Ref ref) { - return ExampleRepository(ref.watch(exampleApiProvider)); -} diff --git a/lib/features/example/data/providers/example_repository_provider.g.dart b/lib/features/example/data/providers/example_repository_provider.g.dart deleted file mode 100644 index d0ae5f2..0000000 --- a/lib/features/example/data/providers/example_repository_provider.g.dart +++ /dev/null @@ -1,57 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'example_repository_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(exampleRepository) -final exampleRepositoryProvider = ExampleRepositoryProvider._(); - -final class ExampleRepositoryProvider - extends - $FunctionalProvider< - ExampleRepository, - ExampleRepository, - ExampleRepository - > - with $Provider { - ExampleRepositoryProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'exampleRepositoryProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$exampleRepositoryHash(); - - @$internal - @override - $ProviderElement $createElement( - $ProviderPointer pointer, - ) => $ProviderElement(pointer); - - @override - ExampleRepository create(Ref ref) { - return exampleRepository(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(ExampleRepository value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$exampleRepositoryHash() => r'c4f53563327d6b61ef5779c1d896c68882cf5ef3'; diff --git a/lib/features/example/data/repository/example_repository.dart b/lib/features/example/data/repository/example_repository.dart deleted file mode 100644 index 15e2bc1..0000000 --- a/lib/features/example/data/repository/example_repository.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:food4student_next/features/example/data/api/example_api.dart'; -import 'package:food4student_next/features/example/data/dto/create_post_request.dart'; -import 'package:food4student_next/features/example/data/dto/create_post_response.dart'; - -class ExampleRepository { - final ExampleApi api; - - ExampleRepository(this.api); - - Future createPost(DateTime createdAt) { - return api.createPost( - CreatePostRequest( - createdAt: createdAt, - ), - ); - } -} diff --git a/lib/features/example/presentation/example_screen.dart b/lib/features/example/presentation/example_screen.dart deleted file mode 100644 index 7a349ec..0000000 --- a/lib/features/example/presentation/example_screen.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:food4student_next/features/example/viewmodel/example_viewmodel.dart'; - -class ExampleScreen extends ConsumerStatefulWidget { - const ExampleScreen({super.key}); - - @override - ConsumerState createState() => _ExampleScreenState(); -} - -class _ExampleScreenState extends ConsumerState { - @override - Widget build(BuildContext context) { - ref.listen(exampleViewModelProvider, (previous, next) { - if (next.errorMessage != null && next.errorMessage != previous?.errorMessage) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Request failed: ${next.errorMessage}'), - ), - ); - } - }); - - final exampleState = ref.watch(exampleViewModelProvider); - final requestRaw = exampleState.requestRaw ?? '-'; - final responseRaw = exampleState.responseRaw ?? '-'; - - return Scaffold( - appBar: AppBar( - title: const Text('Example DateTime'), - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - FilledButton( - onPressed: exampleState.isLoading ? null : _sendRequest, - child: exampleState.isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Send request'), - ), - const SizedBox(height: 16), - const Text('Raw request:'), - const SizedBox(height: 8), - SelectableText( - requestRaw, - style: const TextStyle(fontFamily: 'monospace'), - ), - const SizedBox(height: 16), - const Text('Raw response:'), - const SizedBox(height: 8), - SelectableText( - responseRaw, - style: const TextStyle(fontFamily: 'monospace'), - ), - ], - ), - ), - ); - } - - void _sendRequest() { - ref.read(exampleViewModelProvider.notifier).sendRequest(); - } -} diff --git a/lib/features/example/states/example_state.dart b/lib/features/example/states/example_state.dart deleted file mode 100644 index b727685..0000000 --- a/lib/features/example/states/example_state.dart +++ /dev/null @@ -1,32 +0,0 @@ -class ExampleState { - static const _error = Object(); - - final bool isLoading; - final String? requestRaw; - final String? responseRaw; - final String? errorMessage; - - const ExampleState({ - this.isLoading = false, - this.requestRaw, - this.responseRaw, - this.errorMessage, - }); - - ExampleState copyWith({ - bool? isLoading, - Object? requestRaw = _error, - Object? responseRaw = _error, - Object? errorMessage = _error, - }) { - return ExampleState( - isLoading: isLoading ?? this.isLoading, - requestRaw: - requestRaw == _error ? this.requestRaw : requestRaw as String?, - responseRaw: - responseRaw == _error ? this.responseRaw : responseRaw as String?, - errorMessage: - errorMessage == _error ? this.errorMessage : errorMessage as String?, - ); - } -} diff --git a/lib/features/example/viewmodel/example_viewmodel.dart b/lib/features/example/viewmodel/example_viewmodel.dart deleted file mode 100644 index aebf6e0..0000000 --- a/lib/features/example/viewmodel/example_viewmodel.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:convert'; - -import 'package:food4student_next/features/example/data/dto/create_post_request.dart'; -import 'package:food4student_next/features/example/data/providers/example_repository_provider.dart'; -import 'package:food4student_next/features/example/states/example_state.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'example_viewmodel.g.dart'; - -@riverpod -class ExampleViewModel extends _$ExampleViewModel { - @override - ExampleState build() { - return const ExampleState(); - } - - Future sendRequest() async { - final now = DateTime.now(); - final request = CreatePostRequest(createdAt: now); - - state = state.copyWith( - isLoading: true, - requestRaw: _prettyJson(request.toJson()), - responseRaw: null, - errorMessage: null, - ); - - try { - final response = - await ref.read(exampleRepositoryProvider).createPost(now); - state = state.copyWith( - isLoading: false, - responseRaw: _prettyJson(response.toJson()), - ); - } catch (error) { - state = state.copyWith( - isLoading: false, - errorMessage: error.toString(), - ); - } - } - - String _prettyJson(Map json) { - return const JsonEncoder.withIndent(' ').convert(json); - } -} diff --git a/lib/features/example/viewmodel/example_viewmodel.g.dart b/lib/features/example/viewmodel/example_viewmodel.g.dart deleted file mode 100644 index 6ef1358..0000000 --- a/lib/features/example/viewmodel/example_viewmodel.g.dart +++ /dev/null @@ -1,62 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'example_viewmodel.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(ExampleViewModel) -final exampleViewModelProvider = ExampleViewModelProvider._(); - -final class ExampleViewModelProvider - extends $NotifierProvider { - ExampleViewModelProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'exampleViewModelProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$exampleViewModelHash(); - - @$internal - @override - ExampleViewModel create() => ExampleViewModel(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(ExampleState value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$exampleViewModelHash() => r'55f22ff5dea2dbdabf80d0e6e9cd9a2537170e5e'; - -abstract class _$ExampleViewModel extends $Notifier { - ExampleState build(); - @$mustCallSuper - @override - void runBuild() { - final ref = this.ref as $Ref; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, - ExampleState, - Object?, - Object? - >; - element.handleCreate(ref, build); - } -} diff --git a/lib/main.dart b/lib/main.dart index ac434cb..db32f54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,130 +1,58 @@ -import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:food4student_next/features/auth/presentation/login_screen.dart'; +import 'package:food4student_next/features/auth/session/auth_session_controller.dart'; +import 'package:food4student_next/nav/app_router.dart'; +import 'package:food4student_next/nav/platform_back.dart'; import 'package:food4student_next/theme/app_theme.dart'; Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + // Keep the native launch screen until session restore finishes. + WidgetsBinding.instance.deferFirstFrame(); + await dotenv.load(fileName: '.env'); - runApp(const ProviderScope( - child: MyApp() - ) - ); + final container = ProviderContainer(); + await container + .read(authSessionControllerProvider.notifier) + .restoreFromStorage(); + + runApp(UncontrolledProviderScope(container: container, child: const MyApp())); + + WidgetsBinding.instance.allowFirstFrame(); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerStatefulWidget { const MyApp({super.key}); - // This widget is the root of your application. @override - Widget build(BuildContext context) { - final defaultSeedColor = Colors.deepPurple; - final fallbackScheme = ColorScheme.fromSeed(seedColor: defaultSeedColor); - final fallbackDarkScheme = ColorScheme.fromSeed( - seedColor: defaultSeedColor, - brightness: Brightness.dark, - ); + ConsumerState createState() => _MyAppState(); +} + +class _MyAppState extends ConsumerState { + late final _router = createRouter( + auth: ref.read(authSessionControllerProvider.notifier), + readAuthState: () => ref.read(authSessionControllerProvider), + ); + @override + Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { final lightScheme = lightDynamic ?? fallbackScheme; final darkScheme = darkDynamic ?? fallbackDarkScheme; - return MaterialApp( - title: 'Flutter Demo', + return MaterialApp.router( + title: 'Food4Student Next', themeMode: ThemeMode.system, theme: appTheme(lightScheme), darkTheme: appTheme(darkScheme), - home: const LoginScreen(), + routerConfig: _router, + builder: (context, child) => + MouseBackNavigationListener(router: _router, child: child!), ); }, ); } } - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), - ); - } -} diff --git a/lib/nav/app_router.dart b/lib/nav/app_router.dart new file mode 100644 index 0000000..e727417 --- /dev/null +++ b/lib/nav/app_router.dart @@ -0,0 +1,122 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:food4student_next/features/auth/auth_routes.dart'; +import 'package:food4student_next/features/auth/model/app_role.dart'; +import 'package:food4student_next/features/auth/session/auth_session_controller.dart'; +import 'package:food4student_next/features/auth/session/auth_session_state.dart'; +import 'package:food4student_next/features/debug/debug_screen.dart'; +import 'package:go_router/go_router.dart'; + +String roleHomePath(AppRole role) => switch (role) { + AppRole.admin || AppRole.moderator => '/admin', + AppRole.restaurantOwner => '/resowner', + AppRole.user => '/customer', +}; + +GoRouter createRouter({ + required AuthSessionController auth, + required AuthSessionState Function() readAuthState, +}) { + return GoRouter( + initialLocation: authLoginPath, + routes: [ + authLoginRoute, + authOnboardingRoute, + if (kDebugMode) + GoRoute( + path: '/debug', + builder: (context, state) => const DebugScreen(), + ), + GoRoute( + path: '/customer', + builder: (context, state) => + const MockRoleScreen(label: 'customer route', role: AppRole.user), + ), + GoRoute( + path: '/resowner', + builder: (context, state) => const MockRoleScreen( + label: 'restaurant owner route', + role: AppRole.restaurantOwner, + ), + ), + GoRoute( + path: '/admin', + builder: (context, state) => + const MockRoleScreen(label: 'admin route', role: AppRole.admin), + ), + ], + redirect: (context, state) { + final authState = readAuthState(); + final location = state.matchedLocation; + final isAtLogin = location == authLoginPath; + final isAtOnboarding = location == authOnboardingPath; + final isAtDebug = location == '/debug'; + + if (!kDebugMode && isAtDebug) { + return authState.isLoggedIn + ? _fallbackWhenLoggedIn(authState) + : authLoginPath; + } + + if (!authState.isLoggedIn) { + return isAtLogin ? null : authLoginPath; + } + + final role = authState.primaryRole; + if (role == null) { + if (isAtDebug) { + return null; + } + return isAtOnboarding ? null : authOnboardingPath; + } + + if (isAtLogin || isAtOnboarding) { + return roleHomePath(role); + } + + return null; + }, + refreshListenable: auth, + ); +} + +String _fallbackWhenLoggedIn(AuthSessionState authState) { + final role = authState.primaryRole; + if (role == null) { + return authOnboardingPath; + } + return roleHomePath(role); +} + +class MockRoleScreen extends ConsumerWidget { + const MockRoleScreen({super.key, required this.label, required this.role}); + + final String label; + final AppRole role; + + void _onBack(BuildContext context, WidgetRef ref) { + if (context.canPop()) { + context.pop(); + return; + } + final currentRole = ref.read(authSessionControllerProvider).primaryRole; + context.go( + currentRole == null ? authOnboardingPath : roleHomePath(currentRole), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: Text(label), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => _onBack(context, ref), + ), + ), + body: Center(child: Text(label)), + ); + } +} diff --git a/lib/nav/platform_back.dart b/lib/nav/platform_back.dart new file mode 100644 index 0000000..27ef3e5 --- /dev/null +++ b/lib/nav/platform_back.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Maps the mouse back (thumb) button to [GoRouter.pop] when possible. +/// +/// Enabled on Linux, Windows and macOS desktop embedders only. Flutter does not wire +/// this by default on desktop (see flutter/flutter#56919). +class MouseBackNavigationListener extends StatelessWidget { + const MouseBackNavigationListener({ + super.key, + required this.router, + required this.child, + }); + + final GoRouter router; + final Widget child; + + static bool get enabled => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows); + + void _onPointerDown(PointerDownEvent event) { + if (!enabled) return; + if (event.buttons & kBackMouseButton == 0) return; + if (!router.canPop()) return; + router.pop(); + } + + @override + Widget build(BuildContext context) { + if (!enabled) return child; + + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onPointerDown, + child: child, + ); + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 17cbb8f..347ebb5 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,5 +1,12 @@ import 'package:flutter/material.dart'; +final _fallbackSeedColor = Colors.deepPurple; +final fallbackScheme = ColorScheme.fromSeed(seedColor: _fallbackSeedColor); +final fallbackDarkScheme = ColorScheme.fromSeed( + seedColor: _fallbackSeedColor, + brightness: Brightness.dark, +); + // Centralized app theme ThemeData appTheme(ColorScheme colorScheme) => ThemeData( colorScheme: colorScheme, diff --git a/pubspec.lock b/pubspec.lock index 329cf7f..dafe2a0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -262,6 +262,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.1" + flutter_hooks: + dependency: "direct main" + description: + name: flutter_hooks + sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42" + url: "https://pub.dev" + source: hosted + version: "0.21.3+1" flutter_lints: dependency: "direct dev" description: @@ -360,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" + url: "https://pub.dev" + source: hosted + version: "17.2.3" graphs: dependency: transitive description: @@ -376,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + hooks_riverpod: + dependency: "direct main" + description: + name: hooks_riverpod + sha256: "08527ec06aaef75e4b78694e045ef0cd8346594eaf9cc18b0f866398b07b93b1" + url: "https://pub.dev" + source: hosted + version: "3.3.1" http_multi_server: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b7f6590..13ac607 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,9 @@ dependencies: flutter_secure_storage: ^10.1.0 json_annotation: ^4.11.0 + go_router: ^17.2.3 + hooks_riverpod: ^3.3.1 + flutter_hooks: ^0.21.3+1 dev_dependencies: flutter_test: