Compass App: Basic auth (#2385)

This PR introduces basic auth implementation between the app and the
server as part of the architectural example.

This PR is a big bigger than the previous ones so I hope this
explanation helps:

### Server implementation

The server introduces a new endpoint `/login` to perform login requests,
which accepts login requests defined in the `LoginRequest` data class,
with an email and password.

The login process "simulates" checking on the email and password and
responds with a "token" and user ID, defined by the `LoginResponse` data
class. This is a simple hard-coded check and in any way a guide on how
to implement authentication, just a way to demonstrate an architectural
example.

The server also implements a middleware in
`server/lib/middleware/auth.dart`. This checks that the requests between
the app and the server carry a valid authorization token in the headers,
responding with an unauthorized error otherwise.

### App implementation

The app introduces the following new parts:

- `AuthTokenRepository`: In charge of storing the auth token.
- `AuthLoginComponent`: In charge of performing login.
- `AuthLogoutComponent`: In charge of performing logout.
- `LoginScreen` with `LoginViewModel`: Displays the login screen.
- `LogoutButton` with `LogoutViewModel`: Displays a logout button.

The `AuthTokenRepository` acts as the source of truth to decide if the
user is logged in or not. If the repository contains a token, it means
the user is logged in, otherwise if the token is null, it means that the
user is logged out. This repository is also a `ChangeNotifier`, which
allows listening to change in it.

The `GoRouter` has been modified so it listens to changes in the
`AuthTokenRepository` using the `refreshListenable` property. It also
implements a `redirect`, so if the token is set to `null` in the
repository, the router will redirect users automatically to the login
screen. This follows the example found in
https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart

On app start, `GoRouter` checks the `AuthTokenRepository`, if a token
exists the user stays in `/`, if not, the user is redirected to
`/login`.

The `ApiClient` has also been modified, so it reads the stored token
from the repository when performing network calls, and adds it to the
auth headers.

The two new components implement basic login and logout functionality.
The `AuthLoginComponent` will send the request using the `ApiClient`,
and then store the token from the response. The `AuthLogoutComponent`
clears the stored token from the repository, and as well clears any
existing itinerary configuration, effectively cleaning the app state.
Performing logout redirects the user to the login screen, as explained.

The `LoginScreen` uses the `AuthLoginComponent` internally, it displays
two text fields and a login button, plus the application logo on top. A
successful login redirects the user to `/`.

The `LogoutButton` replaces the home button at the `/`, and on tap it
will perform logout using the `AuthLogoutComponent`.

**Development target app**

The development target app works slightly different compared to the
staging build. In this case, the `AuthTokenRepository` always contains a
fake token, so the app believes it is always logged in.

Auth is only used in the staging build when the server is involved.

## Screenshots

<details>
<summary>Screenshots</summary>

The logout button in the top right corner:

![Screenshot from 2024-08-14
15-28-54](https://github.com/user-attachments/assets/1c5a37dc-9fa1-4950-917e-0c7272896780)

The login screen:

![Screenshot from 2024-08-14
15-28-12](https://github.com/user-attachments/assets/3c26ccc2-8e3b-42d2-a230-d31048af6960)


</details>

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-devrel
channel on [Discord].

<!-- Links -->
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md
[CLA]: https://cla.developers.google.com/
[Discord]:
https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md
[Contributors Guide]:
https://github.com/flutter/samples/blob/main/CONTRIBUTING.md
pull/2389/head
Miguel Beltran 3 months ago committed by GitHub
parent 0c88289339
commit e0f25da42b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,10 @@
<svg width="179" height="41" viewBox="0 0 179 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.4858 33.9056C10.6086 28.3987 4.24247 19.7722 4.24247 15.4858C4.24247 9.2856 9.2856 4.24247 15.4858 4.24247C20.9097 4.24247 25.45 8.10415 26.4996 13.2205H30.8006C29.7022 5.75102 23.253 0 15.4858 0C6.94711 0 0 6.94711 0 15.4858C0 23.4825 12.5126 36.9911 13.943 38.5045L15.4858 40.1399L17.0285 38.4996C18.3271 37.118 28.8185 25.7917 30.6835 17.7413H26.3043C24.8006 22.3499 19.6111 29.2384 15.4858 33.9007V33.9056ZM15.4858 11.2091C17.0138 11.2091 18.3515 12.0146 19.1082 13.2205H23.6973C22.7014 9.62246 19.4011 6.96664 15.4906 6.96664C10.7941 6.96664 6.9764 10.7893 6.9764 15.4809C6.9764 20.1725 10.799 23.9951 15.4906 23.9951C19.4011 23.9951 22.7063 21.3442 23.6973 17.7413H19.1082C18.3515 18.9471 17.0138 19.7526 15.4858 19.7526C13.1277 19.7526 11.2091 17.834 11.2091 15.476C11.2091 13.118 13.1277 11.1993 15.4858 11.1993V11.2091Z" fill="white"/>
<path d="M46.2525 28.389C44.5536 27.4028 43.2452 26.0358 42.3176 24.2978C41.3901 22.5598 40.9263 20.5826 40.9263 18.3711C40.9263 16.1595 41.3852 14.1823 42.3079 12.4443C43.2306 10.7063 44.5292 9.34422 46.2135 8.35317C47.8978 7.36212 49.8555 6.87392 52.0866 6.87392C53.8978 6.87392 55.5284 7.21566 56.9783 7.89914C58.4283 8.58262 59.6146 9.55414 60.5373 10.8137C61.46 12.0733 62.0459 13.533 62.3046 15.188H57.8766C57.6814 14.2604 57.3201 13.4646 56.7977 12.7958C56.2753 12.127 55.6163 11.6095 54.8205 11.2433C54.0296 10.8772 53.1362 10.6965 52.15 10.6965C50.8221 10.6965 49.6553 11.0334 48.6447 11.7071C47.6342 12.3808 46.8579 13.3035 46.3063 14.4703C45.7546 15.6371 45.4812 16.9406 45.4812 18.3808C45.4812 19.9138 45.7546 21.2563 46.3063 22.4085C46.8579 23.5607 47.6342 24.4492 48.635 25.079C49.6358 25.7087 50.8026 26.0212 52.1305 26.0212C53.1508 26.0212 54.0687 25.8259 54.884 25.4353C55.7041 25.0448 56.373 24.498 56.8905 23.7999C57.408 23.1017 57.7448 22.306 57.901 21.4223H62.3437C62.1582 23.0578 61.6211 24.5126 60.7326 25.7917C59.8441 27.0659 58.6626 28.0668 57.1883 28.7844C55.7188 29.5021 54.0394 29.8633 52.1549 29.8633C49.9141 29.8633 47.9417 29.3703 46.2428 28.3841L46.2525 28.389Z" fill="white"/>
<path d="M67.4649 28.76C66.1272 28.0179 65.0727 26.9732 64.3014 25.6209C63.53 24.2685 63.1443 22.7258 63.1443 20.9976C63.1443 19.2694 63.5202 17.7315 64.277 16.3841C65.0337 15.0367 66.0833 13.9821 67.4307 13.2205C68.7782 12.4589 70.2916 12.0781 71.9661 12.0781C73.6407 12.0781 75.1639 12.4541 76.5015 13.2108C77.8392 13.9675 78.8839 15.0171 79.6358 16.3646C80.3876 17.712 80.7635 19.2547 80.7635 20.9927C80.7635 22.7307 80.3827 24.2588 79.6211 25.6062C78.8595 26.9537 77.8099 28.0033 76.4674 28.7454C75.1248 29.4874 73.6211 29.8633 71.9515 29.8633C70.2818 29.8633 68.7977 29.4923 67.46 28.7502L67.4649 28.76ZM74.2607 25.6502C74.9442 25.2254 75.4861 24.6103 75.8815 23.8048C76.277 23.0041 76.4771 22.0668 76.4771 20.9976C76.4771 19.9284 76.2818 18.9911 75.8913 18.1904C75.5007 17.3898 74.9637 16.7698 74.2802 16.3304C73.5967 15.891 72.8205 15.6762 71.9564 15.6762C71.0923 15.6762 70.316 15.8959 69.6325 16.3304C68.9491 16.7698 68.412 17.3898 68.0215 18.2002C67.6309 19.0057 67.4356 19.948 67.4356 21.0171C67.4356 22.0863 67.6358 23.0041 68.0312 23.8048C68.4267 24.6005 68.9637 25.2157 69.6423 25.6502C70.3209 26.0847 71.0923 26.2995 71.9564 26.2995C72.8205 26.2995 73.5772 26.0847 74.2656 25.6599L74.2607 25.6502Z" fill="white"/>
<path d="M86.7391 12.4492V14.7633H86.8026C87.0711 14.2067 87.447 13.7283 87.9352 13.3182C88.4234 12.913 88.9946 12.6005 89.6488 12.3857C90.303 12.1709 91.0255 12.0635 91.8164 12.0635C93.0223 12.0635 94.0329 12.3076 94.8481 12.7958C95.6634 13.284 96.2737 13.9919 96.6789 14.9195H96.7228C97.2159 13.9919 97.9092 13.284 98.8075 12.7958C99.7058 12.3076 100.765 12.0635 102 12.0635C103.235 12.0635 104.241 12.3027 105.12 12.7812C105.999 13.2596 106.663 13.9724 107.126 14.9195C107.59 15.8666 107.82 17.0187 107.82 18.3759V29.5021H103.577V19.0838C103.577 18.0537 103.309 17.2482 102.767 16.6624C102.225 16.0765 101.473 15.7836 100.506 15.7836C99.8473 15.7836 99.2761 15.9349 98.7831 16.2327C98.2949 16.5305 97.9189 16.9553 97.6553 17.507C97.3917 18.0586 97.2599 18.6982 97.2599 19.4305V29.507H93.0613V19.0594C93.0613 18.0391 92.7977 17.2433 92.2656 16.6575C91.7334 16.0716 90.9962 15.7836 90.0491 15.7836C89.3901 15.7836 88.8091 15.9349 88.3063 16.2376C87.8034 16.5403 87.4128 16.9699 87.1394 17.5167C86.8661 18.0684 86.7294 18.703 86.7294 19.4207V29.4972H82.4869V12.4492H86.7391Z" fill="white"/>
<path d="M118.013 29.5509C117.305 29.3361 116.656 29.009 116.075 28.5794C115.494 28.1449 114.996 27.6127 114.586 26.9732H114.508V35.507H110.266V12.4492H114.508V14.9781H114.586C114.967 14.3727 115.44 13.8503 116.007 13.4207C116.573 12.9911 117.208 12.6591 117.911 12.4248C118.614 12.1904 119.38 12.0781 120.2 12.0781C121.68 12.0781 122.988 12.4541 124.111 13.2059C125.239 13.9577 126.108 15.0122 126.718 16.3694C127.328 17.7266 127.636 19.2742 127.636 21.0171C127.636 22.76 127.318 24.3857 126.689 25.7234C126.054 27.0611 125.175 28.0863 124.047 28.8039C122.92 29.5167 121.646 29.878 120.23 29.878C119.458 29.878 118.716 29.7706 118.008 29.5558L118.013 29.5509ZM121.192 25.5574C121.87 25.1327 122.402 24.5273 122.783 23.7462C123.164 22.965 123.354 22.0423 123.354 20.9829C123.354 19.9235 123.164 19.0155 122.783 18.2295C122.402 17.4435 121.875 16.8381 121.201 16.4183C120.528 15.9984 119.756 15.7836 118.892 15.7836C118.028 15.7836 117.227 16.0033 116.554 16.4378C115.88 16.8723 115.362 17.4923 114.996 18.2832C114.63 19.0741 114.449 19.9821 114.449 20.9976C114.449 22.0131 114.635 22.965 115.006 23.7511C115.377 24.5371 115.895 25.1424 116.563 25.5623C117.232 25.9821 118.004 26.1969 118.877 26.1969C119.751 26.1969 120.513 25.9821 121.192 25.5574Z" fill="white"/>
<path d="M132.381 28.7991C131.258 28.0863 130.379 27.0562 129.745 25.7185C129.105 24.3808 128.788 22.8137 128.788 21.0122C128.788 19.2108 129.095 17.7266 129.706 16.3646C130.316 15.0074 131.185 13.9528 132.313 13.201C133.44 12.4492 134.739 12.0733 136.209 12.0733C137.034 12.0733 137.795 12.1904 138.498 12.4199C139.201 12.6493 139.836 12.9813 140.397 13.4061C140.959 13.8308 141.427 14.3434 141.808 14.9439H141.916V12.4443H146.158V29.4972H141.916V27.0122H141.808C141.408 27.6274 140.915 28.1497 140.334 28.5794C139.753 29.009 139.109 29.3263 138.406 29.546C137.702 29.7608 136.96 29.8682 136.189 29.8682C134.768 29.8682 133.499 29.5118 132.376 28.7942L132.381 28.7991ZM139.87 25.5672C140.539 25.1473 141.056 24.5419 141.427 23.7559C141.799 22.9699 141.984 22.0521 141.984 21.0025C141.984 19.9528 141.803 19.079 141.437 18.2881C141.071 17.4972 140.554 16.8821 139.885 16.4427C139.216 16.0033 138.435 15.7885 137.537 15.7885C136.638 15.7885 135.901 16.0033 135.227 16.428C134.554 16.8528 134.026 17.4581 133.655 18.2393C133.279 19.0204 133.094 19.9382 133.094 20.9878C133.094 22.0375 133.284 22.9699 133.665 23.7559C134.046 24.5419 134.573 25.1473 135.247 25.5672C135.921 25.987 136.692 26.2018 137.556 26.2018C138.42 26.2018 139.201 25.9919 139.87 25.5672Z" fill="white"/>
<path d="M149.839 28.345C148.545 27.3149 147.852 25.8601 147.75 23.9756H151.855C151.904 24.8397 152.222 25.5232 152.803 26.0212C153.384 26.5192 154.209 26.7681 155.278 26.7681C156.235 26.7681 156.967 26.5875 157.47 26.2262C157.973 25.865 158.227 25.4158 158.227 24.869C158.227 24.415 158.109 24.0537 157.88 23.7803C157.65 23.5069 157.27 23.2775 156.747 23.092C156.225 22.9065 155.473 22.7161 154.492 22.5208C152.988 22.2327 151.807 21.8959 150.943 21.5102C150.078 21.1245 149.439 20.6168 149.019 19.9821C148.604 19.3475 148.394 18.5371 148.394 17.5509C148.394 16.472 148.658 15.52 149.18 14.6949C149.702 13.8698 150.493 13.2303 151.543 12.7665C152.593 12.3027 153.847 12.0733 155.307 12.0733C156.767 12.0733 157.968 12.2832 159.003 12.7079C160.038 13.1278 160.838 13.7527 161.405 14.5777C161.971 15.4028 162.288 16.389 162.362 17.5411H158.334C158.261 16.8088 157.978 16.2376 157.48 15.8227C156.982 15.4077 156.259 15.1978 155.312 15.1978C154.746 15.1978 154.253 15.271 153.838 15.4126C153.423 15.559 153.1 15.769 152.881 16.0472C152.661 16.3255 152.549 16.6575 152.549 17.048C152.549 17.4581 152.656 17.7852 152.866 18.0293C153.076 18.2734 153.437 18.4736 153.945 18.6396C154.453 18.8056 155.185 18.9813 156.142 19.162C157.704 19.4598 158.934 19.8015 159.823 20.1872C160.711 20.5729 161.38 21.0855 161.82 21.725C162.264 22.3646 162.484 23.1896 162.484 24.21C162.484 25.3719 162.21 26.3727 161.663 27.2124C161.117 28.0521 160.301 28.7063 159.218 29.1799C158.134 29.6534 156.806 29.8877 155.244 29.8877C152.93 29.8877 151.123 29.3751 149.834 28.345H149.839Z" fill="white"/>
<path d="M165.476 28.345C164.183 27.3149 163.489 25.8601 163.387 23.9756H167.493C167.541 24.8397 167.859 25.5232 168.44 26.0212C169.021 26.5192 169.846 26.7681 170.915 26.7681C171.872 26.7681 172.604 26.5875 173.107 26.2262C173.61 25.865 173.864 25.4158 173.864 24.869C173.864 24.415 173.746 24.0537 173.517 23.7803C173.288 23.5069 172.907 23.2775 172.384 23.092C171.862 22.9065 171.11 22.7161 170.129 22.5208C168.625 22.2327 167.444 21.8959 166.58 21.5102C165.716 21.1245 165.076 20.6168 164.656 19.9821C164.241 19.3475 164.031 18.5371 164.031 17.5509C164.031 16.472 164.295 15.52 164.817 14.6949C165.34 13.8698 166.13 13.2303 167.18 12.7665C168.23 12.3027 169.484 12.0733 170.944 12.0733C172.404 12.0733 173.605 12.2832 174.64 12.7079C175.675 13.1278 176.475 13.7527 177.042 14.5777C177.608 15.4028 177.925 16.389 177.999 17.5411H173.971C173.898 16.8088 173.615 16.2376 173.117 15.8227C172.619 15.4077 171.896 15.1978 170.949 15.1978C170.383 15.1978 169.89 15.271 169.475 15.4126C169.06 15.559 168.737 15.769 168.518 16.0472C168.298 16.3255 168.186 16.6575 168.186 17.048C168.186 17.4581 168.293 17.7852 168.503 18.0293C168.713 18.2734 169.074 18.4736 169.582 18.6396C170.09 18.8056 170.822 18.9813 171.779 19.162C173.341 19.4598 174.571 19.8015 175.46 20.1872C176.349 20.5729 177.017 21.0855 177.457 21.725C177.901 22.3646 178.121 23.1896 178.121 24.21C178.121 25.3719 177.847 26.3727 177.301 27.2124C176.754 28.0521 175.938 28.7063 174.855 29.1799C173.771 29.6534 172.443 29.8877 170.881 29.8877C168.567 29.8877 166.76 29.3751 165.471 28.345H165.476Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

@ -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<SingleChildWidget> _sharedProviders = [
@ -29,27 +34,44 @@ List<SingleChildWidget> _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<SingleChildWidget> 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<SingleChildWidget> get providersRemote {
/// Configure dependencies for local data.
/// This dependency list uses repositories that provide local data.
/// The user is always logged in.
List<SingleChildWidget> get providersLocal {
return [
ChangeNotifierProvider.value(
value: AuthTokenRepositoryDev() as AuthTokenRepository,
),
Provider.value(
value: DestinationRepositoryLocal() as DestinationRepository,
),

@ -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<Result<String?>> getToken();
/// Store the token.
/// Will notifiy listeners.
Future<Result<void>> saveToken(String? token);
/// Returns true when the token exists, otherwise false.
Future<bool> hasToken() async {
final result = await getToken();
return result is Ok<String?> && result.value != null;
}
}

@ -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<Result<String?>> getToken() async {
return Result.ok('token');
}
@override
Future<Result<void>> saveToken(String? token) async {
notifyListeners();
return Result.ok(null);
}
}

@ -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<Result<String?>> 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<Result<void>> 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);
}
}
}

@ -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<String?> 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<void> _authHeader(HttpHeaders headers) async {
final result = await _authTokenRepository.getToken();
if (result is Ok<String?>) {
if (result.value != null) {
headers.add(HttpHeaders.authorizationHeader, 'Bearer ${result.value}');
}
}
}
Future<Result<LoginResponse>> 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<Result<List<Continent>>> 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();

@ -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<Result<void>> login({
required String email,
required String password,
}) async {
final result = await _apiClient.login(
LoginRequest(
email: email,
password: password,
),
);
switch (result) {
case Ok<LoginResponse>():
_log.info('User logged int');
return await _authTokenRepository.saveToken(result.value.token);
case Error<LoginResponse>():
_log.warning('Error logging in: ${result.error}');
return Result.error(result.error);
}
}
}

