[Compass App] Home screen with booking list (#2428)

This PR introduces the storage and retrieval of bookings.

### Server implementation

- New booking routes:
  - GET `/booking`: Obtain the list ofer user bookings
  - GET `/booking/{id}`: Obtain the specific booking object
  - POST `/booking`: Creates a new booking
- The `BookingApiModel` objects are incomplete, the `Destination` is a
reference, not the full object, and the `Activity` list are also listed
as references only.
- Server booking always has an existing booking created (Alaska, North
America) for demo purposes.
- Storage is "in memory", stopping the server deletes all stored
bookings.

### New `BookingRepository`

- New repository class.
- Both local and remote implementations.
- Converts the `BookingApiModel` into a complete `Booking` that the app
can use, or a `BookingSummary` that only contains the necessary
information for the home screen.

### New `LocalDataService`

- Service that loads hard-coded data or from assets.

### New `HomeScreen`

- Route path: `/`
- Loads and displays the list of created bookings from the
`BookingRepository`.
- Tap on a booking title opens the `BookingScreen`.
- Floating Action Button to create a new booking.

### Changes in `BookingScreen`

- Can be accessed at the end of creating a booking or from the home
screen when tapping a booking title.
- Two commands: 
- `createBooking`: Takes the stored `ItineraryConfig` and creates a
booking, the booking is stored to the `BookingRepository` (locally or on
the server).
- `loadBooking`: Takes a booking ID and loads that existing booking from
the `BookingRepository`.
- Simplified navigation: Once at `BookingScreen`, user can only navigate
back to `HomeScreen`.
- Share button converted to `FloatingActionButton`

### Integration Tests

- Updated to use new home screen.
- Updated to cover opening an existing booking in tests.

### TODO Next

- Refactor the `compass_model` project and move data model classes to
`server` and `app`, then delete project.
- Implement some user information for the home screen (e.g. retrieve
user name and profile picture url)

### Screencast

[Screencast from 2024-09-02
16-25-25.webm](https://github.com/user-attachments/assets/8aba4a61-def6-4752-a4e5-70cbed362524)

## 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/2444/head
Miguel Beltran 1 year ago committed by GitHub
parent 3be6873210
commit fb869e729e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3,6 +3,8 @@ import 'package:compass_app/main.dart';
import 'package:compass_app/ui/activities/widgets/activities_screen.dart';
import 'package:compass_app/ui/booking/widgets/booking_screen.dart';
import 'package:compass_app/ui/core/ui/custom_checkbox.dart';
import 'package:compass_app/ui/core/ui/home_button.dart';
import 'package:compass_app/ui/home/widgets/home_screen.dart';
import 'package:compass_app/ui/results/widgets/result_card.dart';
import 'package:compass_app/ui/results/widgets/results_screen.dart';
import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart';
@ -27,6 +29,30 @@ void main() {
);
});
testWidgets('Open a booking', (tester) async {
// Load app widget with local configuration
await tester.pumpWidget(
MultiProvider(
providers: providersLocal,
child: const MainApp(),
),
);
await tester.pumpAndSettle();
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
await tester.pumpAndSettle();
// Tap on booking (Alaska is created by default)
await tester.tap(find.text('Alaska, North America'));
await tester.pumpAndSettle();
// Should be at booking screen
expect(find.byType(BookingScreen), findsOneWidget);
expect(find.text('Alaska'), findsOneWidget);
});
testWidgets('Create booking', (tester) async {
// Load app widget with local configuration
await tester.pumpWidget(
@ -35,7 +61,14 @@ void main() {
child: const MainApp(),
),
);
await tester.pumpAndSettle();
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
await tester.pumpAndSettle();
// Select create new booking
await tester.tap(find.byKey(const ValueKey('booking-button')));
await tester.pumpAndSettle();
// Search destinations screen
@ -91,6 +124,16 @@ void main() {
// Should be at booking screen
expect(find.byType(BookingScreen), findsOneWidget);
expect(find.text('Amalfi Coast'), findsOneWidget);
// Navigate back home
await tester.tap(find.byType(HomeButton));
await tester.pumpAndSettle();
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
// New Booking should appear
expect(find.text('Amalfi Coast, Europe'), findsOneWidget);
});
});
}

