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