@ -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<Result<void>> logout() async {
_log.info('User logged out');
// Clear stored ItineraryConfig
var result = await _itineraryConfigRepository
.setItineraryConfig(const ItineraryConfig());
if (result is Error<void>) {
_log.severe('Failed to clear stored ItineraryConfig');
return result;
}
// Clear stored auth token
result = await _authTokenRepository.saveToken(null);
if (result is Error<void>) {
_log.severe('Failed to clear stored auth token');
}
return result;
}
}

@ -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<void> Function(String text);

@ -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()),
);
}
}

@ -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,10 +15,18 @@ 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(
/// 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: '/',
@ -25,6 +38,19 @@ final router = GoRouter(
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) {
@ -65,4 +91,23 @@ final router = GoRouter(
],
),
],
);
);
// From https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart
Future<String?> _redirect(BuildContext context, GoRouterState state) async {
// if the user is not logged in, they need to login
final bool loggedIn = await context.read<AuthTokenRepository>().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;
}

@ -46,7 +46,9 @@ class _ActivitiesScreenState extends State<ActivitiesScreen> {
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,

@ -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<void, (String email, String password)>(_login);
}
final AuthLoginComponent _authLoginComponent;
final _log = Logger('LoginViewModel');
late Command1 login;
Future<Result<void>> _login((String, String) credentials) async {
final (email, password) = credentials;
final result = await _authLoginComponent.login(
email: email,
password: password,
);
if (result is Error<void>) {
_log.warning('Login failed! ${result.error}');
}
return result;
}
}

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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)),
),
),
);
}
}
}