@ -8,6 +8,7 @@ import 'package:compass_app/ui/auth/logout/widgets/logout_button.dart';
import 'package:compass_app/ui/booking/widgets/booking_screen.dart';
import 'package:compass_app/ui/core/ui/custom_checkbox.dart';
import 'package:compass_app/ui/core/ui/home_button.dart';
import 'package:compass_app/ui/home/widgets/home_screen.dart';
import 'package:compass_app/ui/results/widgets/result_card.dart';
import 'package:compass_app/ui/results/widgets/results_screen.dart';
import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart';
@ -24,7 +25,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('end-to-end test with remote data', () {
final port = '8080';
const port = '8080';
late Process p;
setUpAll(() async {
@ -61,6 +62,49 @@ void main() {
expect(find.byType(LoginScreen), findsOneWidget);
});
testWidgets('Open a booking', (tester) async {
// Load app widget with local configuration
await tester.pumpWidget(
MultiProvider(
providers: providersRemote,
child: const MainApp(),
),
);
await tester.pumpAndSettle();
// Login screen because logget out
expect(find.byType(LoginScreen), findsOneWidget);
// Perform login (credentials are prefilled)
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
await tester.pumpAndSettle();
// Tap on booking (Alaska is created by default)
await tester.tap(find.text('Alaska, North America'));
await tester.pumpAndSettle();
// Should be at booking screen
expect(find.byType(BookingScreen), findsOneWidget);
expect(find.text('Alaska'), findsOneWidget);
// Navigate back to home
await tester.tap(find.byType(HomeButton).first);
await tester.pumpAndSettle();
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
// Perform logout
await tester.tap(find.byType(LogoutButton).first);
await tester.pumpAndSettle();
expect(find.byType(LoginScreen), findsOneWidget);
});
testWidgets('Create booking', (tester) async {
// Load app widget with local configuration
await tester.pumpWidget(
@ -79,6 +123,14 @@ void main() {
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
await tester.pumpAndSettle();
// Select create new booking
await tester.tap(find.byKey(const ValueKey('booking-button')));
await tester.pumpAndSettle();
// Search destinations screen
expect(find.byType(SearchFormScreen), findsOneWidget);
@ -136,7 +188,12 @@ void main() {
// Navigate back to home
await tester.tap(find.byType(HomeButton).first);
await tester.pumpAndSettle();
expect(find.byType(SearchFormScreen), findsOneWidget);
// Home screen
expect(find.byType(HomeScreen), findsOneWidget);
// New Booking should appear
expect(find.text('Amalfi Coast, Europe'), findsOneWidget);
// Perform logout
await tester.tap(find.byType(LogoutButton).first);

@ -4,7 +4,11 @@ import 'package:provider/provider.dart';
import '../data/repositories/auth/auth_repository.dart';
import '../data/repositories/auth/auth_repository_dev.dart';
import '../data/repositories/auth/auth_repository_remote.dart';
import '../data/services/auth_api_client.dart';
import '../data/repositories/booking/booking_repository.dart';
import '../data/repositories/booking/booking_repository_local.dart';
import '../data/repositories/booking/booking_repository_remote.dart';
import '../data/services/api/auth_api_client.dart';
import '../data/services/local/local_data_service.dart';
import '../data/services/shared_preferences_service.dart';
import '../data/repositories/activity/activity_repository.dart';
import '../data/repositories/activity/activity_repository_local.dart';
@ -17,7 +21,7 @@ import '../data/repositories/destination/destination_repository_local.dart';
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 '../data/services/api/api_client.dart';
import '../domain/components/booking/booking_create_component.dart';
import '../domain/components/booking/booking_share_component.dart';
@ -28,6 +32,7 @@ List<SingleChildWidget> _sharedProviders = [
create: (context) => BookingCreateComponent(
destinationRepository: context.read(),
activityRepository: context.read(),
bookingRepository: context.read(),
),
),
Provider(
@ -74,6 +79,11 @@ List<SingleChildWidget> get providersRemote {
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
Provider(
create: (context) => BookingRepositoryRemote(
apiClient: context.read(),
) as BookingRepository,
),
..._sharedProviders,
];
}
@ -87,13 +97,27 @@ List<SingleChildWidget> get providersLocal {
value: AuthRepositoryDev() as AuthRepository,
),
Provider.value(
value: DestinationRepositoryLocal() as DestinationRepository,
value: LocalDataService(),
),
Provider.value(
value: ContinentRepositoryLocal() as ContinentRepository,
Provider(
create: (context) => DestinationRepositoryLocal(
localDataService: context.read(),
) as DestinationRepository,
),
Provider.value(
value: ActivityRepositoryLocal() as ActivityRepository,
Provider(
create: (context) => ContinentRepositoryLocal(
localDataService: context.read(),
) as ContinentRepository,
),
Provider(
create: (context) => ActivityRepositoryLocal(
localDataService: context.read(),
) as ActivityRepository,
),
Provider(
create: (context) => BookingRepositoryLocal(
localDataService: context.read(),
) as BookingRepository,
),
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,

@ -1,37 +1,28 @@
import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart';
import '../../../config/assets.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'activity_repository.dart';
/// Local implementation of ActivityRepository
/// Uses data from assets folder
class ActivityRepositoryLocal implements ActivityRepository {
ActivityRepositoryLocal({
required LocalDataService localDataService,
}) : _localDataService = localDataService;
final LocalDataService _localDataService;
@override
Future<Result<List<Activity>>> getByDestination(String ref) async {
try {
final localData = await _loadAsset();
final list = _parse(localData);
final activities =
list.where((activity) => activity.destinationRef == ref).toList();
final activities = (await _localDataService.getActivities())
.where((activity) => activity.destinationRef == ref)
.toList();
return Result.ok(activities);
} on Exception catch (error) {
return Result.error(error);
}
}
Future<String> _loadAsset() async {
return await rootBundle.loadString(Assets.activities);
}
List<Activity> _parse(String localData) {
final parsed = (jsonDecode(localData) as List).cast<Map<String, dynamic>>();
return parsed.map<Activity>((json) => Activity.fromJson(json)).toList();
}
}

@ -1,7 +1,7 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import '../../services/api/api_client.dart';
import 'activity_repository.dart';
/// Remote data source for [Activity].

@ -2,8 +2,8 @@ import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import '../../services/auth_api_client.dart';
import '../../services/api/api_client.dart';
import '../../services/api/auth_api_client.dart';
import '../../services/shared_preferences_service.dart';
import 'auth_repository.dart';

@ -0,0 +1,15 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../utils/result.dart';
abstract class BookingRepository {
/// Returns the list of [BookingSummary] for the current user.
Future<Result<List<BookingSummary>>> getBookingsList();
/// Returns a full [Booking] given the id.
Future<Result<Booking>> getBooking(int id);
/// Creates a new [Booking].
Future<Result<void>> createBooking(Booking booking);
}

@ -0,0 +1,75 @@
import 'dart:async';
import 'package:compass_model/model.dart';
import 'package:collection/collection.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'booking_repository.dart';
class BookingRepositoryLocal implements BookingRepository {
BookingRepositoryLocal({
required LocalDataService localDataService,
}) : _localDataService = localDataService;
final _bookings = List<Booking>.empty(growable: true);
final LocalDataService _localDataService;
@override
Future<Result<void>> createBooking(Booking booking) async {
_bookings.add(booking);
return Result.ok(null);
}
@override
Future<Result<Booking>> getBooking(int id) async {
await _createDefaultBooking();
if (id >= _bookings.length || id < 0) {
return Result.error(Exception('Invalid id: $id'));
}
return Result.ok(_bookings[id]);
}
@override
Future<Result<List<BookingSummary>>> getBookingsList() async {
await _createDefaultBooking();
return Result.ok(_createSummaries());
}
List<BookingSummary> _createSummaries() {
return _bookings
.mapIndexed(
(index, booking) => BookingSummary(
id: index,
name:
'${booking.destination.name}, ${booking.destination.continent}',
startDate: booking.startDate,
endDate: booking.endDate,
),
)
.toList();
}
Future<void> _createDefaultBooking() async {
// create a default booking the first time
if (_bookings.isEmpty) {
final destination = (await _localDataService.getDestinations()).first;
final activities = (await _localDataService.getActivities())
.where((activity) => activity.destinationRef == destination.ref)
.take(4)
.toList();
_bookings.add(
Booking(
startDate: DateTime(2024, 1, 1),
endDate: DateTime(2024, 2, 1),
destination: destination,
activity: activities,
),
);
}
}
}

@ -0,0 +1,103 @@
import 'package:compass_model/model.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../utils/result.dart';
import '../../services/api/api_client.dart';
import '../../services/api/model/booking/booking_api_model.dart';
import 'booking_repository.dart';
class BookingRepositoryRemote implements BookingRepository {
BookingRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
List<Destination>? _cachedDestinations;
@override
Future<Result<void>> createBooking(Booking booking) async {
try {
final BookingApiModel bookingApiModel = BookingApiModel(
startDate: booking.startDate,
endDate: booking.endDate,
name: '${booking.destination.name}, ${booking.destination.continent}',
destinationRef: booking.destination.ref,
activitiesRef:
booking.activity.map((activity) => activity.ref).toList(),
);
return _apiClient.postBooking(bookingApiModel);
} on Exception catch (e) {
return Result.error(e);
}
}
@override
Future<Result<Booking>> getBooking(int id) async {
try {
// Get booking by ID from server
final resultBooking = await _apiClient.getBooking(id);
if (resultBooking is Error<BookingApiModel>) {
return Result.error(resultBooking.error);
}
final booking = resultBooking.asOk.value;
// Load destinations if not loaded yet
if (_cachedDestinations == null) {
final resultDestination = await _apiClient.getDestinations();
if (resultDestination is Error<List<Destination>>) {
return Result.error(resultDestination.error);
}
_cachedDestinations = resultDestination.asOk.value;
}
// Get destination for booking
final destination = _cachedDestinations!.firstWhere(
(destination) => destination.ref == booking.destinationRef);
final resultActivities =
await _apiClient.getActivityByDestination(destination.ref);
if (resultActivities is Error<List<Activity>>) {
return Result.error(resultActivities.error);
}
final activities = resultActivities.asOk.value
.where((activity) => booking.activitiesRef.contains(activity.ref))
.toList();
return Result.ok(
Booking(
startDate: booking.startDate,
endDate: booking.endDate,
destination: destination,
activity: activities,
),
);
} on Exception catch (e) {
return Result.error(e);
}
}
@override
Future<Result<List<BookingSummary>>> getBookingsList() async {
try {
final result = await _apiClient.getBookings();
if (result is Error<List<BookingApiModel>>) {
return Result.error(result.error);
}
final bookingsApi = result.asOk.value;
return Result.ok(bookingsApi
.map(
(bookingApi) => BookingSummary(
id: bookingApi.id!,
name: bookingApi.name,
startDate: bookingApi.startDate,
endDate: bookingApi.endDate,
),
)
.toList());
} on Exception catch (e) {
return Result.error(e);
}
}
}

@ -1,45 +1,19 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'continent_repository.dart';
/// Local data source with all possible continents.
class ContinentRepositoryLocal implements ContinentRepository {
ContinentRepositoryLocal({
required LocalDataService localDataService,
}) : _localDataService = localDataService;
final LocalDataService _localDataService;
@override
Future<Result<List<Continent>>> getContinents() async {
return Future.value(
Result.ok(
[
const Continent(
name: 'Europe',
imageUrl: 'https://rstr.in/google/tripedia/TmR12QdlVTT',
),
const Continent(
name: 'Asia',
imageUrl: 'https://rstr.in/google/tripedia/VJ8BXlQg8O1',
),
const Continent(
name: 'South America',
imageUrl: 'https://rstr.in/google/tripedia/flm_-o1aI8e',
),
const Continent(
name: 'Africa',
imageUrl: 'https://rstr.in/google/tripedia/-nzi8yFOBpF',
),
const Continent(
name: 'North America',
imageUrl: 'https://rstr.in/google/tripedia/jlbgFDrSUVE',
),
const Continent(
name: 'Oceania',
imageUrl: 'https://rstr.in/google/tripedia/vxyrDE-fZVL',
),
const Continent(
name: 'Australia',
imageUrl: 'https://rstr.in/google/tripedia/z6vy6HeRyvZ',
),
],
),
);
return Future.value(Result.ok(_localDataService.getContinents()));
}
}

@ -1,7 +1,7 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import '../../services/api/api_client.dart';
import 'continent_repository.dart';
/// Remote data source for [Continent].

@ -1,36 +1,25 @@
import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart' show rootBundle;
import '../../../config/assets.dart';
import '../../../utils/result.dart';
import '../../services/local/local_data_service.dart';
import 'destination_repository.dart';
/// Local implementation of DestinationRepository
/// Uses data from assets folder
class DestinationRepositoryLocal implements DestinationRepository {
DestinationRepositoryLocal({
required LocalDataService localDataService,
}) : _localDataService = localDataService;
final LocalDataService _localDataService;
/// Obtain list of destinations from local assets
@override
Future<Result<List<Destination>>> getDestinations() async {
try {
final localData = await _loadAsset();
final list = _parse(localData);
return Result.ok(list);
return Result.ok(await _localDataService.getDestinations());
} on Exception catch (error) {
return Result.error(error);
}
}
Future<String> _loadAsset() async {
return await rootBundle.loadString(Assets.destinations);
}
List<Destination> _parse(String localData) {
final parsed = (jsonDecode(localData) as List).cast<Map<String, dynamic>>();
return parsed
.map<Destination>((json) => Destination.fromJson(json))
.toList();
}
}

@ -1,7 +1,7 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import '../../services/api/api_client.dart';
import 'destination_repository.dart';
/// Remote data source for [Destination].

@ -2,7 +2,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import '../../utils/result.dart';
import '../../../utils/result.dart';
import 'model/booking/booking_api_model.dart';
/// Adds the `Authentication` header to a header configuration.
typedef AuthHeaderProvider = String? Function();
@ -88,4 +89,67 @@ class ApiClient {
client.close();
}
}
Future<Result<List<BookingApiModel>>> getBookings() async {
final client = HttpClient();
try {
final request = await client.get('localhost', 8080, '/booking');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final json = jsonDecode(stringData) as List<dynamic>;
final bookings =
json.map((element) => BookingApiModel.fromJson(element)).toList();
return Result.ok(bookings);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
Future<Result<BookingApiModel>> getBooking(int id) async {
final client = HttpClient();
try {
final request = await client.get('localhost', 8080, '/booking/$id');
await _authHeader(request.headers);
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final booking = BookingApiModel.fromJson(jsonDecode(stringData));
return Result.ok(booking);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
Future<Result<BookingApiModel>> postBooking(BookingApiModel booking) async {
final client = HttpClient();
try {
final request = await client.post('localhost', 8080, '/booking');
await _authHeader(request.headers);
request.write(jsonEncode(booking));
final response = await request.close();
if (response.statusCode == 201) {
final stringData = await response.transform(utf8.decoder).join();
final booking = BookingApiModel.fromJson(jsonDecode(stringData));
return Result.ok(booking);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
}

@ -4,7 +4,7 @@ import 'dart:io';
import 'package:compass_model/model.dart';
import '../../utils/result.dart';
import '../../../utils/result.dart';
class AuthApiClient {
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {

@ -0,0 +1,31 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'booking_api_model.freezed.dart';
part 'booking_api_model.g.dart';
@freezed
class BookingApiModel with _$BookingApiModel {
const factory BookingApiModel({
/// Booking ID. Generated when stored in server.
int? id,
/// Start date of the trip
required DateTime startDate,
/// End date of the trip
required DateTime endDate,
/// Booking name
/// Should be "Destination, Continent"
required String name,
/// Destination of the trip
required String destinationRef,
/// List of chosen activities
required List<String> activitiesRef,
}) = _BookingApiModel;
factory BookingApiModel.fromJson(Map<String, Object?> json) =>
_$BookingApiModelFromJson(json);
}

@ -0,0 +1,317 @@
// 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 'booking_api_model.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');
BookingApiModel _$BookingApiModelFromJson(Map<String, dynamic> json) {
return _BookingApiModel.fromJson(json);
}
/// @nodoc
mixin _$BookingApiModel {
/// Booking ID. Generated when stored in server.
int? get id => throw _privateConstructorUsedError;
/// Start date of the trip
DateTime get startDate => throw _privateConstructorUsedError;
/// End date of the trip
DateTime get endDate => throw _privateConstructorUsedError;
/// Booking name
/// Should be "Destination, Continent"
String get name => throw _privateConstructorUsedError;
/// Destination of the trip
String get destinationRef => throw _privateConstructorUsedError;
/// List of chosen activities
List<String> get activitiesRef => throw _privateConstructorUsedError;
/// Serializes this BookingApiModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of BookingApiModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$BookingApiModelCopyWith<BookingApiModel> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $BookingApiModelCopyWith<$Res> {
factory $BookingApiModelCopyWith(
BookingApiModel value, $Res Function(BookingApiModel) then) =
_$BookingApiModelCopyWithImpl<$Res, BookingApiModel>;
@useResult
$Res call(
{int? id,
DateTime startDate,
DateTime endDate,
String name,
String destinationRef,
List<String> activitiesRef});
}
/// @nodoc
class _$BookingApiModelCopyWithImpl<$Res, $Val extends BookingApiModel>
implements $BookingApiModelCopyWith<$Res> {
_$BookingApiModelCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of BookingApiModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? startDate = null,
Object? endDate = null,
Object? name = null,
Object? destinationRef = null,
Object? activitiesRef = null,
}) {
return _then(_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
destinationRef: null == destinationRef
? _value.destinationRef
: destinationRef // ignore: cast_nullable_to_non_nullable
as String,
activitiesRef: null == activitiesRef
? _value.activitiesRef
: activitiesRef // ignore: cast_nullable_to_non_nullable
as List<String>,
) as $Val);
}
}
/// @nodoc
abstract class _$$BookingApiModelImplCopyWith<$Res>
implements $BookingApiModelCopyWith<$Res> {
factory _$$BookingApiModelImplCopyWith(_$BookingApiModelImpl value,
$Res Function(_$BookingApiModelImpl) then) =
__$$BookingApiModelImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int? id,
DateTime startDate,
DateTime endDate,
String name,
String destinationRef,
List<String> activitiesRef});
}
/// @nodoc
class __$$BookingApiModelImplCopyWithImpl<$Res>
extends _$BookingApiModelCopyWithImpl<$Res, _$BookingApiModelImpl>
implements _$$BookingApiModelImplCopyWith<$Res> {
__$$BookingApiModelImplCopyWithImpl(
_$BookingApiModelImpl _value, $Res Function(_$BookingApiModelImpl) _then)
: super(_value, _then);
/// Create a copy of BookingApiModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? startDate = null,
Object? endDate = null,
Object? name = null,
Object? destinationRef = null,
Object? activitiesRef = null,
}) {
return _then(_$BookingApiModelImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
destinationRef: null == destinationRef
? _value.destinationRef
: destinationRef // ignore: cast_nullable_to_non_nullable
as String,
activitiesRef: null == activitiesRef
? _value._activitiesRef
: activitiesRef // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$BookingApiModelImpl implements _BookingApiModel {
const _$BookingApiModelImpl(
{this.id,
required this.startDate,
required this.endDate,
required this.name,
required this.destinationRef,
required final List<String> activitiesRef})
: _activitiesRef = activitiesRef;
factory _$BookingApiModelImpl.fromJson(Map<String, dynamic> json) =>
_$$BookingApiModelImplFromJson(json);
/// Booking ID. Generated when stored in server.
@override
final int? id;
/// Start date of the trip
@override
final DateTime startDate;
/// End date of the trip
@override
final DateTime endDate;
/// Booking name
/// Should be "Destination, Continent"
@override
final String name;
/// Destination of the trip
@override
final String destinationRef;
/// List of chosen activities
final List<String> _activitiesRef;
/// List of chosen activities
@override
List<String> get activitiesRef {
if (_activitiesRef is EqualUnmodifiableListView) return _activitiesRef;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_activitiesRef);
}
@override
String toString() {
return 'BookingApiModel(id: $id, startDate: $startDate, endDate: $endDate, name: $name, destinationRef: $destinationRef, activitiesRef: $activitiesRef)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$BookingApiModelImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.startDate, startDate) ||
other.startDate == startDate) &&
(identical(other.endDate, endDate) || other.endDate == endDate) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.destinationRef, destinationRef) ||
other.destinationRef == destinationRef) &&
const DeepCollectionEquality()
.equals(other._activitiesRef, _activitiesRef));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, startDate, endDate, name,
destinationRef, const DeepCollectionEquality().hash(_activitiesRef));
/// Create a copy of BookingApiModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$BookingApiModelImplCopyWith<_$BookingApiModelImpl> get copyWith =>
__$$BookingApiModelImplCopyWithImpl<_$BookingApiModelImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$BookingApiModelImplToJson(
this,
);
}
}
abstract class _BookingApiModel implements BookingApiModel {
const factory _BookingApiModel(
{final int? id,
required final DateTime startDate,
required final DateTime endDate,
required final String name,
required final String destinationRef,
required final List<String> activitiesRef}) = _$BookingApiModelImpl;
factory _BookingApiModel.fromJson(Map<String, dynamic> json) =
_$BookingApiModelImpl.fromJson;
/// Booking ID. Generated when stored in server.
@override
int? get id;
/// Start date of the trip
@override
DateTime get startDate;
/// End date of the trip
@override
DateTime get endDate;
/// Booking name
/// Should be "Destination, Continent"
@override
String get name;
/// Destination of the trip
@override
String get destinationRef;
/// List of chosen activities
@override
List<String> get activitiesRef;
/// Create a copy of BookingApiModel
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$BookingApiModelImplCopyWith<_$BookingApiModelImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'booking_api_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$BookingApiModelImpl _$$BookingApiModelImplFromJson(
Map<String, dynamic> json) =>
_$BookingApiModelImpl(
id: (json['id'] as num?)?.toInt(),
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
name: json['name'] as String,
destinationRef: json['destinationRef'] as String,
activitiesRef: (json['activitiesRef'] as List<dynamic>)
.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$$BookingApiModelImplToJson(
_$BookingApiModelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate.toIso8601String(),
'name': instance.name,
'destinationRef': instance.destinationRef,
'activitiesRef': instance.activitiesRef,
};

@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart';
import '../../../config/assets.dart';
class LocalDataService {
List<Continent> getContinents() {
return [
const Continent(
name: 'Europe',
imageUrl: 'https://rstr.in/google/tripedia/TmR12QdlVTT',
),
const Continent(
name: 'Asia',
imageUrl: 'https://rstr.in/google/tripedia/VJ8BXlQg8O1',
),
const Continent(
name: 'South America',
imageUrl: 'https://rstr.in/google/tripedia/flm_-o1aI8e',
),
const Continent(
name: 'Africa',
imageUrl: 'https://rstr.in/google/tripedia/-nzi8yFOBpF',
),
const Continent(
name: 'North America',
imageUrl: 'https://rstr.in/google/tripedia/jlbgFDrSUVE',
),
const Continent(
name: 'Oceania',
imageUrl: 'https://rstr.in/google/tripedia/vxyrDE-fZVL',
),
const Continent(
name: 'Australia',
imageUrl: 'https://rstr.in/google/tripedia/z6vy6HeRyvZ',
),
];
}
Future<List<Activity>> getActivities() async {
final json = await _loadStringAsset(Assets.activities);
return json.map<Activity>((json) => Activity.fromJson(json)).toList();
}
Future<List<Destination>> getDestinations() async {
final json = await _loadStringAsset(Assets.destinations);
return json.map<Destination>((json) => Destination.fromJson(json)).toList();
}
Future<List<Map<String, dynamic>>> _loadStringAsset(String asset) async {
final localData = await rootBundle.loadString(asset);
return (jsonDecode(localData) as List).cast<Map<String, dynamic>>();
}
}

@ -2,6 +2,7 @@ import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/activity/activity_repository.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../utils/result.dart';
@ -13,11 +14,14 @@ class BookingCreateComponent {
BookingCreateComponent({
required DestinationRepository destinationRepository,
required ActivityRepository activityRepository,
required BookingRepository bookingRepository,
}) : _destinationRepository = destinationRepository,
_activityRepository = activityRepository;
_activityRepository = activityRepository,
_bookingRepository = bookingRepository;
final DestinationRepository _destinationRepository;
final ActivityRepository _activityRepository;
final BookingRepository _bookingRepository;
final _log = Logger('BookingComponent');
/// Create [Booking] from a stored [ItineraryConfig]
@ -60,15 +64,25 @@ class BookingCreateComponent {
return Result.error(Exception('Dates are not set'));
}
// Create Booking object
return Result.ok(
Booking(
startDate: itineraryConfig.startDate!,
endDate: itineraryConfig.endDate!,
destination: destinationResult.asOk.value,
activity: activities,
),
final booking = Booking(
startDate: itineraryConfig.startDate!,
endDate: itineraryConfig.endDate!,
destination: destinationResult.asOk.value,
activity: activities,
);
final saveBookingResult = await _bookingRepository.createBooking(booking);
switch (saveBookingResult) {
case Ok<void>():
_log.fine('Booking saved successfully');
break;
case Error<void>():
_log.warning('Failed to save booking', saveBookingResult.error);
return Result.error(saveBookingResult.error);
}
// Create Booking object
return Result.ok(booking);
}
Future<Result<Destination>> _fetchDestination(String destinationRef) async {

@ -0,0 +1,30 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'booking_summary.freezed.dart';
part 'booking_summary.g.dart';
/// BookingSummary contains the necessary data to display a booking
/// in the user home screen, but lacks the rest of the booking data
/// like activitities or destination.
///
/// Use the [BookingRepository] to obtain a full [Booking]
/// using the [BookingSummary.id].
@freezed
class BookingSummary with _$BookingSummary {
const factory BookingSummary({
/// Booking id
required int id,
/// Name to be displayed
required String name,
/// Start date of the booking
required DateTime startDate,
/// End date of the booking
required DateTime endDate,
}) = _BookingSummary;
factory BookingSummary.fromJson(Map<String, Object?> json) =>
_$BookingSummaryFromJson(json);
}

@ -0,0 +1,243 @@
// 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 'booking_summary.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');
BookingSummary _$BookingSummaryFromJson(Map<String, dynamic> json) {
return _BookingSummary.fromJson(json);
}
/// @nodoc
mixin _$BookingSummary {
/// Booking id
int get id => throw _privateConstructorUsedError;
/// Destination name to be displayed.
String get name => throw _privateConstructorUsedError;
/// Start date of the booking.
DateTime get startDate => throw _privateConstructorUsedError;
/// End date of the booking
DateTime get endDate => throw _privateConstructorUsedError;
/// Serializes this BookingSummary to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of BookingSummary
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$BookingSummaryCopyWith<BookingSummary> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $BookingSummaryCopyWith<$Res> {
factory $BookingSummaryCopyWith(
BookingSummary value, $Res Function(BookingSummary) then) =
_$BookingSummaryCopyWithImpl<$Res, BookingSummary>;
@useResult
$Res call({int id, String name, DateTime startDate, DateTime endDate});
}
/// @nodoc
class _$BookingSummaryCopyWithImpl<$Res, $Val extends BookingSummary>
implements $BookingSummaryCopyWith<$Res> {
_$BookingSummaryCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of BookingSummary
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? startDate = null,
Object? endDate = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
) as $Val);
}
}
/// @nodoc
abstract class _$$BookingSummaryImplCopyWith<$Res>
implements $BookingSummaryCopyWith<$Res> {
factory _$$BookingSummaryImplCopyWith(_$BookingSummaryImpl value,
$Res Function(_$BookingSummaryImpl) then) =
__$$BookingSummaryImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({int id, String name, DateTime startDate, DateTime endDate});
}
/// @nodoc
class __$$BookingSummaryImplCopyWithImpl<$Res>
extends _$BookingSummaryCopyWithImpl<$Res, _$BookingSummaryImpl>
implements _$$BookingSummaryImplCopyWith<$Res> {
__$$BookingSummaryImplCopyWithImpl(
_$BookingSummaryImpl _value, $Res Function(_$BookingSummaryImpl) _then)
: super(_value, _then);
/// Create a copy of BookingSummary
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? startDate = null,
Object? endDate = null,
}) {
return _then(_$BookingSummaryImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// @nodoc
@JsonSerializable()
class _$BookingSummaryImpl implements _BookingSummary {
const _$BookingSummaryImpl(
{required this.id,
required this.name,
required this.startDate,
required this.endDate});
factory _$BookingSummaryImpl.fromJson(Map<String, dynamic> json) =>
_$$BookingSummaryImplFromJson(json);
/// Booking id
@override
final int id;
/// Destination name to be displayed.
@override
final String name;
/// Start date of the booking.
@override
final DateTime startDate;
/// End date of the booking
@override
final DateTime endDate;
@override
String toString() {
return 'BookingSummary(id: $id, name: $name, startDate: $startDate, endDate: $endDate)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$BookingSummaryImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.startDate, startDate) ||
other.startDate == startDate) &&
(identical(other.endDate, endDate) || other.endDate == endDate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, name, startDate, endDate);
/// Create a copy of BookingSummary
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$BookingSummaryImplCopyWith<_$BookingSummaryImpl> get copyWith =>
__$$BookingSummaryImplCopyWithImpl<_$BookingSummaryImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$BookingSummaryImplToJson(
this,
);
}
}
abstract class _BookingSummary implements BookingSummary {
const factory _BookingSummary(
{required final int id,
required final String name,
required final DateTime startDate,
required final DateTime endDate}) = _$BookingSummaryImpl;
factory _BookingSummary.fromJson(Map<String, dynamic> json) =
_$BookingSummaryImpl.fromJson;
/// Booking id
@override
int get id;
/// Destination name to be displayed.
@override
String get name;
/// Start date of the booking.
@override
DateTime get startDate;
/// End date of the booking
@override
DateTime get endDate;
/// Create a copy of BookingSummary
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$BookingSummaryImplCopyWith<_$BookingSummaryImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'booking_summary.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$BookingSummaryImpl _$$BookingSummaryImplFromJson(Map<String, dynamic> json) =>
_$BookingSummaryImpl(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
);
Map<String, dynamic> _$$BookingSummaryImplToJson(
_$BookingSummaryImpl instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate.toIso8601String(),
};

@ -9,10 +9,13 @@ 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/home/view_models/home_viewmodel.dart';
import '../ui/home/widgets/home_screen.dart';
import '../ui/results/view_models/results_viewmodel.dart';
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';
import 'routes.dart';
/// Top go_router entry point.
///
@ -22,33 +25,42 @@ GoRouter router(
AuthRepository authRepository,
) =>
GoRouter(
initialLocation: '/',
initialLocation: Routes.home,
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authRepository,
routes: [
GoRoute(
path: '/',
path: Routes.login,
builder: (context, state) {
final viewModel = SearchFormViewModel(
continentRepository: context.read(),
itineraryConfigRepository: context.read(),
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
return SearchFormScreen(viewModel: viewModel);
},
),
GoRoute(
path: Routes.home,
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},
routes: [
GoRoute(
path: 'login',
path: Routes.searchRelative,
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
final viewModel = SearchFormViewModel(
continentRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return SearchFormScreen(viewModel: viewModel);
},
),
GoRoute(
path: 'results',
path: Routes.resultsRelative,
builder: (context, state) {
final viewModel = ResultsViewModel(
destinationRepository: context.read(),
@ -60,7 +72,7 @@ GoRouter router(
},
),
GoRoute(
path: 'activities',
path: Routes.activitiesRelative,
builder: (context, state) {
final viewModel = ActivitiesViewModel(
activityRepository: context.read(),
@ -72,17 +84,45 @@ GoRouter router(
},
),
GoRoute(
path: 'booking',
path: Routes.bookingRelative,
builder: (context, state) {
final viewModel = BookingViewModel(
itineraryConfigRepository: context.read(),
bookingComponent: context.read(),
shareComponent: context.read(),
bookingRepository: context.read(),
);
// When opening the booking screen directly
// create a new booking from the stored ItineraryConfig.
viewModel.createBooking.execute();
return BookingScreen(
viewModel: viewModel,
);
},
routes: [
GoRoute(
path: ':id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
final viewModel = BookingViewModel(
itineraryConfigRepository: context.read(),
bookingComponent: context.read(),
shareComponent: context.read(),
bookingRepository: context.read(),
);
// When opening the booking screen with an existing id
// load and display that booking.
viewModel.loadBooking.execute(id);
return BookingScreen(
viewModel: viewModel,
);
},
),
],
),
],
),
@ -93,15 +133,15 @@ GoRouter router(
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<AuthRepository>().isAuthenticated;
final bool loggingIn = state.matchedLocation == '/login';
final bool loggingIn = state.matchedLocation == Routes.login;
if (!loggedIn) {
return '/login';
return Routes.login;
}
// if the user is logged in but still on the login page, send them to
// the home page
if (loggingIn) {
return '/';
return Routes.home;
}
// no need to redirect at all

@ -0,0 +1,13 @@
class Routes {
static const home = '/';
static const login = '/login';
static const search = '/$searchRelative';
static const searchRelative = 'search';
static const results = '/$resultsRelative';
static const resultsRelative = 'results';
static const activities = '/$activitiesRelative';
static const activitiesRelative = 'activities';
static const booking = '/$bookingRelative';
static const bookingRelative = 'booking';
static String bookingWithId(int id) => '$booking/$id';
}

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/back_button.dart';
@ -27,7 +28,7 @@ class ActivitiesHeader extends StatelessWidget {
CustomBackButton(
onTap: () {
// Navigate to ResultsScreen and edit search
context.go('/results');
context.go(Routes.results);
},
),
Text(

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/error_indicator.dart';
@ -47,7 +48,7 @@ class _ActivitiesScreenState extends State<ActivitiesScreen> {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go('/results');
if (!didPop) context.go(Routes.results);
},
child: Scaffold(
body: ListenableBuilder(
@ -119,7 +120,7 @@ class _ActivitiesScreenState extends State<ActivitiesScreen> {
void _onResult() {
if (widget.viewModel.saveActivities.completed) {
widget.viewModel.saveActivities.clearResult();
context.go('/booking');
context.go(Routes.booking);
}
if (widget.viewModel.saveActivities.error) {

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../routing/routes.dart';
import '../../../core/localization/applocalization.dart';
import '../../../core/themes/dimens.dart';
import '../view_models/login_viewmodel.dart';
@ -88,7 +89,7 @@ class _LoginScreenState extends State<LoginScreen> {
void _onResult() {
if (widget.viewModel.login.completed) {
widget.viewModel.login.clearResult();
context.go('/');
context.go(Routes.home);
}
if (widget.viewModel.login.error) {

@ -2,6 +2,7 @@ import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
@ -13,27 +14,36 @@ class BookingViewModel extends ChangeNotifier {
required BookingCreateComponent bookingComponent,
required BookingShareComponent shareComponent,
required ItineraryConfigRepository itineraryConfigRepository,
required BookingRepository bookingRepository,
}) : _createComponent = bookingComponent,
_shareComponent = shareComponent,
_itineraryConfigRepository = itineraryConfigRepository {
loadBooking = Command0(_loadBooking)..execute();
_itineraryConfigRepository = itineraryConfigRepository,
_bookingRepository = bookingRepository {
createBooking = Command0(_createBooking);
shareBooking = Command0(() => _shareComponent.shareBooking(_booking!));
loadBooking = Command1(_load);
}
final BookingCreateComponent _createComponent;
final BookingShareComponent _shareComponent;
final ItineraryConfigRepository _itineraryConfigRepository;
final BookingRepository _bookingRepository;
final _log = Logger('BookingViewModel');
Booking? _booking;
Booking? get booking => _booking;
late final Command0 loadBooking;
/// Creates a booking from the ItineraryConfig
/// and saves it to the user bookins
late final Command0 createBooking;
/// Loads booking by id
late final Command1<void, int> loadBooking;
/// Share the current booking using the OS share dialog.
late final Command0 shareBooking;
Future<Result<void>> _loadBooking() async {
Future<Result<void>> _createBooking() async {
_log.fine('Loading booking');
final itineraryConfig =
await _itineraryConfigRepository.getItineraryConfig();
@ -58,4 +68,17 @@ class BookingViewModel extends ChangeNotifier {
return Result.error(itineraryConfig.error);
}
}
Future<Result<void>> _load(int id) async {
final result = await _bookingRepository.getBooking(id);
switch (result) {
case Ok<Booking>():
_log.fine('Loaded booking $id');
_booking = result.value;
notifyListeners();
case Error<Booking>():
_log.warning('Failed to load booking $id');
}
return result;
}
}

@ -1,13 +1,11 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/colors.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/back_button.dart';
import '../../core/ui/date_format_start_end.dart';
import '../../core/ui/home_button.dart';
import '../../core/ui/tag_chip.dart';
@ -65,17 +63,6 @@ class _Top extends StatelessWidget {
_HeaderImage(booking: booking),
const _Gradient(),
_Headline(booking: booking),
Positioned(
left: Dimens.of(context).paddingScreenHorizontal,
top: Dimens.of(context).paddingScreenVertical,
child: SafeArea(
top: true,
child: CustomBackButton(
onTap: () => context.go('/activities'),
blur: true,
),
),
),
Positioned(
right: Dimens.of(context).paddingScreenHorizontal,
top: Dimens.of(context).paddingScreenVertical,

@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/ui/error_indicator.dart';
import '../view_models/booking_viewmodel.dart';
import 'booking_body.dart';
import 'booking_share_button.dart';
class BookingScreen extends StatelessWidget {
class BookingScreen extends StatefulWidget {
const BookingScreen({
super.key,
required this.viewModel,
@ -15,41 +15,97 @@ class BookingScreen extends StatelessWidget {
final BookingViewModel viewModel;
@override
State<BookingScreen> createState() => _BookingScreenState();
}
class _BookingScreenState extends State<BookingScreen> {
@override
void initState() {
super.initState();
widget.viewModel.shareBooking.addListener(_listener);
}
@override
void dispose() {
widget.viewModel.shareBooking.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go('/activities');
// Back navigation always goes to home
if (!didPop) context.go(Routes.home);
},
child: Scaffold(
floatingActionButton: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) => FloatingActionButton.extended(
// Workaround for https://github.com/flutter/flutter/issues/115358#issuecomment-2117157419
heroTag: null,
key: const ValueKey('share-button'),
onPressed: widget.viewModel.booking != null
? widget.viewModel.shareBooking.execute
: null,
label: Text(AppLocalization.of(context).shareTrip),
icon: const Icon(Icons.share_outlined),
),
),
body: ListenableBuilder(
listenable: viewModel.loadBooking,
// Listen to changes in both commands
listenable: Listenable.merge([
widget.viewModel.createBooking,
widget.viewModel.loadBooking,
]),
builder: (context, child) {
if (viewModel.loadBooking.running) {
// If either command is running, show progress indicator
if (widget.viewModel.createBooking.running ||
widget.viewModel.loadBooking.running) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (viewModel.loadBooking.error) {
// If fails to create booking, tap to try again
if (widget.viewModel.createBooking.error) {
return Center(
child: ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingBooking,
label: AppLocalization.of(context).tryAgain,
onPressed: viewModel.loadBooking.execute,
onPressed: widget.viewModel.createBooking.execute,
),
);
}
// If existing booking fails to load, tap to go /home
if (widget.viewModel.loadBooking.error) {
return Center(
child: ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingBooking,
label: AppLocalization.of(context).close,
onPressed: () => context.go(Routes.home),
),
);
}
return child!;
},
child: Stack(
children: [
BookingBody(viewModel: viewModel),
BookingShareButton(viewModel: viewModel),
],
),
child: BookingBody(viewModel: widget.viewModel),
),
),
);
}
void _listener() {
if (widget.viewModel.shareBooking.error) {
widget.viewModel.shareBooking.clearResult();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalization.of(context).errorWhileSharing),
action: SnackBarAction(
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.shareBooking.execute,
),
));
}
}
}

@ -1,50 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/blur_filter.dart';
import '../view_models/booking_viewmodel.dart';
class BookingShareButton extends StatelessWidget {
const BookingShareButton({
super.key,
required this.viewModel,
});
final BookingViewModel viewModel;
@override
Widget build(BuildContext context) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: SafeArea(
bottom: true,
top: false,
child: ClipRect(
child: SizedBox(
height: (Dimens.of(context).paddingScreenVertical * 2) + 64,
child: BackdropFilter(
filter: kBlurFilter,
child: Padding(
padding: Dimens.of(context).edgeInsetsScreenSymmetric,
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return FilledButton(
key: const Key('share-button'),
onPressed: viewModel.booking != null
? viewModel.shareBooking.execute
: null,
child: Text(AppLocalization.of(context).shareTrip),
);
}),
),
),
),
),
),
);
}
}

@ -11,6 +11,8 @@ class AppLocalization {
static const _strings = <String, String>{
'activities': 'Activities',
'addDates': 'Add Dates',
'bookNewTrip': 'Book New Trip',
'close': 'Close',
'confirm': 'Confirm',
'daytime': 'Daytime',
'errorWhileLoadingActivities': 'Error while loading activities',
@ -21,6 +23,7 @@ class AppLocalization {
'errorWhileLogout': 'Error while trying to logout',
'errorWhileSavingActivities': 'Error while saving activities',
'errorWhileSavingItinerary': 'Error while saving itinerary',
'errorWhileSharing': 'Error while sharing booking',
'evening': 'Evening',
'login': 'Login',
'search': 'Search',
@ -28,6 +31,7 @@ class AppLocalization {
'selected': '{1} selected',
'shareTrip': 'Share Trip',
'tryAgain': 'Try again',
'yourBookings': 'Your bookings:',
'yourChosenActivities': 'Your chosen activities',
'when': 'When',
};
@ -77,6 +81,14 @@ class AppLocalization {
String get errorWhileLogout => _get('errorWhileLogout');
String get close => _get('close');
String get errorWhileSharing => _get('errorWhileSharing');
String get bookNewTrip => _get('bookNewTrip');
String get yourBookings => _get('yourBookings');
String selected(int value) =>
_get('selected').replaceAll('{1}', value.toString());
}

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../themes/colors.dart';
import 'blur_filter.dart';
@ -37,7 +38,7 @@ class HomeButton extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
context.go('/');
context.go(Routes.home);
},
child: Center(
child: Icon(

@ -1,13 +1,10 @@
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/colors.dart';
import '../themes/dimens.dart';
import 'date_format_start_end.dart';
import '../themes/colors.dart';
import 'home_button.dart';
/// Application top search bar.
@ -19,12 +16,10 @@ 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) {
@ -53,15 +48,7 @@ class AppSearchBar extends StatelessWidget {
),
),
const SizedBox(width: 10),
// Display a logout button if at the root route
homeScreen
? LogoutButton(
viewModel: LogoutViewModel(
authRepository: context.read(),
itineraryConfigRepository: context.read(),
),
)
: const HomeButton(),
const HomeButton(),
],
);
}

@ -0,0 +1,41 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
}) : _bookingRepository = bookingRepository {
load = Command0(_load)..execute();
}
final BookingRepository _bookingRepository;
final _log = Logger('HomeViewModel');
List<BookingSummary> _bookings = [];
late Command0 load;
List<BookingSummary> get bookings => _bookings;
Future<Result> _load() async {
try {
final result = await _bookingRepository.getBookingsList();
switch (result) {
case Ok<List<BookingSummary>>():
_bookings = result.value;
_log.fine('Loaded bookings');
case Error<List<BookingSummary>>():
_log.warning('Failed to load bookings', result.error);
}
return result;
} finally {
notifyListeners();
}
}
}

@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../routing/routes.dart';
import '../../auth/logout/view_models/logout_viewmodel.dart';
import '../../auth/logout/widgets/logout_button.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/date_format_start_end.dart';
import '../view_models/home_viewmodel.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
super.key,
required this.viewModel,
});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
// Workaround for https://github.com/flutter/flutter/issues/115358#issuecomment-2117157419
heroTag: null,
key: const ValueKey('booking-button'),
onPressed: () => context.go(Routes.search),
label: Text(AppLocalization.of(context).bookNewTrip),
icon: const Icon(Icons.add_location_outlined),
),
body: SafeArea(
top: true,
bottom: true,
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: Dimens.of(context).paddingScreenVertical,
horizontal: Dimens.of(context).paddingScreenHorizontal,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalization.of(context).yourBookings,
style: Theme.of(context).textTheme.headlineMedium,
),
LogoutButton(
viewModel: LogoutViewModel(
authRepository: context.read(),
itineraryConfigRepository: context.read(),
),
),
],
),
),
),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(index),
booking: viewModel.bookings[index],
onTap: () => context.push(
Routes.bookingWithId(viewModel.bookings[index].id)),
),
)
],
);
},
),
),
);
}
}
class _Booking extends StatelessWidget {
const _Booking({
super.key,
required this.booking,
required this.onTap,
});
final BookingSummary booking;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: Dimens.of(context).paddingScreenHorizontal,
vertical: Dimens.paddingVertical,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
booking.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
dateFormatStartEnd(
DateTimeRange(
start: booking.startDate,
end: booking.endDate,
),
),
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/error_indicator.dart';
@ -42,48 +43,54 @@ class _ResultsScreenState extends State<ResultsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListenableBuilder(
listenable: widget.viewModel.search,
builder: (context, child) {
if (widget.viewModel.search.completed) {
return child!;
}
return Column(
children: [
_AppSearchBar(widget: widget),
if (widget.viewModel.search.running)
const Expanded(
child: Center(child: CircularProgressIndicator())),
if (widget.viewModel.search.error)
Expanded(
child: Center(
child: ErrorIndicator(
title: AppLocalization.of(context)
.errorWhileLoadingDestinations,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.search.execute,
),
),
),
],
);
},
child: ListenableBuilder(
listenable: widget.viewModel,
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go(Routes.search);
},
child: Scaffold(
body: ListenableBuilder(
listenable: widget.viewModel.search,
builder: (context, child) {
return Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _AppSearchBar(widget: widget),
if (widget.viewModel.search.completed) {
return child!;
}
return Column(
children: [
_AppSearchBar(widget: widget),
if (widget.viewModel.search.running)
const Expanded(
child: Center(child: CircularProgressIndicator())),
if (widget.viewModel.search.error)
Expanded(
child: Center(
child: ErrorIndicator(
title: AppLocalization.of(context)
.errorWhileLoadingDestinations,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.search.execute,
),
),
),
_Grid(viewModel: widget.viewModel),
],
),
],
);
},
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) {
return Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _AppSearchBar(widget: widget),
),
_Grid(viewModel: widget.viewModel),
],
),
);
},
),
),
),
);
@ -92,7 +99,7 @@ class _ResultsScreenState extends State<ResultsScreen> {
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go('/activities');
context.go(Routes.activities);
}
if (widget.viewModel.updateItineraryConfig.error) {

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/search_bar.dart';
import '../../results/widgets/results_screen.dart';
@ -24,29 +26,35 @@ class SearchFormScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
top: true,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: Dimens.of(context).paddingScreenVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.paddingVertical,
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go(Routes.home);
},
child: Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
top: true,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: Dimens.of(context).paddingScreenVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.paddingVertical,
),
child: const AppSearchBar(),
),
child: const AppSearchBar(homeScreen: true),
),
),
SearchFormContinent(viewModel: viewModel),
SearchFormDate(viewModel: viewModel),
SearchFormGuests(viewModel: viewModel),
const Spacer(),
SearchFormSubmit(viewModel: viewModel),
],
SearchFormContinent(viewModel: viewModel),
SearchFormDate(viewModel: viewModel),
SearchFormGuests(viewModel: viewModel),
const Spacer(),
SearchFormSubmit(viewModel: viewModel),
],
),
),
);
}

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../results/widgets/results_screen.dart';
@ -76,7 +77,7 @@ class _SearchFormSubmitState extends State<SearchFormSubmit> {
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go('/results');
context.go(Routes.results);
}
if (widget.viewModel.updateItineraryConfig.error) {

@ -27,6 +27,8 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
C203960636D4F782F1839CCD /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 624CAC5D902868ED5B3541A6 /* Pods_Runner.framework */; };
D9B813DBC6B840FC789B8BC8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D3F947B2B2872CA43E01D9D3 /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -60,11 +62,12 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
2A6019B907567C19CDA56577 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* compass_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "compass_app.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* compass_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = compass_app.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@ -76,8 +79,15 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
4E966B71DA7CDEDDA1AC2EDF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
614F996EF4C7B207997C69FB /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
624CAC5D902868ED5B3541A6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
8094AA7203951A1D4ED31304 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
9F59A18A4C705DE2978D2023 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
D3F947B2B2872CA43E01D9D3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ECE434935E45147DB18A8135 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -85,6 +95,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D9B813DBC6B840FC789B8BC8 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -92,12 +103,27 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C203960636D4F782F1839CCD /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1D6CC27981A89B68E6FDD92F /* Pods */ = {
isa = PBXGroup;
children = (
2A6019B907567C19CDA56577 /* Pods-Runner.debug.xcconfig */,
614F996EF4C7B207997C69FB /* Pods-Runner.release.xcconfig */,
ECE434935E45147DB18A8135 /* Pods-Runner.profile.xcconfig */,
9F59A18A4C705DE2978D2023 /* Pods-RunnerTests.debug.xcconfig */,
4E966B71DA7CDEDDA1AC2EDF /* Pods-RunnerTests.release.xcconfig */,
8094AA7203951A1D4ED31304 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@ -125,6 +151,7 @@
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
1D6CC27981A89B68E6FDD92F /* Pods */,
);
sourceTree = "<group>";
};
@ -175,6 +202,8 @@
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
624CAC5D902868ED5B3541A6 /* Pods_Runner.framework */,
D3F947B2B2872CA43E01D9D3 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -186,6 +215,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
06AAD07EFF5D1E8FAB2A6247 /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
@ -204,11 +234,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
0DDEBBC387E6CED7A7451269 /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
0C8C541B6AD305AF898FAB29 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@ -291,6 +323,67 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
06AAD07EFF5D1E8FAB2A6247 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
0C8C541B6AD305AF898FAB29 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
0DDEBBC387E6CED7A7451269 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@ -380,6 +473,7 @@
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9F59A18A4C705DE2978D2023 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@ -394,6 +488,7 @@
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 4E966B71DA7CDEDDA1AC2EDF /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@ -408,6 +503,7 @@
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 8094AA7203951A1D4ED31304 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;

