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); + }); }