@ -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')),
],
),
),
),
);
}
}

@ -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<Result> _logout() => _authLogoutComponent.logout();
}

@ -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<LogoutButton> createState() => _LogoutButtonState();
}
class _LogoutButtonState extends State<LogoutButton> {
@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,
),
),
);
}
}
}

@ -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({

@ -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,

@ -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());
}

@ -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(),
],
);
}

@ -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),

@ -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"))
}

@ -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

@ -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<Ok<void>>());
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<Error<void>>());
expect(result.asError.error.toString(), 'Exception: ERROR');
expect(authTokenRepository.token, null);
});
});
}
class _ApiClient extends FakeApiClient {
@override
Future<Result<LoginResponse>> 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'));
}
}
}

@ -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(),
);
});
});
}

@ -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', () {

@ -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', () {

@ -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<void> 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);
});
});
});
}

@ -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<void> 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(),
);
});
});
});
}

@ -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';

@ -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,
);
}

@ -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<Result<String?>> getToken() async {
return Result.ok(token);
}
@override
Future<Result<void>> saveToken(String? token) async {
this.token = token;
notifyListeners();
return Result.ok(null);
}
}

@ -69,4 +69,9 @@ class FakeApiClient implements ApiClient {
return SynchronousFuture(Result.ok([]));
}
@override
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
return Result.ok(const LoginResponse(token: 'TOKEN', userId: '1234'));
}
}

