From e0f25da42b77ac92fd0a2a2b8d4e8c99dba79529 Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Tue, 20 Aug 2024 13:45:18 +0200 Subject: [PATCH] Compass App: Basic auth (#2385) This PR introduces basic auth implementation between the app and the server as part of the architectural example. This PR is a big bigger than the previous ones so I hope this explanation helps: ### Server implementation The server introduces a new endpoint `/login` to perform login requests, which accepts login requests defined in the `LoginRequest` data class, with an email and password. The login process "simulates" checking on the email and password and responds with a "token" and user ID, defined by the `LoginResponse` data class. This is a simple hard-coded check and in any way a guide on how to implement authentication, just a way to demonstrate an architectural example. The server also implements a middleware in `server/lib/middleware/auth.dart`. This checks that the requests between the app and the server carry a valid authorization token in the headers, responding with an unauthorized error otherwise. ### App implementation The app introduces the following new parts: - `AuthTokenRepository`: In charge of storing the auth token. - `AuthLoginComponent`: In charge of performing login. - `AuthLogoutComponent`: In charge of performing logout. - `LoginScreen` with `LoginViewModel`: Displays the login screen. - `LogoutButton` with `LogoutViewModel`: Displays a logout button. The `AuthTokenRepository` acts as the source of truth to decide if the user is logged in or not. If the repository contains a token, it means the user is logged in, otherwise if the token is null, it means that the user is logged out. This repository is also a `ChangeNotifier`, which allows listening to change in it. The `GoRouter` has been modified so it listens to changes in the `AuthTokenRepository` using the `refreshListenable` property. It also implements a `redirect`, so if the token is set to `null` in the repository, the router will redirect users automatically to the login screen. This follows the example found in https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart On app start, `GoRouter` checks the `AuthTokenRepository`, if a token exists the user stays in `/`, if not, the user is redirected to `/login`. The `ApiClient` has also been modified, so it reads the stored token from the repository when performing network calls, and adds it to the auth headers. The two new components implement basic login and logout functionality. The `AuthLoginComponent` will send the request using the `ApiClient`, and then store the token from the response. The `AuthLogoutComponent` clears the stored token from the repository, and as well clears any existing itinerary configuration, effectively cleaning the app state. Performing logout redirects the user to the login screen, as explained. The `LoginScreen` uses the `AuthLoginComponent` internally, it displays two text fields and a login button, plus the application logo on top. A successful login redirects the user to `/`. The `LogoutButton` replaces the home button at the `/`, and on tap it will perform logout using the `AuthLogoutComponent`. **Development target app** The development target app works slightly different compared to the staging build. In this case, the `AuthTokenRepository` always contains a fake token, so the app believes it is always logged in. Auth is only used in the staging build when the server is involved. ## Screenshots
Screenshots The logout button in the top right corner: ![Screenshot from 2024-08-14 15-28-54](https://github.com/user-attachments/assets/1c5a37dc-9fa1-4950-917e-0c7272896780) The login screen: ![Screenshot from 2024-08-14 15-28-12](https://github.com/user-attachments/assets/3c26ccc2-8e3b-42d2-a230-d31048af6960)
## Pre-launch Checklist - [x] I read the [Flutter Style Guide] _recently_, and have followed its advice. - [x] I signed the [CLA]. - [x] I read the [Contributors Guide]. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-devrel channel on [Discord]. [Flutter Style Guide]: https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md [CLA]: https://cla.developers.google.com/ [Discord]: https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md [Contributors Guide]: https://github.com/flutter/samples/blob/main/CONTRIBUTING.md --- compass_app/app/assets/logo.svg | 10 + compass_app/app/lib/config/dependencies.dart | 52 +++-- .../auth/auth_token_repository.dart | 21 ++ .../auth/auth_token_repository_dev.dart | 16 ++ .../auth_token_repository_shared_prefs.dart | 41 ++++ .../app/lib/data/services/api_client.dart | 42 +++- .../components/auth/auth_login_component.dart | 41 ++++ .../auth/auth_logout_component.dart | 45 ++++ .../booking}/booking_create_component.dart | 0 .../booking}/booking_share_component.dart | 2 +- compass_app/app/lib/main.dart | 3 +- compass_app/app/lib/routing/router.dart | 141 ++++++++----- .../activities/widgets/activities_screen.dart | 4 +- .../login/view_models/login_viewmodel.dart | 30 +++ .../ui/auth/login/widgets/login_screen.dart | 108 ++++++++++ .../ui/auth/login/widgets/tilted_cards.dart | 90 ++++++++ .../logout/view_models/logout_viewmodel.dart | 15 ++ .../ui/auth/logout/widgets/logout_button.dart | 84 ++++++++ .../view_models/booking_viewmodel.dart | 4 +- .../ui/booking/widgets/booking_screen.dart | 4 +- .../ui/core/localization/applocalization.dart | 9 + .../app/lib/ui/core/ui/search_bar.dart | 12 +- .../widgets/search_form_screen.dart | 2 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + compass_app/app/pubspec.yaml | 3 + .../auth/auth_login_component_test.dart | 59 ++++++ .../auth/auth_logout_component_test.dart | 36 ++++ .../booking_create_component_test.dart | 12 +- .../booking/booking_share_component_test.dart | 6 +- .../app/test/ui/auth/login_screen_test.dart | 64 ++++++ .../app/test/ui/auth/logout_button_test.dart | 77 +++++++ .../test/ui/booking/booking_screen_test.dart | 4 +- .../widgets/search_form_screen_test.dart | 11 +- .../fake_auth_token_repository.dart | 18 ++ .../fakes/services/fake_api_client.dart | 5 + compass_app/model/lib/model.dart | 2 + .../auth/login_request/login_request.dart | 20 ++ .../login_request/login_request.freezed.dart | 192 ++++++++++++++++++ .../auth/login_request/login_request.g.dart | 19 ++ .../auth/login_response/login_response.dart | 20 ++ .../login_response.freezed.dart | 191 +++++++++++++++++ .../auth/login_response/login_response.g.dart | 19 ++ compass_app/server/bin/compass_server.dart | 11 +- compass_app/server/lib/config/constants.dart | 14 ++ compass_app/server/lib/middleware/auth.dart | 26 +++ compass_app/server/lib/routes/login.dart | 44 ++++ compass_app/server/test/server_test.dart | 65 +++++- 47 files changed, 1607 insertions(+), 89 deletions(-) create mode 100644 compass_app/app/assets/logo.svg create mode 100644 compass_app/app/lib/data/repositories/auth/auth_token_repository.dart create mode 100644 compass_app/app/lib/data/repositories/auth/auth_token_repository_dev.dart create mode 100644 compass_app/app/lib/data/repositories/auth/auth_token_repository_shared_prefs.dart create mode 100644 compass_app/app/lib/domain/components/auth/auth_login_component.dart create mode 100644 compass_app/app/lib/domain/components/auth/auth_logout_component.dart rename compass_app/app/lib/{ui/booking/components => domain/components/booking}/booking_create_component.dart (100%) rename compass_app/app/lib/{ui/booking/components => domain/components/booking}/booking_share_component.dart (96%) create mode 100644 compass_app/app/lib/ui/auth/login/view_models/login_viewmodel.dart create mode 100644 compass_app/app/lib/ui/auth/login/widgets/login_screen.dart create mode 100644 compass_app/app/lib/ui/auth/login/widgets/tilted_cards.dart create mode 100644 compass_app/app/lib/ui/auth/logout/view_models/logout_viewmodel.dart create mode 100644 compass_app/app/lib/ui/auth/logout/widgets/logout_button.dart create mode 100644 compass_app/app/test/domain/components/auth/auth_login_component_test.dart create mode 100644 compass_app/app/test/domain/components/auth/auth_logout_component_test.dart rename compass_app/app/test/{ui => domain/components}/booking/booking_create_component_test.dart (63%) rename compass_app/app/test/{ui => domain/components}/booking/booking_share_component_test.dart (79%) create mode 100644 compass_app/app/test/ui/auth/login_screen_test.dart create mode 100644 compass_app/app/test/ui/auth/logout_button_test.dart create mode 100644 compass_app/app/testing/fakes/repositories/fake_auth_token_repository.dart create mode 100644 compass_app/model/lib/src/model/auth/login_request/login_request.dart create mode 100644 compass_app/model/lib/src/model/auth/login_request/login_request.freezed.dart create mode 100644 compass_app/model/lib/src/model/auth/login_request/login_request.g.dart create mode 100644 compass_app/model/lib/src/model/auth/login_response/login_response.dart create mode 100644 compass_app/model/lib/src/model/auth/login_response/login_response.freezed.dart create mode 100644 compass_app/model/lib/src/model/auth/login_response/login_response.g.dart create mode 100644 compass_app/server/lib/config/constants.dart create mode 100644 compass_app/server/lib/middleware/auth.dart create mode 100644 compass_app/server/lib/routes/login.dart diff --git a/compass_app/app/assets/logo.svg b/compass_app/app/assets/logo.svg new file mode 100644 index 000000000..1bf798e05 --- /dev/null +++ b/compass_app/app/assets/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/compass_app/app/lib/config/dependencies.dart b/compass_app/app/lib/config/dependencies.dart index 84d73cd16..e5598c554 100644 --- a/compass_app/app/lib/config/dependencies.dart +++ b/compass_app/app/lib/config/dependencies.dart @@ -1,9 +1,14 @@ import 'package:provider/single_child_widget.dart'; import 'package:provider/provider.dart'; +import '../domain/components/auth/auth_login_component.dart'; +import '../domain/components/auth/auth_logout_component.dart'; import '../data/repositories/activity/activity_repository.dart'; import '../data/repositories/activity/activity_repository_local.dart'; import '../data/repositories/activity/activity_repository_remote.dart'; +import '../data/repositories/auth/auth_token_repository.dart'; +import '../data/repositories/auth/auth_token_repository_dev.dart'; +import '../data/repositories/auth/auth_token_repository_shared_prefs.dart'; import '../data/repositories/continent/continent_repository.dart'; import '../data/repositories/continent/continent_repository_local.dart'; import '../data/repositories/continent/continent_repository_remote.dart'; @@ -13,8 +18,8 @@ import '../data/repositories/destination/destination_repository_remote.dart'; import '../data/repositories/itinerary_config/itinerary_config_repository.dart'; import '../data/repositories/itinerary_config/itinerary_config_repository_memory.dart'; import '../data/services/api_client.dart'; -import '../ui/booking/components/booking_create_component.dart'; -import '../ui/booking/components/booking_share_component.dart'; +import '../domain/components/booking/booking_create_component.dart'; +import '../domain/components/booking/booking_share_component.dart'; /// Shared providers for all configurations. List _sharedProviders = [ @@ -29,27 +34,44 @@ List _sharedProviders = [ lazy: true, create: (context) => BookingShareComponent.withSharePlus(), ), + Provider( + lazy: true, + create: (context) => AuthLogoutComponent( + authTokenRepository: context.read(), + itineraryConfigRepository: context.read(), + ), + ), ]; /// Configure dependencies for remote data. /// This dependency list uses repositories that connect to a remote server. List get providersRemote { - final apiClient = ApiClient(); - return [ - Provider.value( - value: DestinationRepositoryRemote( - apiClient: apiClient, + ChangeNotifierProvider.value( + value: AuthTokenRepositorySharedPrefs() as AuthTokenRepository, + ), + Provider( + create: (context) => ApiClient(authTokenRepository: context.read()), + ), + Provider( + create: (context) => AuthLoginComponent( + authTokenRepository: context.read(), + apiClient: context.read(), + ), + ), + Provider( + create: (context) => DestinationRepositoryRemote( + apiClient: context.read(), ) as DestinationRepository, ), - Provider.value( - value: ContinentRepositoryRemote( - apiClient: apiClient, + Provider( + create: (context) => ContinentRepositoryRemote( + apiClient: context.read(), ) as ContinentRepository, ), - Provider.value( - value: ActivityRepositoryRemote( - apiClient: apiClient, + Provider( + create: (context) => ActivityRepositoryRemote( + apiClient: context.read(), ) as ActivityRepository, ), Provider.value( @@ -61,8 +83,12 @@ List get providersRemote { /// Configure dependencies for local data. /// This dependency list uses repositories that provide local data. +/// The user is always logged in. List get providersLocal { return [ + ChangeNotifierProvider.value( + value: AuthTokenRepositoryDev() as AuthTokenRepository, + ), Provider.value( value: DestinationRepositoryLocal() as DestinationRepository, ), diff --git a/compass_app/app/lib/data/repositories/auth/auth_token_repository.dart b/compass_app/app/lib/data/repositories/auth/auth_token_repository.dart new file mode 100644 index 000000000..8dc84ec14 --- /dev/null +++ b/compass_app/app/lib/data/repositories/auth/auth_token_repository.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; + +import '../../../utils/result.dart'; + +/// Repository to save and get auth token. +/// Notifies listeners when the token changes e.g. user logs out. +abstract class AuthTokenRepository extends ChangeNotifier { + /// Get the token. + /// If the value is null, usually means that the user is logged out. + Future> getToken(); + + /// Store the token. + /// Will notifiy listeners. + Future> saveToken(String? token); + + /// Returns true when the token exists, otherwise false. + Future hasToken() async { + final result = await getToken(); + return result is Ok && result.value != null; + } +} diff --git a/compass_app/app/lib/data/repositories/auth/auth_token_repository_dev.dart b/compass_app/app/lib/data/repositories/auth/auth_token_repository_dev.dart new file mode 100644 index 000000000..26055b461 --- /dev/null +++ b/compass_app/app/lib/data/repositories/auth/auth_token_repository_dev.dart @@ -0,0 +1,16 @@ +import '../../../utils/result.dart'; +import 'auth_token_repository.dart'; + +/// Development [AuthTokenRepository] that always returns a fake token +class AuthTokenRepositoryDev extends AuthTokenRepository { + @override + Future> getToken() async { + return Result.ok('token'); + } + + @override + Future> saveToken(String? token) async { + notifyListeners(); + return Result.ok(null); + } +} diff --git a/compass_app/app/lib/data/repositories/auth/auth_token_repository_shared_prefs.dart b/compass_app/app/lib/data/repositories/auth/auth_token_repository_shared_prefs.dart new file mode 100644 index 000000000..d7bda833c --- /dev/null +++ b/compass_app/app/lib/data/repositories/auth/auth_token_repository_shared_prefs.dart @@ -0,0 +1,41 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../utils/result.dart'; +import 'auth_token_repository.dart'; + +/// [AuthTokenRepository] that stores the token in Shared Preferences. +/// Provided for demo purposes, consider using a secure store instead. +class AuthTokenRepositorySharedPrefs extends AuthTokenRepository { + static const _tokenKey = 'TOKEN'; + String? cachedToken; + + @override + Future> getToken() async { + if (cachedToken != null) return Result.ok(cachedToken); + + try { + final sharedPreferences = await SharedPreferences.getInstance(); + final token = sharedPreferences.getString(_tokenKey); + return Result.ok(token); + } on Exception catch (e) { + return Result.error(e); + } + } + + @override + Future> saveToken(String? token) async { + try { + final sharedPreferences = await SharedPreferences.getInstance(); + if (token == null) { + await sharedPreferences.remove(_tokenKey); + } else { + await sharedPreferences.setString(_tokenKey, token); + } + cachedToken = token; + notifyListeners(); + return Result.ok(null); + } on Exception catch (e) { + return Result.error(e); + } + } +} diff --git a/compass_app/app/lib/data/services/api_client.dart b/compass_app/app/lib/data/services/api_client.dart index 993c041ae..0c0edb586 100644 --- a/compass_app/app/lib/data/services/api_client.dart +++ b/compass_app/app/lib/data/services/api_client.dart @@ -3,14 +3,52 @@ import 'dart:io'; import 'package:compass_model/model.dart'; import '../../utils/result.dart'; +import '../repositories/auth/auth_token_repository.dart'; + +typedef AuthTokenProvider = Future Function(); -// TODO: Basic auth request // TODO: Configurable baseurl/host/port class ApiClient { + ApiClient({ + required AuthTokenRepository authTokenRepository, + }) : _authTokenRepository = authTokenRepository; + + /// Provides the auth token to be used in the request + final AuthTokenRepository _authTokenRepository; + + Future _authHeader(HttpHeaders headers) async { + final result = await _authTokenRepository.getToken(); + if (result is Ok) { + if (result.value != null) { + headers.add(HttpHeaders.authorizationHeader, 'Bearer ${result.value}'); + } + } + } + + Future> login(LoginRequest loginRequest) async { + final client = HttpClient(); + try { + final request = await client.post('localhost', 8080, '/login'); + request.write(jsonEncode(loginRequest)); + final response = await request.close(); + if (response.statusCode == 200) { + final stringData = await response.transform(utf8.decoder).join(); + return Result.ok(LoginResponse.fromJson(jsonDecode(stringData))); + } else { + return Result.error(const HttpException("Login error")); + } + } on Exception catch (error) { + return Result.error(error); + } finally { + client.close(); + } + } + Future>> getContinents() async { final client = HttpClient(); try { final request = await client.get('localhost', 8080, '/continent'); + await _authHeader(request.headers); final response = await request.close(); if (response.statusCode == 200) { final stringData = await response.transform(utf8.decoder).join(); @@ -31,6 +69,7 @@ class ApiClient { final client = HttpClient(); try { final request = await client.get('localhost', 8080, '/destination'); + await _authHeader(request.headers); final response = await request.close(); if (response.statusCode == 200) { final stringData = await response.transform(utf8.decoder).join(); @@ -52,6 +91,7 @@ class ApiClient { try { final request = await client.get('localhost', 8080, '/destination/$ref/activity'); + await _authHeader(request.headers); final response = await request.close(); if (response.statusCode == 200) { final stringData = await response.transform(utf8.decoder).join(); diff --git a/compass_app/app/lib/domain/components/auth/auth_login_component.dart b/compass_app/app/lib/domain/components/auth/auth_login_component.dart new file mode 100644 index 000000000..cdad2a49a --- /dev/null +++ b/compass_app/app/lib/domain/components/auth/auth_login_component.dart @@ -0,0 +1,41 @@ +import 'package:compass_model/model.dart'; +import 'package:logging/logging.dart'; + +import '../../../utils/result.dart'; +import '../../../data/repositories/auth/auth_token_repository.dart'; +import '../../../data/services/api_client.dart'; + +/// Performs user login. +class AuthLoginComponent { + AuthLoginComponent({ + required AuthTokenRepository authTokenRepository, + required ApiClient apiClient, + }) : _authTokenRepository = authTokenRepository, + _apiClient = apiClient; + + final AuthTokenRepository _authTokenRepository; + final ApiClient _apiClient; + final _log = Logger('AuthLoginComponent'); + + /// Login with username and password. + /// Performs login with the server and stores the obtained auth token. + Future> login({ + required String email, + required String password, + }) async { + final result = await _apiClient.login( + LoginRequest( + email: email, + password: password, + ), + ); + switch (result) { + case Ok(): + _log.info('User logged int'); + return await _authTokenRepository.saveToken(result.value.token); + case Error(): + _log.warning('Error logging in: ${result.error}'); + return Result.error(result.error); + } + } +} diff --git a/compass_app/app/lib/domain/components/auth/auth_logout_component.dart b/compass_app/app/lib/domain/components/auth/auth_logout_component.dart new file mode 100644 index 000000000..eda92a13f --- /dev/null +++ b/compass_app/app/lib/domain/components/auth/auth_logout_component.dart @@ -0,0 +1,45 @@ +import 'package:compass_model/model.dart'; +import 'package:logging/logging.dart'; + +import '../../../utils/result.dart'; +import '../../../data/repositories/auth/auth_token_repository.dart'; +import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart'; + +/// Performs user logout. +class AuthLogoutComponent { + AuthLogoutComponent({ + required AuthTokenRepository authTokenRepository, + required ItineraryConfigRepository itineraryConfigRepository, + }) : _authTokenRepository = authTokenRepository, + _itineraryConfigRepository = itineraryConfigRepository; + + final AuthTokenRepository _authTokenRepository; + final ItineraryConfigRepository _itineraryConfigRepository; + final _log = Logger('AuthLogoutComponent'); + + /// Perform user logout. + /// + /// 1. Clears the stored ItineraryConfig. + /// 2. Clears the stored auth token. + /// + /// GoRouter will automatically redirect the user to /login + Future> logout() async { + _log.info('User logged out'); + + // Clear stored ItineraryConfig + var result = await _itineraryConfigRepository + .setItineraryConfig(const ItineraryConfig()); + if (result is Error) { + _log.severe('Failed to clear stored ItineraryConfig'); + return result; + } + + // Clear stored auth token + result = await _authTokenRepository.saveToken(null); + if (result is Error) { + _log.severe('Failed to clear stored auth token'); + } + + return result; + } +} diff --git a/compass_app/app/lib/ui/booking/components/booking_create_component.dart b/compass_app/app/lib/domain/components/booking/booking_create_component.dart similarity index 100% rename from compass_app/app/lib/ui/booking/components/booking_create_component.dart rename to compass_app/app/lib/domain/components/booking/booking_create_component.dart diff --git a/compass_app/app/lib/ui/booking/components/booking_share_component.dart b/compass_app/app/lib/domain/components/booking/booking_share_component.dart similarity index 96% rename from compass_app/app/lib/ui/booking/components/booking_share_component.dart rename to compass_app/app/lib/domain/components/booking/booking_share_component.dart index 10f9e6266..89ba1e220 100644 --- a/compass_app/app/lib/ui/booking/components/booking_share_component.dart +++ b/compass_app/app/lib/domain/components/booking/booking_share_component.dart @@ -4,7 +4,7 @@ import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; import '../../../utils/result.dart'; -import '../../core/ui/date_format_start_end.dart'; +import '../../../ui/core/ui/date_format_start_end.dart'; typedef ShareFunction = Future Function(String text); diff --git a/compass_app/app/lib/main.dart b/compass_app/app/lib/main.dart index e5e208081..e7e3603bc 100644 --- a/compass_app/app/lib/main.dart +++ b/compass_app/app/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:provider/provider.dart'; import 'ui/core/localization/applocalization.dart'; import 'ui/core/themes/theme.dart'; @@ -29,7 +30,7 @@ class MainApp extends StatelessWidget { theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.system, - routerConfig: router, + routerConfig: router(context.read()), ); } } diff --git a/compass_app/app/lib/routing/router.dart b/compass_app/app/lib/routing/router.dart index 173b00d28..9803a677a 100644 --- a/compass_app/app/lib/routing/router.dart +++ b/compass_app/app/lib/routing/router.dart @@ -1,8 +1,13 @@ +import 'package:flutter/cupertino.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../domain/components/auth/auth_login_component.dart'; +import '../data/repositories/auth/auth_token_repository.dart'; import '../ui/activities/view_models/activities_viewmodel.dart'; import '../ui/activities/widgets/activities_screen.dart'; +import '../ui/auth/login/view_models/login_viewmodel.dart'; +import '../ui/auth/login/widgets/login_screen.dart'; import '../ui/booking/widgets/booking_screen.dart'; import '../ui/booking/view_models/booking_viewmodel.dart'; import '../ui/results/view_models/results_viewmodel.dart'; @@ -10,59 +15,99 @@ import '../ui/results/widgets/results_screen.dart'; import '../ui/search_form/view_models/search_form_viewmodel.dart'; import '../ui/search_form/widgets/search_form_screen.dart'; -/// Top go_router entry point -final router = GoRouter( - initialLocation: '/', - debugLogDiagnostics: true, - routes: [ - GoRoute( - path: '/', - builder: (context, state) { - final viewModel = SearchFormViewModel( - continentRepository: context.read(), - itineraryConfigRepository: context.read(), - ); - return SearchFormScreen(viewModel: viewModel); - }, +/// Top go_router entry point. +/// +/// Listens to changes in [AuthTokenRepository] to redirect the user +/// to /login when the user logs out. +GoRouter router( + AuthTokenRepository authTokenRepository, +) => + GoRouter( + initialLocation: '/', + debugLogDiagnostics: true, + redirect: _redirect, + refreshListenable: authTokenRepository, routes: [ GoRoute( - path: 'results', + path: '/', builder: (context, state) { - final viewModel = ResultsViewModel( - destinationRepository: context.read(), + final viewModel = SearchFormViewModel( + continentRepository: context.read(), itineraryConfigRepository: context.read(), ); - return ResultsScreen( - viewModel: viewModel, - ); - }, - ), - GoRoute( - path: 'activities', - builder: (context, state) { - final viewModel = ActivitiesViewModel( - activityRepository: context.read(), - itineraryConfigRepository: context.read(), - ); - return ActivitiesScreen( - viewModel: viewModel, - ); - }, - ), - GoRoute( - path: 'booking', - builder: (context, state) { - final viewModel = BookingViewModel( - itineraryConfigRepository: context.read(), - bookingComponent: context.read(), - shareComponent: context.read(), - ); - return BookingScreen( - viewModel: viewModel, - ); + return SearchFormScreen(viewModel: viewModel); }, + routes: [ + GoRoute( + path: 'login', + builder: (context, state) { + return LoginScreen( + viewModel: LoginViewModel( + authLoginComponent: AuthLoginComponent( + authTokenRepository: context.read(), + apiClient: context.read(), + ), + ), + ); + }, + ), + GoRoute( + path: 'results', + builder: (context, state) { + final viewModel = ResultsViewModel( + destinationRepository: context.read(), + itineraryConfigRepository: context.read(), + ); + return ResultsScreen( + viewModel: viewModel, + ); + }, + ), + GoRoute( + path: 'activities', + builder: (context, state) { + final viewModel = ActivitiesViewModel( + activityRepository: context.read(), + itineraryConfigRepository: context.read(), + ); + return ActivitiesScreen( + viewModel: viewModel, + ); + }, + ), + GoRoute( + path: 'booking', + builder: (context, state) { + final viewModel = BookingViewModel( + itineraryConfigRepository: context.read(), + bookingComponent: context.read(), + shareComponent: context.read(), + ); + return BookingScreen( + viewModel: viewModel, + ); + }, + ), + ], ), ], - ), - ], -); + ); + +// From https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart +Future _redirect(BuildContext context, GoRouterState state) async { + // if the user is not logged in, they need to login + final bool loggedIn = await context.read().hasToken(); + final bool loggingIn = state.matchedLocation == '/login'; + if (!loggedIn) { + return '/login'; + } + + // if the user is logged in but still on the login page, send them to + // the home page + if (loggingIn) { + return '/'; + } + + // no need to redirect at all + return null; +} diff --git a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart index ad45e3d3e..66df7c7c3 100644 --- a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart +++ b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart @@ -46,7 +46,9 @@ class _ActivitiesScreenState extends State { Widget build(BuildContext context) { return PopScope( canPop: false, - onPopInvokedWithResult: (d, r) => context.go('/results'), + onPopInvokedWithResult: (didPop, r) { + if (!didPop) context.go('/results'); + }, child: Scaffold( body: ListenableBuilder( listenable: widget.viewModel.loadActivities, diff --git a/compass_app/app/lib/ui/auth/login/view_models/login_viewmodel.dart b/compass_app/app/lib/ui/auth/login/view_models/login_viewmodel.dart new file mode 100644 index 000000000..0d9d74922 --- /dev/null +++ b/compass_app/app/lib/ui/auth/login/view_models/login_viewmodel.dart @@ -0,0 +1,30 @@ +import 'package:logging/logging.dart'; + +import '../../../../domain/components/auth/auth_login_component.dart'; +import '../../../../utils/command.dart'; +import '../../../../utils/result.dart'; + +class LoginViewModel { + LoginViewModel({ + required AuthLoginComponent authLoginComponent, + }) : _authLoginComponent = authLoginComponent { + login = Command1(_login); + } + + final AuthLoginComponent _authLoginComponent; + final _log = Logger('LoginViewModel'); + + late Command1 login; + + Future> _login((String, String) credentials) async { + final (email, password) = credentials; + final result = await _authLoginComponent.login( + email: email, + password: password, + ); + if (result is Error) { + _log.warning('Login failed! ${result.error}'); + } + return result; + } +} diff --git a/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart b/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart new file mode 100644 index 000000000..eb865b3b3 --- /dev/null +++ b/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/localization/applocalization.dart'; +import '../../../core/themes/dimens.dart'; +import '../view_models/login_viewmodel.dart'; +import 'tilted_cards.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({ + super.key, + required this.viewModel, + }); + + final LoginViewModel viewModel; + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final TextEditingController _email = + TextEditingController(text: 'email@example.com'); + final TextEditingController _password = + TextEditingController(text: 'password'); + + @override + void initState() { + super.initState(); + widget.viewModel.login.addListener(_onResult); + } + + @override + void didUpdateWidget(covariant LoginScreen oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.viewModel.login.removeListener(_onResult); + widget.viewModel.login.addListener(_onResult); + } + + @override + void dispose() { + widget.viewModel.login.removeListener(_onResult); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const TiltedCards(), + Padding( + padding: Dimens.of(context).edgeInsetsScreenSymmetric, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _email, + ), + const SizedBox(height: Dimens.paddingVertical), + TextField( + controller: _password, + obscureText: true, + ), + const SizedBox(height: Dimens.paddingVertical), + ListenableBuilder( + listenable: widget.viewModel.login, + builder: (context, _) { + return FilledButton( + onPressed: () { + widget.viewModel.login + .execute((_email.value.text, _password.value.text)); + }, + child: Text(AppLocalization.of(context).login), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } + + void _onResult() { + if (widget.viewModel.login.completed) { + widget.viewModel.login.clearResult(); + context.go('/'); + } + + if (widget.viewModel.login.error) { + widget.viewModel.login.clearResult(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalization.of(context).errorWhileLogin), + action: SnackBarAction( + label: AppLocalization.of(context).tryAgain, + onPressed: () => widget.viewModel.login + .execute((_email.value.text, _password.value.text)), + ), + ), + ); + } + } +} diff --git a/compass_app/app/lib/ui/auth/login/widgets/tilted_cards.dart b/compass_app/app/lib/ui/auth/login/widgets/tilted_cards.dart new file mode 100644 index 000000000..68b58c015 --- /dev/null +++ b/compass_app/app/lib/ui/auth/login/widgets/tilted_cards.dart @@ -0,0 +1,90 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class TiltedCards extends StatelessWidget { + const TiltedCards({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: const AspectRatio( + aspectRatio: 1, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: 0, + child: _Card( + imageUrl: 'https://rstr.in/google/tripedia/g2i0BsYPKW-', + width: 200, + height: 273, + tilt: -3.83 / 360, + ), + ), + Positioned( + right: 0, + child: _Card( + imageUrl: 'https://rstr.in/google/tripedia/980sqNgaDRK', + width: 180, + height: 230, + tilt: 3.46 / 360, + ), + ), + _Card( + imageUrl: 'https://rstr.in/google/tripedia/pHfPmf3o5NU', + width: 225, + height: 322, + tilt: 0, + showTitle: true, + ), + ], + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({ + required this.imageUrl, + required this.width, + required this.height, + required this.tilt, + this.showTitle = false, + }); + + final double tilt; + final double width; + final double height; + final String imageUrl; + final bool showTitle; + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: AlwaysStoppedAnimation(tilt), + child: SizedBox( + width: width, + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + color: showTitle ? Colors.black.withOpacity(0.5) : null, + colorBlendMode: showTitle ? BlendMode.darken : null, + ), + if (showTitle) Center(child: SvgPicture.asset('assets/logo.svg')), + ], + ), + ), + ), + ); + } +} diff --git a/compass_app/app/lib/ui/auth/logout/view_models/logout_viewmodel.dart b/compass_app/app/lib/ui/auth/logout/view_models/logout_viewmodel.dart new file mode 100644 index 000000000..071c2b12b --- /dev/null +++ b/compass_app/app/lib/ui/auth/logout/view_models/logout_viewmodel.dart @@ -0,0 +1,15 @@ +import '../../../../domain/components/auth/auth_logout_component.dart'; +import '../../../../utils/command.dart'; +import '../../../../utils/result.dart'; + +class LogoutViewModel { + LogoutViewModel({ + required AuthLogoutComponent authLogoutComponent, + }) : _authLogoutComponent = authLogoutComponent { + logout = Command0(_logout); + } + final AuthLogoutComponent _authLogoutComponent; + late Command0 logout; + + Future _logout() => _authLogoutComponent.logout(); +} diff --git a/compass_app/app/lib/ui/auth/logout/widgets/logout_button.dart b/compass_app/app/lib/ui/auth/logout/widgets/logout_button.dart new file mode 100644 index 000000000..ee80be5db --- /dev/null +++ b/compass_app/app/lib/ui/auth/logout/widgets/logout_button.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../../../core/localization/applocalization.dart'; +import '../../../core/themes/colors.dart'; +import '../view_models/logout_viewmodel.dart'; + +class LogoutButton extends StatefulWidget { + const LogoutButton({ + super.key, + required this.viewModel, + }); + + final LogoutViewModel viewModel; + + @override + State createState() => _LogoutButtonState(); +} + +class _LogoutButtonState extends State { + @override + void initState() { + super.initState(); + widget.viewModel.logout.addListener(_onResult); + } + + @override + void didUpdateWidget(covariant LogoutButton oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.viewModel.logout.removeListener(_onResult); + widget.viewModel.logout.addListener(_onResult); + } + + @override + void dispose() { + widget.viewModel.logout.removeListener(_onResult); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40.0, + width: 40.0, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(8.0), + color: Colors.transparent, + ), + child: InkResponse( + borderRadius: BorderRadius.circular(8.0), + onTap: () { + widget.viewModel.logout.execute(); + }, + child: Center( + child: Icon( + size: 24.0, + Icons.logout, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), + ); + } + + void _onResult() { + // We do not need to navigate to `/login` on logout, + // it is done automatically by GoRouter. + + if (widget.viewModel.logout.error) { + widget.viewModel.logout.clearResult(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalization.of(context).errorWhileLogout), + action: SnackBarAction( + label: AppLocalization.of(context).tryAgain, + onPressed: widget.viewModel.logout.execute, + ), + ), + ); + } + } +} diff --git a/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart b/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart index e270ee6cf..79e2274db 100644 --- a/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart +++ b/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart @@ -5,8 +5,8 @@ import 'package:logging/logging.dart'; import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart'; import '../../../utils/command.dart'; import '../../../utils/result.dart'; -import '../components/booking_create_component.dart'; -import '../components/booking_share_component.dart'; +import '../../../domain/components/booking/booking_create_component.dart'; +import '../../../domain/components/booking/booking_share_component.dart'; class BookingViewModel extends ChangeNotifier { BookingViewModel({ diff --git a/compass_app/app/lib/ui/booking/widgets/booking_screen.dart b/compass_app/app/lib/ui/booking/widgets/booking_screen.dart index dda5c04a1..5db968cb7 100644 --- a/compass_app/app/lib/ui/booking/widgets/booking_screen.dart +++ b/compass_app/app/lib/ui/booking/widgets/booking_screen.dart @@ -19,7 +19,9 @@ class BookingScreen extends StatelessWidget { Widget build(BuildContext context) { return PopScope( canPop: false, - onPopInvokedWithResult: (d, r) => context.go('/activities'), + onPopInvokedWithResult: (didPop, r) { + if (!didPop) context.go('/activities'); + }, child: Scaffold( body: ListenableBuilder( listenable: viewModel.loadBooking, diff --git a/compass_app/app/lib/ui/core/localization/applocalization.dart b/compass_app/app/lib/ui/core/localization/applocalization.dart index ae41e20f1..2ee86816a 100644 --- a/compass_app/app/lib/ui/core/localization/applocalization.dart +++ b/compass_app/app/lib/ui/core/localization/applocalization.dart @@ -17,9 +17,12 @@ class AppLocalization { 'errorWhileLoadingBooking': 'Error while loading booking', 'errorWhileLoadingContinents': 'Error while loading continents', 'errorWhileLoadingDestinations': 'Error while loading destinations', + 'errorWhileLogin': 'Error while trying to login', + 'errorWhileLogout': 'Error while trying to logout', 'errorWhileSavingActivities': 'Error while saving activities', 'errorWhileSavingItinerary': 'Error while saving itinerary', 'evening': 'Evening', + 'login': 'Login', 'search': 'Search', 'searchDestination': 'Search destination', 'selected': '{1} selected', @@ -68,6 +71,12 @@ class AppLocalization { String get when => _get('when'); + String get errorWhileLogin => _get('errorWhileLogin'); + + String get login => _get('login'); + + String get errorWhileLogout => _get('errorWhileLogout'); + String selected(int value) => _get('selected').replaceAll('{1}', value.toString()); } diff --git a/compass_app/app/lib/ui/core/ui/search_bar.dart b/compass_app/app/lib/ui/core/ui/search_bar.dart index 89de234ca..a41575a1a 100644 --- a/compass_app/app/lib/ui/core/ui/search_bar.dart +++ b/compass_app/app/lib/ui/core/ui/search_bar.dart @@ -1,6 +1,9 @@ import 'package:compass_model/model.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../auth/logout/view_models/logout_viewmodel.dart'; +import '../../auth/logout/widgets/logout_button.dart'; import '../localization/applocalization.dart'; import '../themes/dimens.dart'; import 'date_format_start_end.dart'; @@ -16,10 +19,12 @@ class AppSearchBar extends StatelessWidget { super.key, this.config, this.onTap, + this.homeScreen = false, }); final ItineraryConfig? config; final GestureTapCallback? onTap; + final bool homeScreen; @override Widget build(BuildContext context) { @@ -48,7 +53,12 @@ class AppSearchBar extends StatelessWidget { ), ), const SizedBox(width: 10), - const HomeButton(), + // Display a logout button if at the root route + homeScreen + ? LogoutButton( + viewModel: LogoutViewModel(authLogoutComponent: context.read()), + ) + : const HomeButton(), ], ); } diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart index a2101f1fa..3ee5dad5a 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart @@ -38,7 +38,7 @@ class SearchFormScreen extends StatelessWidget { right: Dimens.of(context).paddingScreenHorizontal, bottom: Dimens.paddingVertical, ), - child: const AppSearchBar(), + child: const AppSearchBar(homeScreen: true), ), ), SearchFormContinent(viewModel: viewModel), diff --git a/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift b/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift index 9ac62d532..db8fb0789 100644 --- a/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,12 @@ import Foundation import path_provider_foundation import share_plus +import shared_preferences_foundation import sqflite func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/compass_app/app/pubspec.yaml b/compass_app/app/pubspec.yaml index 53ed1fa58..c7cf81ba8 100644 --- a/compass_app/app/pubspec.yaml +++ b/compass_app/app/pubspec.yaml @@ -14,12 +14,14 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + flutter_svg: ^2.0.10+1 go_router: ^14.2.0 google_fonts: ^6.2.1 intl: any logging: ^1.2.0 provider: ^6.1.2 share_plus: ^7.2.2 + shared_preferences: ^2.3.1 dev_dependencies: flutter_test: @@ -33,3 +35,4 @@ flutter: assets: - assets/activities.json - assets/destinations.json + - assets/logo.svg diff --git a/compass_app/app/test/domain/components/auth/auth_login_component_test.dart b/compass_app/app/test/domain/components/auth/auth_login_component_test.dart new file mode 100644 index 000000000..70ba2233d --- /dev/null +++ b/compass_app/app/test/domain/components/auth/auth_login_component_test.dart @@ -0,0 +1,59 @@ +import 'package:compass_app/domain/components/auth/auth_login_component.dart'; +import 'package:compass_app/data/services/api_client.dart'; +import 'package:compass_app/utils/result.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../testing/fakes/repositories/fake_auth_token_repository.dart'; +import '../../../../testing/fakes/services/fake_api_client.dart'; + +void main() { + group('AuthLoginComponent test', () { + late AuthLoginComponent authLoginComponent; + late FakeAuthTokenRepository authTokenRepository; + late ApiClient apiClient; + + setUp(() { + authTokenRepository = FakeAuthTokenRepository(); + apiClient = _ApiClient(); + authLoginComponent = AuthLoginComponent( + authTokenRepository: authTokenRepository, + apiClient: apiClient, + ); + }); + + test('should perform login', () async { + // Pass valid email and password + final result = await authLoginComponent.login( + email: 'EMAIL', + password: 'PASSWORD', + ); + // Got good response + expect(result, isA>()); + expect(authTokenRepository.token, 'TOKEN'); + }); + + test('should fail to login', () async { + // Pass wrong email and password + final result = await authLoginComponent.login( + email: 'WRONG', + password: 'WRONG', + ); + // Got bad response + expect(result, isA>()); + expect(result.asError.error.toString(), 'Exception: ERROR'); + expect(authTokenRepository.token, null); + }); + }); +} + +class _ApiClient extends FakeApiClient { + @override + Future> login(LoginRequest loginRequest) async { + if (loginRequest.email == 'EMAIL' && loginRequest.password == 'PASSWORD') { + return Result.ok(const LoginResponse(token: 'TOKEN', userId: '1234')); + } else { + return Result.error(Exception('ERROR')); + } + } +} diff --git a/compass_app/app/test/domain/components/auth/auth_logout_component_test.dart b/compass_app/app/test/domain/components/auth/auth_logout_component_test.dart new file mode 100644 index 000000000..e30928e11 --- /dev/null +++ b/compass_app/app/test/domain/components/auth/auth_logout_component_test.dart @@ -0,0 +1,36 @@ +import 'package:compass_app/domain/components/auth/auth_logout_component.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../testing/fakes/repositories/fake_auth_token_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; + +void main() { + group('AuthLogoutComponent test', () { + late AuthLogoutComponent authLogoutComponent; + late FakeAuthTokenRepository authTokenRepository; + late FakeItineraryConfigRepository itineraryConfigRepository; + + setUp(() { + authTokenRepository = FakeAuthTokenRepository(); + itineraryConfigRepository = FakeItineraryConfigRepository( + itineraryConfig: const ItineraryConfig(continent: 'CONTINENT'), + ); + authLogoutComponent = AuthLogoutComponent( + authTokenRepository: authTokenRepository, + itineraryConfigRepository: itineraryConfigRepository, + ); + }); + + test('should perform logout', () async { + await authLogoutComponent.logout(); + // Token should be removed + expect(authTokenRepository.token, null); + // Itinerary config should be cleared + expect( + itineraryConfigRepository.itineraryConfig, + const ItineraryConfig(), + ); + }); + }); +} diff --git a/compass_app/app/test/ui/booking/booking_create_component_test.dart b/compass_app/app/test/domain/components/booking/booking_create_component_test.dart similarity index 63% rename from compass_app/app/test/ui/booking/booking_create_component_test.dart rename to compass_app/app/test/domain/components/booking/booking_create_component_test.dart index f189180ae..5f3075d3d 100644 --- a/compass_app/app/test/ui/booking/booking_create_component_test.dart +++ b/compass_app/app/test/domain/components/booking/booking_create_component_test.dart @@ -1,12 +1,12 @@ -import 'package:compass_app/ui/booking/components/booking_create_component.dart'; +import 'package:compass_app/domain/components/booking/booking_create_component.dart'; import 'package:compass_model/model.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../../../testing/fakes/repositories/fake_activities_repository.dart'; -import '../../../testing/fakes/repositories/fake_destination_repository.dart'; -import '../../../testing/models/activity.dart'; -import '../../../testing/models/booking.dart'; -import '../../../testing/models/destination.dart'; +import '../../../../testing/fakes/repositories/fake_activities_repository.dart'; +import '../../../../testing/fakes/repositories/fake_destination_repository.dart'; +import '../../../../testing/models/activity.dart'; +import '../../../../testing/models/booking.dart'; +import '../../../../testing/models/destination.dart'; void main() { group('BookingCreateComponent tests', () { diff --git a/compass_app/app/test/ui/booking/booking_share_component_test.dart b/compass_app/app/test/domain/components/booking/booking_share_component_test.dart similarity index 79% rename from compass_app/app/test/ui/booking/booking_share_component_test.dart rename to compass_app/app/test/domain/components/booking/booking_share_component_test.dart index b553f2b80..6c884d3e9 100644 --- a/compass_app/app/test/ui/booking/booking_share_component_test.dart +++ b/compass_app/app/test/domain/components/booking/booking_share_component_test.dart @@ -1,9 +1,9 @@ -import 'package:compass_app/ui/booking/components/booking_share_component.dart'; +import 'package:compass_app/domain/components/booking/booking_share_component.dart'; import 'package:compass_model/model.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../../../testing/models/activity.dart'; -import '../../../testing/models/destination.dart'; +import '../../../../testing/models/activity.dart'; +import '../../../../testing/models/destination.dart'; void main() { group('BookingShareComponent tests', () { diff --git a/compass_app/app/test/ui/auth/login_screen_test.dart b/compass_app/app/test/ui/auth/login_screen_test.dart new file mode 100644 index 000000000..db5c0a402 --- /dev/null +++ b/compass_app/app/test/ui/auth/login_screen_test.dart @@ -0,0 +1,64 @@ +import 'package:compass_app/domain/components/auth/auth_login_component.dart'; +import 'package:compass_app/ui/auth/login/view_models/login_viewmodel.dart'; +import 'package:compass_app/ui/auth/login/widgets/login_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; + +import '../../../testing/app.dart'; +import '../../../testing/fakes/repositories/fake_auth_token_repository.dart'; +import '../../../testing/fakes/services/fake_api_client.dart'; +import '../../../testing/mocks.dart'; + +void main() { + group('LoginScreen test', () { + late LoginViewModel viewModel; + late MockGoRouter goRouter; + late FakeAuthTokenRepository fakeAuthTokenRepository; + + setUp(() { + fakeAuthTokenRepository = FakeAuthTokenRepository(); + viewModel = LoginViewModel( + authLoginComponent: AuthLoginComponent( + authTokenRepository: fakeAuthTokenRepository, + apiClient: FakeApiClient(), + ), + ); + goRouter = MockGoRouter(); + }); + + Future loadScreen(WidgetTester tester) async { + await testApp( + tester, + LoginScreen(viewModel: viewModel), + goRouter: goRouter, + ); + } + + testWidgets('should load screen', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + expect(find.byType(LoginScreen), findsOneWidget); + }); + }); + + testWidgets('should perform login', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + + // Repo should have no key + expect(fakeAuthTokenRepository.token, null); + + // Perform login + await tester.tap(find.text('Login')); + await tester.pumpAndSettle(); + + // Repo should have key + expect(fakeAuthTokenRepository.token, 'TOKEN'); + + // Should navigate to home screen + verify(() => goRouter.go('/')).called(1); + }); + }); + }); +} diff --git a/compass_app/app/test/ui/auth/logout_button_test.dart b/compass_app/app/test/ui/auth/logout_button_test.dart new file mode 100644 index 000000000..82a4bd9f3 --- /dev/null +++ b/compass_app/app/test/ui/auth/logout_button_test.dart @@ -0,0 +1,77 @@ +import 'package:compass_app/domain/components/auth/auth_logout_component.dart'; +import 'package:compass_app/ui/auth/logout/view_models/logout_viewmodel.dart'; +import 'package:compass_app/ui/auth/logout/widgets/logout_button.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; + +import '../../../testing/app.dart'; +import '../../../testing/fakes/repositories/fake_auth_token_repository.dart'; +import '../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../testing/mocks.dart'; + +void main() { + group('LogoutButton test', () { + late MockGoRouter goRouter; + late FakeAuthTokenRepository fakeAuthTokenRepository; + late FakeItineraryConfigRepository fakeItineraryConfigRepository; + late LogoutViewModel viewModel; + + setUp(() { + goRouter = MockGoRouter(); + fakeAuthTokenRepository = FakeAuthTokenRepository(); + // Setup a token, should be cleared after logout + fakeAuthTokenRepository.token = 'TOKEN'; + // Setup an ItineraryConfig with some data, should be cleared after logout + fakeItineraryConfigRepository = FakeItineraryConfigRepository( + itineraryConfig: const ItineraryConfig(continent: 'CONTINENT')); + viewModel = LogoutViewModel( + authLogoutComponent: AuthLogoutComponent( + authTokenRepository: fakeAuthTokenRepository, + itineraryConfigRepository: fakeItineraryConfigRepository, + ), + ); + }); + + Future loadScreen(WidgetTester tester) async { + await testApp( + tester, + LogoutButton(viewModel: viewModel), + goRouter: goRouter, + ); + } + + testWidgets('should load widget', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + expect(find.byType(LogoutButton), findsOneWidget); + }); + }); + + testWidgets('should perform logout', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + + // Repo should have a key + expect(fakeAuthTokenRepository.token, 'TOKEN'); + // Itinerary config should have data + expect( + fakeItineraryConfigRepository.itineraryConfig, + const ItineraryConfig(continent: 'CONTINENT'), + ); + + // // Perform logout + await tester.tap(find.byType(LogoutButton)); + await tester.pumpAndSettle(); + + // Repo should have no key + expect(fakeAuthTokenRepository.token, null); + // Itinerary config should be cleared + expect( + fakeItineraryConfigRepository.itineraryConfig, + const ItineraryConfig(), + ); + }); + }); + }); +} diff --git a/compass_app/app/test/ui/booking/booking_screen_test.dart b/compass_app/app/test/ui/booking/booking_screen_test.dart index 56f0c9a17..168aa0352 100644 --- a/compass_app/app/test/ui/booking/booking_screen_test.dart +++ b/compass_app/app/test/ui/booking/booking_screen_test.dart @@ -1,5 +1,5 @@ -import 'package:compass_app/ui/booking/components/booking_create_component.dart'; -import 'package:compass_app/ui/booking/components/booking_share_component.dart'; +import 'package:compass_app/domain/components/booking/booking_create_component.dart'; +import 'package:compass_app/domain/components/booking/booking_share_component.dart'; import 'package:compass_app/ui/booking/view_models/booking_viewmodel.dart'; import 'package:compass_app/ui/booking/widgets/booking_screen.dart'; import 'package:compass_model/model.dart'; diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart index 971e62008..30539ca84 100644 --- a/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart +++ b/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart @@ -1,10 +1,13 @@ +import 'package:compass_app/domain/components/auth/auth_logout_component.dart'; import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; import '../../../../testing/app.dart'; +import '../../../../testing/fakes/repositories/fake_auth_token_repository.dart'; import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; import '../../../../testing/mocks.dart'; @@ -25,7 +28,13 @@ void main() { loadWidget(WidgetTester tester) async { await testApp( tester, - SearchFormScreen(viewModel: viewModel), + Provider.value( + value: AuthLogoutComponent( + authTokenRepository: FakeAuthTokenRepository(), + itineraryConfigRepository: FakeItineraryConfigRepository(), + ), + child: SearchFormScreen(viewModel: viewModel), + ), goRouter: goRouter, ); } diff --git a/compass_app/app/testing/fakes/repositories/fake_auth_token_repository.dart b/compass_app/app/testing/fakes/repositories/fake_auth_token_repository.dart new file mode 100644 index 000000000..68ea49074 --- /dev/null +++ b/compass_app/app/testing/fakes/repositories/fake_auth_token_repository.dart @@ -0,0 +1,18 @@ +import 'package:compass_app/data/repositories/auth/auth_token_repository.dart'; +import 'package:compass_app/utils/result.dart'; + +class FakeAuthTokenRepository extends AuthTokenRepository { + String? token; + + @override + Future> getToken() async { + return Result.ok(token); + } + + @override + Future> saveToken(String? token) async { + this.token = token; + notifyListeners(); + return Result.ok(null); + } +} diff --git a/compass_app/app/testing/fakes/services/fake_api_client.dart b/compass_app/app/testing/fakes/services/fake_api_client.dart index 7613b0975..46e625feb 100644 --- a/compass_app/app/testing/fakes/services/fake_api_client.dart +++ b/compass_app/app/testing/fakes/services/fake_api_client.dart @@ -69,4 +69,9 @@ class FakeApiClient implements ApiClient { return SynchronousFuture(Result.ok([])); } + + @override + Future> login(LoginRequest loginRequest) async { + return Result.ok(const LoginResponse(token: 'TOKEN', userId: '1234')); + } } diff --git a/compass_app/model/lib/model.dart b/compass_app/model/lib/model.dart index 54578522d..8538e5ccc 100644 --- a/compass_app/model/lib/model.dart +++ b/compass_app/model/lib/model.dart @@ -1,6 +1,8 @@ library; export 'src/model/activity/activity.dart'; +export 'src/model/auth/login_request/login_request.dart'; +export 'src/model/auth/login_response/login_response.dart'; export 'src/model/booking/booking.dart'; export 'src/model/continent/continent.dart'; export 'src/model/destination/destination.dart'; diff --git a/compass_app/model/lib/src/model/auth/login_request/login_request.dart b/compass_app/model/lib/src/model/auth/login_request/login_request.dart new file mode 100644 index 000000000..b154a0c75 --- /dev/null +++ b/compass_app/model/lib/src/model/auth/login_request/login_request.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'login_request.freezed.dart'; + +part 'login_request.g.dart'; + +/// Simple data class to hold login request data. +@freezed +class LoginRequest with _$LoginRequest { + const factory LoginRequest({ + /// Email address. + required String email, + + /// Plain text password. + required String password, + }) = _LoginRequest; + + factory LoginRequest.fromJson(Map json) => + _$LoginRequestFromJson(json); +} diff --git a/compass_app/model/lib/src/model/auth/login_request/login_request.freezed.dart b/compass_app/model/lib/src/model/auth/login_request/login_request.freezed.dart new file mode 100644 index 000000000..d130b9a4f --- /dev/null +++ b/compass_app/model/lib/src/model/auth/login_request/login_request.freezed.dart @@ -0,0 +1,192 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'login_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +LoginRequest _$LoginRequestFromJson(Map json) { + return _LoginRequest.fromJson(json); +} + +/// @nodoc +mixin _$LoginRequest { + /// Email address. + String get email => throw _privateConstructorUsedError; + + /// Plain text password. + String get password => throw _privateConstructorUsedError; + + /// Serializes this LoginRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of LoginRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $LoginRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LoginRequestCopyWith<$Res> { + factory $LoginRequestCopyWith( + LoginRequest value, $Res Function(LoginRequest) then) = + _$LoginRequestCopyWithImpl<$Res, LoginRequest>; + @useResult + $Res call({String email, String password}); +} + +/// @nodoc +class _$LoginRequestCopyWithImpl<$Res, $Val extends LoginRequest> + implements $LoginRequestCopyWith<$Res> { + _$LoginRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LoginRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? email = null, + Object? password = null, + }) { + return _then(_value.copyWith( + email: null == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String, + password: null == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$LoginRequestImplCopyWith<$Res> + implements $LoginRequestCopyWith<$Res> { + factory _$$LoginRequestImplCopyWith( + _$LoginRequestImpl value, $Res Function(_$LoginRequestImpl) then) = + __$$LoginRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String email, String password}); +} + +/// @nodoc +class __$$LoginRequestImplCopyWithImpl<$Res> + extends _$LoginRequestCopyWithImpl<$Res, _$LoginRequestImpl> + implements _$$LoginRequestImplCopyWith<$Res> { + __$$LoginRequestImplCopyWithImpl( + _$LoginRequestImpl _value, $Res Function(_$LoginRequestImpl) _then) + : super(_value, _then); + + /// Create a copy of LoginRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? email = null, + Object? password = null, + }) { + return _then(_$LoginRequestImpl( + email: null == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String, + password: null == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$LoginRequestImpl implements _LoginRequest { + const _$LoginRequestImpl({required this.email, required this.password}); + + factory _$LoginRequestImpl.fromJson(Map json) => + _$$LoginRequestImplFromJson(json); + + /// Email address. + @override + final String email; + + /// Plain text password. + @override + final String password; + + @override + String toString() { + return 'LoginRequest(email: $email, password: $password)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LoginRequestImpl && + (identical(other.email, email) || other.email == email) && + (identical(other.password, password) || + other.password == password)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, email, password); + + /// Create a copy of LoginRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LoginRequestImplCopyWith<_$LoginRequestImpl> get copyWith => + __$$LoginRequestImplCopyWithImpl<_$LoginRequestImpl>(this, _$identity); + + @override + Map toJson() { + return _$$LoginRequestImplToJson( + this, + ); + } +} + +abstract class _LoginRequest implements LoginRequest { + const factory _LoginRequest( + {required final String email, + required final String password}) = _$LoginRequestImpl; + + factory _LoginRequest.fromJson(Map json) = + _$LoginRequestImpl.fromJson; + + /// Email address. + @override + String get email; + + /// Plain text password. + @override + String get password; + + /// Create a copy of LoginRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LoginRequestImplCopyWith<_$LoginRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/compass_app/model/lib/src/model/auth/login_request/login_request.g.dart b/compass_app/model/lib/src/model/auth/login_request/login_request.g.dart new file mode 100644 index 000000000..0a11bd9f2 --- /dev/null +++ b/compass_app/model/lib/src/model/auth/login_request/login_request.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'login_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$LoginRequestImpl _$$LoginRequestImplFromJson(Map json) => + _$LoginRequestImpl( + email: json['email'] as String, + password: json['password'] as String, + ); + +Map _$$LoginRequestImplToJson(_$LoginRequestImpl instance) => + { + 'email': instance.email, + 'password': instance.password, + }; diff --git a/compass_app/model/lib/src/model/auth/login_response/login_response.dart b/compass_app/model/lib/src/model/auth/login_response/login_response.dart new file mode 100644 index 000000000..d7388a894 --- /dev/null +++ b/compass_app/model/lib/src/model/auth/login_response/login_response.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'login_response.freezed.dart'; + +part 'login_response.g.dart'; + +/// LoginResponse model. +@freezed +class LoginResponse with _$LoginResponse { + const factory LoginResponse({ + /// The token to be used for authentication. + required String token, + + /// The user id. + required String userId, + }) = _LoginResponse; + + factory LoginResponse.fromJson(Map json) => + _$LoginResponseFromJson(json); +} diff --git a/compass_app/model/lib/src/model/auth/login_response/login_response.freezed.dart b/compass_app/model/lib/src/model/auth/login_response/login_response.freezed.dart new file mode 100644 index 000000000..bfc5829a6 --- /dev/null +++ b/compass_app/model/lib/src/model/auth/login_response/login_response.freezed.dart @@ -0,0 +1,191 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'login_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +LoginResponse _$LoginResponseFromJson(Map json) { + return _LoginResponse.fromJson(json); +} + +/// @nodoc +mixin _$LoginResponse { + /// The token to be used for authentication. + String get token => throw _privateConstructorUsedError; + + /// The user id. + String get userId => throw _privateConstructorUsedError; + + /// Serializes this LoginResponse to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of LoginResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $LoginResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LoginResponseCopyWith<$Res> { + factory $LoginResponseCopyWith( + LoginResponse value, $Res Function(LoginResponse) then) = + _$LoginResponseCopyWithImpl<$Res, LoginResponse>; + @useResult + $Res call({String token, String userId}); +} + +/// @nodoc +class _$LoginResponseCopyWithImpl<$Res, $Val extends LoginResponse> + implements $LoginResponseCopyWith<$Res> { + _$LoginResponseCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LoginResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? token = null, + Object? userId = null, + }) { + return _then(_value.copyWith( + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + userId: null == userId + ? _value.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$LoginResponseImplCopyWith<$Res> + implements $LoginResponseCopyWith<$Res> { + factory _$$LoginResponseImplCopyWith( + _$LoginResponseImpl value, $Res Function(_$LoginResponseImpl) then) = + __$$LoginResponseImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String token, String userId}); +} + +/// @nodoc +class __$$LoginResponseImplCopyWithImpl<$Res> + extends _$LoginResponseCopyWithImpl<$Res, _$LoginResponseImpl> + implements _$$LoginResponseImplCopyWith<$Res> { + __$$LoginResponseImplCopyWithImpl( + _$LoginResponseImpl _value, $Res Function(_$LoginResponseImpl) _then) + : super(_value, _then); + + /// Create a copy of LoginResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? token = null, + Object? userId = null, + }) { + return _then(_$LoginResponseImpl( + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + userId: null == userId + ? _value.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$LoginResponseImpl implements _LoginResponse { + const _$LoginResponseImpl({required this.token, required this.userId}); + + factory _$LoginResponseImpl.fromJson(Map json) => + _$$LoginResponseImplFromJson(json); + + /// The token to be used for authentication. + @override + final String token; + + /// The user id. + @override + final String userId; + + @override + String toString() { + return 'LoginResponse(token: $token, userId: $userId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LoginResponseImpl && + (identical(other.token, token) || other.token == token) && + (identical(other.userId, userId) || other.userId == userId)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, token, userId); + + /// Create a copy of LoginResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LoginResponseImplCopyWith<_$LoginResponseImpl> get copyWith => + __$$LoginResponseImplCopyWithImpl<_$LoginResponseImpl>(this, _$identity); + + @override + Map toJson() { + return _$$LoginResponseImplToJson( + this, + ); + } +} + +abstract class _LoginResponse implements LoginResponse { + const factory _LoginResponse( + {required final String token, + required final String userId}) = _$LoginResponseImpl; + + factory _LoginResponse.fromJson(Map json) = + _$LoginResponseImpl.fromJson; + + /// The token to be used for authentication. + @override + String get token; + + /// The user id. + @override + String get userId; + + /// Create a copy of LoginResponse + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LoginResponseImplCopyWith<_$LoginResponseImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/compass_app/model/lib/src/model/auth/login_response/login_response.g.dart b/compass_app/model/lib/src/model/auth/login_response/login_response.g.dart new file mode 100644 index 000000000..f1ee1db63 --- /dev/null +++ b/compass_app/model/lib/src/model/auth/login_response/login_response.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'login_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$LoginResponseImpl _$$LoginResponseImplFromJson(Map json) => + _$LoginResponseImpl( + token: json['token'] as String, + userId: json['userId'] as String, + ); + +Map _$$LoginResponseImplToJson(_$LoginResponseImpl instance) => + { + 'token': instance.token, + 'userId': instance.userId, + }; diff --git a/compass_app/server/bin/compass_server.dart b/compass_app/server/bin/compass_server.dart index 209b7dbe2..5d3d00105 100644 --- a/compass_app/server/bin/compass_server.dart +++ b/compass_app/server/bin/compass_server.dart @@ -1,7 +1,9 @@ import 'dart:io'; +import 'package:compass_server/middleware/auth.dart'; import 'package:compass_server/routes/continent.dart'; import 'package:compass_server/routes/destination.dart'; +import 'package:compass_server/routes/login.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; @@ -9,15 +11,18 @@ import 'package:shelf_router/shelf_router.dart'; // Configure routes. final _router = Router() ..get('/continent', continentHandler) - ..mount('/destination', DestinationApi().router.call); + ..mount('/destination', DestinationApi().router.call) + ..mount('/login', LoginApi().router.call); void main(List args) async { // Use any available host or container IP (usually `0.0.0.0`). final ip = InternetAddress.anyIPv4; // Configure a pipeline that logs requests. - final handler = - Pipeline().addMiddleware(logRequests()).addHandler(_router.call); + final handler = Pipeline() + .addMiddleware(logRequests()) + .addMiddleware(authRequests()) + .addHandler(_router.call); // For running in containers, we respect the PORT environment variable. final port = int.parse(Platform.environment['PORT'] ?? '8080'); diff --git a/compass_app/server/lib/config/constants.dart b/compass_app/server/lib/config/constants.dart new file mode 100644 index 000000000..4a9831b65 --- /dev/null +++ b/compass_app/server/lib/config/constants.dart @@ -0,0 +1,14 @@ +class Constants { + /// Email for the hardcoded login. + static const email = 'email@example.com'; + + /// Password for the hardcoded login. + static const password = 'password'; + + /// Token to be returned on successful login. + static const token = + ' e1c37dfd973353b78bb71df050e2c6e72d53034e148920383968ae49b96f1fd2'; + + /// User id to be returned on successful login. + static const userId = '123'; +} diff --git a/compass_app/server/lib/middleware/auth.dart b/compass_app/server/lib/middleware/auth.dart new file mode 100644 index 000000000..c238d75d5 --- /dev/null +++ b/compass_app/server/lib/middleware/auth.dart @@ -0,0 +1,26 @@ +import 'package:shelf/shelf.dart'; + +import '../config/constants.dart'; + +/// Implements a simple auth Middleware. +/// +/// This is provided as an example for Flutter architectural purposes only +/// and shouldn't be used as example on how to implement authentication +/// in production. +/// +/// This Middleware checks if the token is present in the request headers, +/// otherwise returns a 401 Unauthorized response. +/// +/// This token does not expire and is not secure. +Middleware authRequests() => (innerHandler) { + return (Request request) async { + if (request.url.path != 'login' && + request.headers['Authorization'] != 'Bearer ${Constants.token}') { + // If the request is not a login request and the token is not present, + // return a 401 Unauthorized response. + return Response.unauthorized('Unauthorized'); + } + + return innerHandler(request); + }; + }; diff --git a/compass_app/server/lib/routes/login.dart b/compass_app/server/lib/routes/login.dart new file mode 100644 index 000000000..28067f334 --- /dev/null +++ b/compass_app/server/lib/routes/login.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:compass_model/model.dart'; +import 'package:compass_server/config/constants.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +/// Implements a simple login API. +/// +/// This is provided as an example for Flutter architectural purposes only +/// and shouldn't be used as example on how to implement authentication +/// in production. +/// +/// This API only accepts a fixed email and password for demonstration purposes, +/// then returns a hardcoded token and a user id. +/// +/// This token does not expire and is not secure. +class LoginApi { + Router get router { + final router = Router(); + + router.post('/', (Request request) async { + final body = await request.readAsString(); + final loginRequest = LoginRequest.fromJson(json.decode(body)); + + if (loginRequest.email == Constants.email && + loginRequest.password == Constants.password) { + return Response.ok( + json.encode( + LoginResponse( + token: Constants.token, + userId: Constants.userId, + ), + ), + headers: {'Content-Type': 'application/json'}, + ); + } + + return Response.unauthorized('Invalid credentials'); + }); + + return router; + } +} diff --git a/compass_app/server/test/server_test.dart b/compass_app/server/test/server_test.dart index 5ae5eb5ce..90524b6f2 100644 --- a/compass_app/server/test/server_test.dart +++ b/compass_app/server/test/server_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:compass_model/model.dart'; +import 'package:compass_server/config/constants.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; @@ -10,6 +11,10 @@ void main() { final host = 'http://0.0.0.0:$port'; late Process p; + var headers = { + 'Authorization': 'Bearer ${Constants.token}', + }; + setUp(() async { p = await Process.start( 'dart', @@ -24,7 +29,11 @@ void main() { test('Get Continent end-point', () async { // Query /continent end-point - final response = await get(Uri.parse('$host/continent')); + final response = await get( + Uri.parse('$host/continent'), + headers: headers, + ); + expect(response.statusCode, 200); // Parse json response list final list = jsonDecode(response.body) as List; @@ -36,7 +45,10 @@ void main() { test('Get Destination end-point', () async { // Query /destination end-point - final response = await get(Uri.parse('$host/destination')); + final response = await get( + Uri.parse('$host/destination'), + headers: headers, + ); expect(response.statusCode, 200); // Parse json response list final list = jsonDecode(response.body) as List; @@ -48,7 +60,10 @@ void main() { test('Get Activities end-point', () async { // Query /destination/alaska/activity end-point - final response = await get(Uri.parse('$host/destination/alaska/activity')); + final response = await get( + Uri.parse('$host/destination/alaska/activity'), + headers: headers, + ); expect(response.statusCode, 200); // Parse json response list final list = jsonDecode(response.body) as List; @@ -59,7 +74,49 @@ void main() { }); test('404', () async { - final response = await get(Uri.parse('$host/foobar')); + final response = await get( + Uri.parse('$host/foobar'), + headers: headers, + ); expect(response.statusCode, 404); }); + + test('Login with valid credentials', () async { + final response = await post( + Uri.parse('$host/login'), + body: jsonEncode( + LoginRequest( + email: Constants.email, + password: Constants.password, + ), + ), + ); + expect(response.statusCode, 200); + final loginResponse = LoginResponse.fromJson(jsonDecode(response.body)); + expect(loginResponse.token, Constants.token); + expect(loginResponse.userId, Constants.userId); + }); + + test('Login with wrong credentials', () async { + final response = await post( + Uri.parse('$host/login'), + body: jsonEncode( + LoginRequest( + email: 'INVALID', + password: 'INVALID', + ), + ), + ); + expect(response.statusCode, 401); + }); + + test('Unauthorized request', () async { + // Query /continent end-point + // No auth headers + final response = await get( + Uri.parse('$host/continent'), + ); + + expect(response.statusCode, 401); + }); }