@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

@ -1,7 +1,7 @@
import Cocoa
import FlutterMacOS
@NSApplicationMain
@main
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true

@ -8,6 +8,7 @@ environment:
dependencies:
cached_network_image: ^3.3.1
collection: ^1.18.0
compass_model:
path: ../model
flutter:
@ -15,9 +16,11 @@ dependencies:
flutter_localizations:
sdk: flutter
flutter_svg: ^2.0.10+1
freezed_annotation: ^2.4.4
go_router: ^14.2.0
google_fonts: ^6.2.1
intl: any
json_annotation: ^4.9.0
logging: ^1.2.0
provider: ^6.1.2
share_plus: ^7.2.2
@ -31,6 +34,9 @@ dev_dependencies:
mocktail: ^1.0.4
integration_test:
sdk: flutter
build_runner: ^2.4.12
freezed: ^2.5.7
json_serializable: ^6.8.0
flutter:
uses-material-design: true

@ -1,4 +1,5 @@
import 'package:compass_app/data/repositories/activity/activity_repository_local.dart';
import 'package:compass_app/data/services/local/local_data_service.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
@ -7,7 +8,9 @@ void main() {
// To load assets
TestWidgetsFlutterBinding.ensureInitialized();
final repository = ActivityRepositoryLocal();
final repository = ActivityRepositoryLocal(
localDataService: LocalDataService(),
);
test('should get by destination ref', () async {
final result = await repository.getByDestination('alaska');

@ -0,0 +1,40 @@
import 'package:compass_app/data/repositories/booking/booking_repository.dart';
import 'package:compass_app/data/repositories/booking/booking_repository_remote.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/fakes/services/fake_api_client.dart';
import '../../../../testing/models/booking.dart';
void main() {
group('BookingRepositoryRemote tests', () {
late BookingRepository bookingRepository;
late FakeApiClient fakeApiClient;
setUp(() {
fakeApiClient = FakeApiClient();
bookingRepository = BookingRepositoryRemote(
apiClient: fakeApiClient,
);
});
test('should get booking', () async {
final result = await bookingRepository.getBooking(0);
final booking = result.asOk.value;
expect(booking, kBooking);
});
test('should create booking', () async {
expect(fakeApiClient.bookings, isEmpty);
final result = await bookingRepository.createBooking(kBooking);
expect(result, isA<Ok<void>>());
expect(fakeApiClient.bookings.first, kBookingApiModel);
});
test('should get list of booking', () async {
final result = await bookingRepository.getBookingsList();
final list = result.asOk.value;
expect(list, [kBookingSummary]);
});
});
}

@ -1,3 +1,4 @@
import 'package:compass_app/data/services/local/local_data_service.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_app/data/repositories/destination/destination_repository_local.dart';
import 'package:flutter_test/flutter_test.dart';
@ -7,7 +8,9 @@ void main() {
// To load assets
TestWidgetsFlutterBinding.ensureInitialized();
final repository = DestinationRepositoryLocal();
final repository = DestinationRepositoryLocal(
localDataService: LocalDataService(),
);
test('should load and parse', () async {
// Should load the json and parse it

@ -3,6 +3,7 @@ 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_booking_repository.dart';
import '../../../../testing/fakes/repositories/fake_destination_repository.dart';
import '../../../../testing/models/activity.dart';
import '../../../../testing/models/booking.dart';
@ -14,6 +15,7 @@ void main() {
final component = BookingCreateComponent(
activityRepository: FakeActivityRepository(),
destinationRepository: FakeDestinationRepository(),
bookingRepository: FakeBookingRepository(),
);
final booking = await component.createFrom(

@ -8,10 +8,12 @@ import 'package:flutter_test/flutter_test.dart';
import '../../../testing/app.dart';
import '../../../testing/fakes/repositories/fake_activities_repository.dart';
import '../../../testing/fakes/repositories/fake_booking_repository.dart';
import '../../../testing/fakes/repositories/fake_destination_repository.dart';
import '../../../testing/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../../testing/mocks.dart';
import '../../../testing/models/activity.dart';
import '../../../testing/models/booking.dart';
import '../../../testing/models/destination.dart';
void main() {
@ -19,9 +21,11 @@ void main() {
late MockGoRouter goRouter;
late BookingViewModel viewModel;
late bool shared;
late FakeBookingRepository bookingRepository;
setUp(() {
shared = false;
bookingRepository = FakeBookingRepository();
viewModel = BookingViewModel(
itineraryConfigRepository: FakeItineraryConfigRepository(
itineraryConfig: ItineraryConfig(
@ -36,10 +40,12 @@ void main() {
bookingComponent: BookingCreateComponent(
activityRepository: FakeActivityRepository(),
destinationRepository: FakeDestinationRepository(),
bookingRepository: bookingRepository,
),
shareComponent: BookingShareComponent.custom((text) async {
shared = true;
}),
bookingRepository: bookingRepository,
);
goRouter = MockGoRouter();
});
@ -57,18 +63,44 @@ void main() {
expect(find.byType(BookingScreen), findsOneWidget);
});
testWidgets('should display booking', (WidgetTester tester) async {
testWidgets('should display booking from ID', (WidgetTester tester) async {
// Add a booking to repository
bookingRepository.createBooking(kBooking);
// Load screen
await loadScreen(tester);
// Load booking with ID 0
viewModel.loadBooking.execute(0);
// Wait for booking to load
await tester.pumpAndSettle();
expect(find.text(kBooking.destination.name), findsOneWidget);
expect(find.text(kBooking.destination.tags.first), findsOneWidget);
});
testWidgets('should create booking from itinerary config',
(WidgetTester tester) async {
await loadScreen(tester);
// Wait for list to load
// Create a new booking from stored itinerary config
viewModel.createBooking.execute();
// Wait for booking to load
await tester.pumpAndSettle();
expect(find.text('name1'), findsOneWidget);
expect(find.text('tags1'), findsOneWidget);
// Booking is saved
expect(bookingRepository.bookings.length, 1);
});
testWidgets('should share booking', (WidgetTester tester) async {
bookingRepository.createBooking(kBooking);
await loadScreen(tester);
viewModel.loadBooking.execute(0);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('share-button')));
expect(shared, true);

@ -0,0 +1,76 @@
import 'package:compass_app/data/repositories/auth/auth_repository.dart';
import 'package:compass_app/data/repositories/itinerary_config/itinerary_config_repository.dart';
import 'package:compass_app/routing/routes.dart';
import 'package:compass_app/ui/home/view_models/home_viewmodel.dart';
import 'package:compass_app/ui/home/widgets/home_screen.dart';
import 'package:flutter/foundation.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_repository.dart';
import '../../../../testing/fakes/repositories/fake_booking_repository.dart';
import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../../../testing/mocks.dart';
import '../../../../testing/models/booking.dart';
void main() {
group('HomeScreen tests', () {
late HomeViewModel viewModel;
late MockGoRouter goRouter;
setUp(() {
viewModel = HomeViewModel(
bookingRepository: FakeBookingRepository()..createBooking(kBooking),
);
goRouter = MockGoRouter();
when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
});
loadWidget(WidgetTester tester) async {
await testApp(
tester,
ChangeNotifierProvider.value(
value: FakeAuthRepository() as AuthRepository,
child: Provider.value(
value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
child: HomeScreen(viewModel: viewModel),
),
),
goRouter: goRouter,
);
}
testWidgets('should load screen', (tester) async {
await loadWidget(tester);
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});
testWidgets('should navigate to search', (tester) async {
await loadWidget(tester);
await tester.pumpAndSettle();
// Tap on create a booking FAB
await tester.tap(find.byKey(const ValueKey('booking-button')));
await tester.pumpAndSettle();
// Should navigate to results screen
verify(() => goRouter.go(Routes.search)).called(1);
});
testWidgets('should open existing booking', (tester) async {
await loadWidget(tester);
await tester.pumpAndSettle();
// Tap on booking (created from kBooking)
await tester.tap(find.text('name1, Europe'));
await tester.pumpAndSettle();
// Should navigate to results screen
verify(() => goRouter.push(Routes.bookingWithId(0))).called(1);
});
});
}

@ -0,0 +1,39 @@
import 'package:collection/collection.dart';
import 'package:compass_app/data/repositories/booking/booking_repository.dart';
import 'package:compass_app/domain/models/booking/booking_summary.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/booking/booking.dart';
class FakeBookingRepository implements BookingRepository {
List<Booking> bookings = List.empty(growable: true);
@override
Future<Result<void>> createBooking(Booking booking) async {
bookings.add(booking);
return Result.ok(null);
}
@override
Future<Result<Booking>> getBooking(int id) async {
return Result.ok(bookings[id]);
}
@override
Future<Result<List<BookingSummary>>> getBookingsList() async {
return Result.ok(_createSummaries());
}
List<BookingSummary> _createSummaries() {
return bookings
.mapIndexed(
(index, booking) => BookingSummary(
id: index,
name:
'${booking.destination.name}, ${booking.destination.continent}',
startDate: booking.startDate,
endDate: booking.endDate,
),
)
.toList();
}
}

@ -1,7 +1,10 @@
import 'package:compass_app/data/services/api_client.dart';
import 'package:compass_app/data/services/api/api_client.dart';
import 'package:compass_app/data/services/api/model/booking/booking_api_model.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';
import '../../models/activity.dart';
import '../../models/booking.dart';
class FakeApiClient implements ApiClient {
// Should not increase when using cached data
@ -45,11 +48,11 @@ class FakeApiClient implements ApiClient {
}
@override
Future<Result<List<Activity>>> getActivityByDestination(String ref) {
Future<Result<List<Activity>>> getActivityByDestination(String ref) async {
requestCount++;
if (ref == 'alaska') {
return SynchronousFuture(Result.ok([
return Result.ok([
const Activity(
name: 'Glacier Trekking and Ice Climbing',
description:
@ -64,12 +67,35 @@ class FakeApiClient implements ApiClient {
imageUrl:
'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg',
),
]));
]);
}
if (ref == kBooking.destination.ref) {
return Result.ok([kActivity]);
}
return SynchronousFuture(Result.ok([]));
return Result.ok([]);
}
@override
AuthHeaderProvider? authHeaderProvider;
@override
Future<Result<BookingApiModel>> getBooking(int id) async {
return Result.ok(kBookingApiModel);
}
@override
Future<Result<List<BookingApiModel>>> getBookings() async {
return Result.ok([kBookingApiModel]);
}
List<BookingApiModel> bookings = [];
@override
Future<Result<BookingApiModel>> postBooking(BookingApiModel booking) async {
final bookingWithId = booking.copyWith(id: bookings.length);
bookings.add(bookingWithId);
return Result.ok(bookingWithId);
}
}

@ -1,4 +1,4 @@
import 'package:compass_app/data/services/auth_api_client.dart';
import 'package:compass_app/data/services/api/auth_api_client.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/auth/login_request/login_request.dart';
import 'package:compass_model/src/model/auth/login_response/login_response.dart';

@ -1,3 +1,5 @@
import 'package:compass_app/data/services/api/model/booking/booking_api_model.dart';
import 'package:compass_app/domain/models/booking/booking_summary.dart';
import 'package:compass_model/model.dart';
import 'activity.dart';
@ -9,3 +11,19 @@ final kBooking = Booking(
destination: kDestination1,
activity: [kActivity],
);
final kBookingSummary = BookingSummary(
id: 0,
startDate: kBooking.startDate,
endDate: kBooking.endDate,
name: '${kDestination1.name}, ${kDestination1.continent}',
);
final kBookingApiModel = BookingApiModel(
id: 0,
startDate: kBooking.startDate,
endDate: kBooking.endDate,
name: '${kDestination1.name}, ${kDestination1.continent}',
destinationRef: kDestination1.ref,
activitiesRef: [kActivity.ref],
);

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:compass_server/middleware/auth.dart';
import 'package:compass_server/routes/booking.dart';
import 'package:compass_server/routes/continent.dart';
import 'package:compass_server/routes/destination.dart';
import 'package:compass_server/routes/login.dart';
@ -12,6 +13,7 @@ import 'package:shelf_router/shelf_router.dart';
final _router = Router()
..get('/continent', continentHandler)
..mount('/destination', DestinationApi().router.call)
..mount('/booking', BookingApi().router.call)
..mount('/login', LoginApi().router.call);
void main(List<String> args) async {

@ -1,4 +1,18 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
class Assets {
static const activities = '../app/assets/activities.json';
static const destinations = '../app/assets/destinations.json';
static const _activities = '../app/assets/activities.json';
static const _destinations = '../app/assets/destinations.json';
static final List<Destination> destinations =
(json.decode(File(Assets._destinations).readAsStringSync()) as List)
.map((element) => Destination.fromJson(element))
.toList();
static final List<Activity> activities =
(json.decode(File(Assets._activities).readAsStringSync()) as List)
.map((element) => Activity.fromJson(element))
.toList();
}

@ -0,0 +1,31 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'booking.freezed.dart';
part 'booking.g.dart';
@freezed
class Booking with _$Booking {
const factory Booking({
/// Booking ID. Generated when stored in server.
int? id,
/// Start date of the trip
required DateTime startDate,
/// End date of the trip
required DateTime endDate,
/// Booking name
/// Should be "Destination, Continent"
required String name,
/// Destination of the trip
required String destinationRef,
/// List of chosen activities
required List<String> activitiesRef,
}) = _Booking;
factory Booking.fromJson(Map<String, Object?> json) =>
_$BookingFromJson(json);
}

@ -0,0 +1,312 @@
// 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 'booking.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');
Booking _$BookingFromJson(Map<String, dynamic> json) {
return _Booking.fromJson(json);
}
/// @nodoc
mixin _$Booking {
/// Booking ID. Generated when stored in server.
int? get id => throw _privateConstructorUsedError;
/// Start date of the trip
DateTime get startDate => throw _privateConstructorUsedError;
/// End date of the trip
DateTime get endDate => throw _privateConstructorUsedError;
/// Booking display name
/// Should be "Destination, Continent"
String get name => throw _privateConstructorUsedError;
/// Destination of the trip
String get destinationRef => throw _privateConstructorUsedError;
/// List of chosen activities
List<String> get activitiesRef => throw _privateConstructorUsedError;
/// Serializes this Booking to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$BookingCopyWith<Booking> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $BookingCopyWith<$Res> {
factory $BookingCopyWith(Booking value, $Res Function(Booking) then) =
_$BookingCopyWithImpl<$Res, Booking>;
@useResult
$Res call(
{int? id,
DateTime startDate,
DateTime endDate,
String name,
String destinationRef,
List<String> activitiesRef});
}
/// @nodoc
class _$BookingCopyWithImpl<$Res, $Val extends Booking>
implements $BookingCopyWith<$Res> {
_$BookingCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? startDate = null,
Object? endDate = null,
Object? name = null,
Object? destinationRef = null,
Object? activitiesRef = null,
}) {
return _then(_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
destinationRef: null == destinationRef
? _value.destinationRef
: destinationRef // ignore: cast_nullable_to_non_nullable
as String,
activitiesRef: null == activitiesRef
? _value.activitiesRef
: activitiesRef // ignore: cast_nullable_to_non_nullable
as List<String>,
) as $Val);
}
}
/// @nodoc
abstract class _$$BookingImplCopyWith<$Res> implements $BookingCopyWith<$Res> {
factory _$$BookingImplCopyWith(
_$BookingImpl value, $Res Function(_$BookingImpl) then) =
__$$BookingImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int? id,
DateTime startDate,
DateTime endDate,
String name,
String destinationRef,
List<String> activitiesRef});
}
/// @nodoc
class __$$BookingImplCopyWithImpl<$Res>
extends _$BookingCopyWithImpl<$Res, _$BookingImpl>
implements _$$BookingImplCopyWith<$Res> {
__$$BookingImplCopyWithImpl(
_$BookingImpl _value, $Res Function(_$BookingImpl) _then)
: super(_value, _then);
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? startDate = null,
Object? endDate = null,
Object? name = null,
Object? destinationRef = null,
Object? activitiesRef = null,
}) {
return _then(_$BookingImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
destinationRef: null == destinationRef
? _value.destinationRef
: destinationRef // ignore: cast_nullable_to_non_nullable
as String,
activitiesRef: null == activitiesRef
? _value._activitiesRef
: activitiesRef // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$BookingImpl implements _Booking {
const _$BookingImpl(
{this.id,
required this.startDate,
required this.endDate,
required this.name,
required this.destinationRef,
required final List<String> activitiesRef})
: _activitiesRef = activitiesRef;
factory _$BookingImpl.fromJson(Map<String, dynamic> json) =>
_$$BookingImplFromJson(json);
/// Booking ID. Generated when stored in server.
@override
final int? id;
/// Start date of the trip
@override
final DateTime startDate;
/// End date of the trip
@override
final DateTime endDate;
/// Booking display name
/// Should be "Destination, Continent"
@override
final String name;
/// Destination of the trip
@override
final String destinationRef;
/// List of chosen activities
final List<String> _activitiesRef;
/// List of chosen activities
@override
List<String> get activitiesRef {
if (_activitiesRef is EqualUnmodifiableListView) return _activitiesRef;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_activitiesRef);
}
@override
String toString() {
return 'Booking(id: $id, startDate: $startDate, endDate: $endDate, name: $name, destinationRef: $destinationRef, activitiesRef: $activitiesRef)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$BookingImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.startDate, startDate) ||
other.startDate == startDate) &&
(identical(other.endDate, endDate) || other.endDate == endDate) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.destinationRef, destinationRef) ||
other.destinationRef == destinationRef) &&
const DeepCollectionEquality()
.equals(other._activitiesRef, _activitiesRef));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, startDate, endDate, name,
destinationRef, const DeepCollectionEquality().hash(_activitiesRef));
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$BookingImplCopyWith<_$BookingImpl> get copyWith =>
__$$BookingImplCopyWithImpl<_$BookingImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$BookingImplToJson(
this,
);
}
}
abstract class _Booking implements Booking {
const factory _Booking(
{final int? id,
required final DateTime startDate,
required final DateTime endDate,
required final String name,
required final String destinationRef,
required final List<String> activitiesRef}) = _$BookingImpl;
factory _Booking.fromJson(Map<String, dynamic> json) = _$BookingImpl.fromJson;
/// Booking ID. Generated when stored in server.
@override
int? get id;
/// Start date of the trip
@override
DateTime get startDate;
/// End date of the trip
@override
DateTime get endDate;
/// Booking display name
/// Should be "Destination, Continent"
@override
String get name;
/// Destination of the trip
@override
String get destinationRef;
/// List of chosen activities
@override
List<String> get activitiesRef;
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$BookingImplCopyWith<_$BookingImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'booking.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$BookingImpl _$$BookingImplFromJson(Map<String, dynamic> json) =>
_$BookingImpl(
id: (json['id'] as num?)?.toInt(),
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
name: json['name'] as String,
destinationRef: json['destinationRef'] as String,
activitiesRef: (json['activitiesRef'] as List<dynamic>)
.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$$BookingImplToJson(_$BookingImpl instance) =>
<String, dynamic>{
'id': instance.id,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate.toIso8601String(),
'name': instance.name,
'destinationRef': instance.destinationRef,
'activitiesRef': instance.activitiesRef,
};

@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../config/assets.dart';
import '../model/booking/booking.dart';
/// Allows the user to save and restore bookings.
///
/// Bookings are stored in memory for demo purposes,
/// so they are lost when the server stops.
///
/// For demo purposes, this API includes a default pre-generated booking.
///
/// The [Booking.id] is also the index in the bookings list.
class BookingApi {
BookingApi() {
// Create a default booking
var destination = Assets.destinations.first;
final activitiesRef = Assets.activities
.where((activity) => activity.destinationRef == destination.ref)
.map((activity) => activity.ref)
.toList();
_bookings.insert(
0,
Booking(
id: 0,
name: '${destination.name}, ${destination.continent}',
startDate: DateTime(2024, 7, 20),
endDate: DateTime(2024, 8, 15),
destinationRef: destination.ref,
activitiesRef: activitiesRef,
),
);
}
// Bookings are kept in memory for demo purposes.
// To keep things simple, the id is also the index in the list.
final List<Booking> _bookings = List.empty(growable: true);
Router get router {
final router = Router();
// Get User bookings
router.get('/', (Request request) {
return Response.ok(
json.encode(_bookings),
headers: {'Content-Type': 'application/json'},
);
});
// Get a booking by id
router.get('/<id>', (Request request, String id) {
final index = int.parse(id);
if (index < 0 || index >= _bookings.length) {
return Response.notFound('Invalid id');
}
return Response.ok(
json.encode(_bookings[index]),
headers: {'Content-Type': 'application/json'},
);
});
// Save a new booking
router.post('/', (Request request) async {
final body = await request.readAsString();
final booking = Booking.fromJson(json.decode(body));
if (booking.id != null) {
// POST endpoint only allows newly created bookings
return Response.badRequest(
body: 'Booking already has id, use PUT instead.');
}
// Add ID to new booking
final id = _bookings.length;
final bookingWithId = booking.copyWith(id: id);
// Store booking
_bookings.add(bookingWithId);
// Respond with newly created booking
return Response(
201, // created
body: json.encode(bookingWithId),
headers: {'Content-Type': 'application/json'},
);
});
return router;
}
}

@ -1,34 +1,23 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../config/assets.dart';
class DestinationApi {
final List<Destination> destinations =
(json.decode(File(Assets.destinations).readAsStringSync()) as List)
.map((element) => Destination.fromJson(element))
.toList();
final List<Activity> activities =
(json.decode(File(Assets.activities).readAsStringSync()) as List)
.map((element) => Activity.fromJson(element))
.toList();
Router get router {
final router = Router();
router.get('/', (Request request) {
return Response.ok(
json.encode(destinations),
json.encode(Assets.destinations),
headers: {'Content-Type': 'application/json'},
);
});
router.get('/<id>/activity', (Request request, String id) {
final list = activities
final list = Assets.activities
.where((activity) => activity.destinationRef == id)
.toList();
return Response.ok(

@ -10,6 +10,8 @@ dependencies:
args: ^2.4.0
shelf: ^1.4.0
shelf_router: ^1.1.0
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
compass_model:
path: ../model
@ -17,3 +19,6 @@ dev_dependencies:
http: ^1.1.0
lints: ^3.0.0
test: ^1.24.0
build_runner: ^2.4.11
freezed: ^2.5.7
json_serializable: ^6.8.0

@ -1,8 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
// TODO: Remove the compass_model and replace by a server-side model
import 'package:compass_model/model.dart' hide Booking;
import 'package:compass_server/config/constants.dart';
import 'package:compass_server/model/booking/booking.dart';
import 'package:http/http.dart';
import 'package:test/test.dart';
@ -73,6 +75,55 @@ void main() {
expect(activity.first.name, 'Glacier Trekking and Ice Climbing');
});
test('Get bookings end-point', () async {
final response = await get(
Uri.parse('$host/booking'),
headers: headers,
);
expect(response.statusCode, 200);
// Parse json response list
final list = jsonDecode(response.body) as List<dynamic>;
// Parse items
final bookings = list.map((element) => Booking.fromJson(element));
expect(bookings.length, 1);
expect(bookings.first.id, 0);
});
test('Get booking with id 0', () async {
final response = await get(
Uri.parse('$host/booking/0'),
headers: headers,
);
expect(response.statusCode, 200);
final booking = Booking.fromJson(jsonDecode(response.body));
// Got booking with id 0
expect(booking.id, 0);
});
test('Store a booking', () async {
final response = await post(
Uri.parse('$host/booking'),
headers: headers,
body: jsonEncode(
Booking(
name: "DESTINATION, CONTINENT",
startDate: DateTime(2024, 1, 1),
endDate: DateTime(2024, 2, 2),
destinationRef: 'REF',
activitiesRef: ['ACT1', 'ACT2'],
),
),
);
expect(response.statusCode, 201);
final booking = Booking.fromJson(jsonDecode(response.body));
// New ID should be 1
expect(booking.id, 1);
});
test('404', () async {
final response = await get(
Uri.parse('$host/foobar'),

Loading…
Cancel
Save