@ -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';

@ -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<String, Object?> json) =>
_$LoginRequestFromJson(json);
}

@ -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>(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<String, dynamic> 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<String, dynamic> 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<LoginRequest> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_request.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$LoginRequestImpl _$$LoginRequestImplFromJson(Map<String, dynamic> json) =>
_$LoginRequestImpl(
email: json['email'] as String,
password: json['password'] as String,
);
Map<String, dynamic> _$$LoginRequestImplToJson(_$LoginRequestImpl instance) =>
<String, dynamic>{
'email': instance.email,
'password': instance.password,
};

@ -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<String, Object?> json) =>
_$LoginResponseFromJson(json);
}

@ -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>(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<String, dynamic> 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<String, dynamic> 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<LoginResponse> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$LoginResponseImpl _$$LoginResponseImplFromJson(Map<String, dynamic> json) =>
_$LoginResponseImpl(
token: json['token'] as String,
userId: json['userId'] as String,
);
Map<String, dynamic> _$$LoginResponseImplToJson(_$LoginResponseImpl instance) =>
<String, dynamic>{
'token': instance.token,
'userId': instance.userId,
};

@ -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<String> 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');

@ -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';
}

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

@ -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;
}
}

@ -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<dynamic>;
@ -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<dynamic>;
@ -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<dynamic>;
@ -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);
});
}

Loading…
Cancel
Save