diff --git a/compass_app/app/integration_test/app_local_data_test.dart b/compass_app/app/integration_test/app_local_data_test.dart index 06032d40a..7281fce55 100644 --- a/compass_app/app/integration_test/app_local_data_test.dart +++ b/compass_app/app/integration_test/app_local_data_test.dart @@ -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); }); }); } diff --git a/compass_app/app/integration_test/app_server_data_test.dart b/compass_app/app/integration_test/app_server_data_test.dart index d941e1ad5..57ef59139 100644 --- a/compass_app/app/integration_test/app_server_data_test.dart +++ b/compass_app/app/integration_test/app_server_data_test.dart @@ -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); diff --git a/compass_app/app/lib/config/dependencies.dart b/compass_app/app/lib/config/dependencies.dart index 072fc797a..f56975707 100644 --- a/compass_app/app/lib/config/dependencies.dart +++ b/compass_app/app/lib/config/dependencies.dart @@ -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 _sharedProviders = [ create: (context) => BookingCreateComponent( destinationRepository: context.read(), activityRepository: context.read(), + bookingRepository: context.read(), ), ), Provider( @@ -74,6 +79,11 @@ List get providersRemote { Provider.value( value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository, ), + Provider( + create: (context) => BookingRepositoryRemote( + apiClient: context.read(), + ) as BookingRepository, + ), ..._sharedProviders, ]; } @@ -87,13 +97,27 @@ List 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, diff --git a/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart b/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart index 202c249de..28695d59c 100644 --- a/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart +++ b/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart @@ -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>> 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 _loadAsset() async { - return await rootBundle.loadString(Assets.activities); - } - - List _parse(String localData) { - final parsed = (jsonDecode(localData) as List).cast>(); - - return parsed.map((json) => Activity.fromJson(json)).toList(); - } } diff --git a/compass_app/app/lib/data/repositories/activity/activity_repository_remote.dart b/compass_app/app/lib/data/repositories/activity/activity_repository_remote.dart index 9933995ea..5af0b3dcb 100644 --- a/compass_app/app/lib/data/repositories/activity/activity_repository_remote.dart +++ b/compass_app/app/lib/data/repositories/activity/activity_repository_remote.dart @@ -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]. diff --git a/compass_app/app/lib/data/repositories/auth/auth_repository_remote.dart b/compass_app/app/lib/data/repositories/auth/auth_repository_remote.dart index d3a9501eb..567aac693 100644 --- a/compass_app/app/lib/data/repositories/auth/auth_repository_remote.dart +++ b/compass_app/app/lib/data/repositories/auth/auth_repository_remote.dart @@ -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'; diff --git a/compass_app/app/lib/data/repositories/booking/booking_repository.dart b/compass_app/app/lib/data/repositories/booking/booking_repository.dart new file mode 100644 index 000000000..b79be9656 --- /dev/null +++ b/compass_app/app/lib/data/repositories/booking/booking_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>> getBookingsList(); + + /// Returns a full [Booking] given the id. + Future> getBooking(int id); + + /// Creates a new [Booking]. + Future> createBooking(Booking booking); +} diff --git a/compass_app/app/lib/data/repositories/booking/booking_repository_local.dart b/compass_app/app/lib/data/repositories/booking/booking_repository_local.dart new file mode 100644 index 000000000..cd8cd212a --- /dev/null +++ b/compass_app/app/lib/data/repositories/booking/booking_repository_local.dart @@ -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.empty(growable: true); + final LocalDataService _localDataService; + + @override + Future> createBooking(Booking booking) async { + _bookings.add(booking); + return Result.ok(null); + } + + @override + Future> 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>> getBookingsList() async { + await _createDefaultBooking(); + return Result.ok(_createSummaries()); + } + + List _createSummaries() { + return _bookings + .mapIndexed( + (index, booking) => BookingSummary( + id: index, + name: + '${booking.destination.name}, ${booking.destination.continent}', + startDate: booking.startDate, + endDate: booking.endDate, + ), + ) + .toList(); + } + + Future _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, + ), + ); + } + } +} diff --git a/compass_app/app/lib/data/repositories/booking/booking_repository_remote.dart b/compass_app/app/lib/data/repositories/booking/booking_repository_remote.dart new file mode 100644 index 000000000..23938b0ce --- /dev/null +++ b/compass_app/app/lib/data/repositories/booking/booking_repository_remote.dart @@ -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? _cachedDestinations; + + @override + Future> 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> getBooking(int id) async { + try { + // Get booking by ID from server + final resultBooking = await _apiClient.getBooking(id); + if (resultBooking is Error) { + 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>) { + 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>) { + 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>> getBookingsList() async { + try { + final result = await _apiClient.getBookings(); + if (result is Error>) { + 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); + } + } +} diff --git a/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart b/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart index 50a4ef6af..e815ced61 100644 --- a/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart +++ b/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart @@ -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>> 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())); } } diff --git a/compass_app/app/lib/data/repositories/continent/continent_repository_remote.dart b/compass_app/app/lib/data/repositories/continent/continent_repository_remote.dart index 1e6968d88..c2ff991ef 100644 --- a/compass_app/app/lib/data/repositories/continent/continent_repository_remote.dart +++ b/compass_app/app/lib/data/repositories/continent/continent_repository_remote.dart @@ -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]. diff --git a/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart b/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart index 7f0fc7787..41aa8db7a 100644 --- a/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart +++ b/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart @@ -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>> 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 _loadAsset() async { - return await rootBundle.loadString(Assets.destinations); - } - - List _parse(String localData) { - final parsed = (jsonDecode(localData) as List).cast>(); - - return parsed - .map((json) => Destination.fromJson(json)) - .toList(); - } } diff --git a/compass_app/app/lib/data/repositories/destination/destination_repository_remote.dart b/compass_app/app/lib/data/repositories/destination/destination_repository_remote.dart index fd9be3296..360c2a7de 100644 --- a/compass_app/app/lib/data/repositories/destination/destination_repository_remote.dart +++ b/compass_app/app/lib/data/repositories/destination/destination_repository_remote.dart @@ -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]. diff --git a/compass_app/app/lib/data/services/api_client.dart b/compass_app/app/lib/data/services/api/api_client.dart similarity index 56% rename from compass_app/app/lib/data/services/api_client.dart rename to compass_app/app/lib/data/services/api/api_client.dart index 2dc50531b..4bef6a049 100644 --- a/compass_app/app/lib/data/services/api_client.dart +++ b/compass_app/app/lib/data/services/api/api_client.dart @@ -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>> 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; + 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> 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> 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(); + } + } } diff --git a/compass_app/app/lib/data/services/auth_api_client.dart b/compass_app/app/lib/data/services/api/auth_api_client.dart similarity index 95% rename from compass_app/app/lib/data/services/auth_api_client.dart rename to compass_app/app/lib/data/services/api/auth_api_client.dart index 5b44ff7d7..f5dbd1646 100644 --- a/compass_app/app/lib/data/services/auth_api_client.dart +++ b/compass_app/app/lib/data/services/api/auth_api_client.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:compass_model/model.dart'; -import '../../utils/result.dart'; +import '../../../utils/result.dart'; class AuthApiClient { Future> login(LoginRequest loginRequest) async { diff --git a/compass_app/app/lib/data/services/api/model/booking/booking_api_model.dart b/compass_app/app/lib/data/services/api/model/booking/booking_api_model.dart new file mode 100644 index 000000000..5a67b5dc5 --- /dev/null +++ b/compass_app/app/lib/data/services/api/model/booking/booking_api_model.dart @@ -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 activitiesRef, + }) = _BookingApiModel; + + factory BookingApiModel.fromJson(Map json) => + _$BookingApiModelFromJson(json); +} diff --git a/compass_app/app/lib/data/services/api/model/booking/booking_api_model.freezed.dart b/compass_app/app/lib/data/services/api/model/booking/booking_api_model.freezed.dart new file mode 100644 index 000000000..e3589dc52 --- /dev/null +++ b/compass_app/app/lib/data/services/api/model/booking/booking_api_model.freezed.dart @@ -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 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 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 get activitiesRef => throw _privateConstructorUsedError; + + /// Serializes this BookingApiModel to a JSON map. + Map 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 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 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, + ) 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 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, + )); + } +} + +/// @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 activitiesRef}) + : _activitiesRef = activitiesRef; + + factory _$BookingApiModelImpl.fromJson(Map 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 _activitiesRef; + + /// List of chosen activities + @override + List 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 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 activitiesRef}) = _$BookingApiModelImpl; + + factory _BookingApiModel.fromJson(Map 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 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; +} diff --git a/compass_app/app/lib/data/services/api/model/booking/booking_api_model.g.dart b/compass_app/app/lib/data/services/api/model/booking/booking_api_model.g.dart new file mode 100644 index 000000000..8be3181bf --- /dev/null +++ b/compass_app/app/lib/data/services/api/model/booking/booking_api_model.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'booking_api_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$BookingApiModelImpl _$$BookingApiModelImplFromJson( + Map 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) + .map((e) => e as String) + .toList(), + ); + +Map _$$BookingApiModelImplToJson( + _$BookingApiModelImpl instance) => + { + 'id': instance.id, + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + 'name': instance.name, + 'destinationRef': instance.destinationRef, + 'activitiesRef': instance.activitiesRef, + }; diff --git a/compass_app/app/lib/data/services/local/local_data_service.dart b/compass_app/app/lib/data/services/local/local_data_service.dart new file mode 100644 index 000000000..0b1a15268 --- /dev/null +++ b/compass_app/app/lib/data/services/local/local_data_service.dart @@ -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 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> getActivities() async { + final json = await _loadStringAsset(Assets.activities); + return json.map((json) => Activity.fromJson(json)).toList(); + } + + Future> getDestinations() async { + final json = await _loadStringAsset(Assets.destinations); + return json.map((json) => Destination.fromJson(json)).toList(); + } + + Future>> _loadStringAsset(String asset) async { + final localData = await rootBundle.loadString(asset); + return (jsonDecode(localData) as List).cast>(); + } +} diff --git a/compass_app/app/lib/domain/components/booking/booking_create_component.dart b/compass_app/app/lib/domain/components/booking/booking_create_component.dart index 23bf9faf8..0acccf0af 100644 --- a/compass_app/app/lib/domain/components/booking/booking_create_component.dart +++ b/compass_app/app/lib/domain/components/booking/booking_create_component.dart @@ -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(): + _log.fine('Booking saved successfully'); + break; + case Error(): + _log.warning('Failed to save booking', saveBookingResult.error); + return Result.error(saveBookingResult.error); + } + + // Create Booking object + return Result.ok(booking); } Future> _fetchDestination(String destinationRef) async { diff --git a/compass_app/app/lib/domain/models/booking/booking_summary.dart b/compass_app/app/lib/domain/models/booking/booking_summary.dart new file mode 100644 index 000000000..439ecd3c3 --- /dev/null +++ b/compass_app/app/lib/domain/models/booking/booking_summary.dart @@ -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 json) => + _$BookingSummaryFromJson(json); +} diff --git a/compass_app/app/lib/domain/models/booking/booking_summary.freezed.dart b/compass_app/app/lib/domain/models/booking/booking_summary.freezed.dart new file mode 100644 index 000000000..59686266e --- /dev/null +++ b/compass_app/app/lib/domain/models/booking/booking_summary.freezed.dart @@ -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 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 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 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 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 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 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 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; +} diff --git a/compass_app/app/lib/domain/models/booking/booking_summary.g.dart b/compass_app/app/lib/domain/models/booking/booking_summary.g.dart new file mode 100644 index 000000000..456acf50b --- /dev/null +++ b/compass_app/app/lib/domain/models/booking/booking_summary.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'booking_summary.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$BookingSummaryImpl _$$BookingSummaryImplFromJson(Map 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 _$$BookingSummaryImplToJson( + _$BookingSummaryImpl instance) => + { + 'id': instance.id, + 'name': instance.name, + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + }; diff --git a/compass_app/app/lib/routing/router.dart b/compass_app/app/lib/routing/router.dart index fe93c9990..a34922037 100644 --- a/compass_app/app/lib/routing/router.dart +++ b/compass_app/app/lib/routing/router.dart @@ -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 _redirect(BuildContext context, GoRouterState state) async { // if the user is not logged in, they need to login final bool loggedIn = await context.read().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 diff --git a/compass_app/app/lib/routing/routes.dart b/compass_app/app/lib/routing/routes.dart new file mode 100644 index 000000000..ee48f97e0 --- /dev/null +++ b/compass_app/app/lib/routing/routes.dart @@ -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'; +} diff --git a/compass_app/app/lib/ui/activities/widgets/activities_header.dart b/compass_app/app/lib/ui/activities/widgets/activities_header.dart index 22e1ffc43..474d21165 100644 --- a/compass_app/app/lib/ui/activities/widgets/activities_header.dart +++ b/compass_app/app/lib/ui/activities/widgets/activities_header.dart @@ -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( diff --git a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart index 66df7c7c3..327a9b1cd 100644 --- a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart +++ b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart @@ -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 { 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 { void _onResult() { if (widget.viewModel.saveActivities.completed) { widget.viewModel.saveActivities.clearResult(); - context.go('/booking'); + context.go(Routes.booking); } if (widget.viewModel.saveActivities.error) { diff --git a/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart b/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart index eb865b3b3..22fdf1ddc 100644 --- a/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart +++ b/compass_app/app/lib/ui/auth/login/widgets/login_screen.dart @@ -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 { void _onResult() { if (widget.viewModel.login.completed) { widget.viewModel.login.clearResult(); - context.go('/'); + context.go(Routes.home); } if (widget.viewModel.login.error) { diff --git a/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart b/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart index 79e2274db..b6fab19bf 100644 --- a/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart +++ b/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart @@ -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 loadBooking; /// Share the current booking using the OS share dialog. late final Command0 shareBooking; - Future> _loadBooking() async { + Future> _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> _load(int id) async { + final result = await _bookingRepository.getBooking(id); + switch (result) { + case Ok(): + _log.fine('Loaded booking $id'); + _booking = result.value; + notifyListeners(); + case Error(): + _log.warning('Failed to load booking $id'); + } + return result; + } } diff --git a/compass_app/app/lib/ui/booking/widgets/booking_header.dart b/compass_app/app/lib/ui/booking/widgets/booking_header.dart index 697b6bd0a..f3b53c088 100644 --- a/compass_app/app/lib/ui/booking/widgets/booking_header.dart +++ b/compass_app/app/lib/ui/booking/widgets/booking_header.dart @@ -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, diff --git a/compass_app/app/lib/ui/booking/widgets/booking_screen.dart b/compass_app/app/lib/ui/booking/widgets/booking_screen.dart index 5db968cb7..46d9e3d58 100644 --- a/compass_app/app/lib/ui/booking/widgets/booking_screen.dart +++ b/compass_app/app/lib/ui/booking/widgets/booking_screen.dart @@ -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 createState() => _BookingScreenState(); +} + +class _BookingScreenState extends State { + @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, + ), + )); + } + } } diff --git a/compass_app/app/lib/ui/booking/widgets/booking_share_button.dart b/compass_app/app/lib/ui/booking/widgets/booking_share_button.dart deleted file mode 100644 index d38bb552e..000000000 --- a/compass_app/app/lib/ui/booking/widgets/booking_share_button.dart +++ /dev/null @@ -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), - ); - }), - ), - ), - ), - ), - ), - ); - } -} diff --git a/compass_app/app/lib/ui/core/localization/applocalization.dart b/compass_app/app/lib/ui/core/localization/applocalization.dart index 2ee86816a..9338a0bc2 100644 --- a/compass_app/app/lib/ui/core/localization/applocalization.dart +++ b/compass_app/app/lib/ui/core/localization/applocalization.dart @@ -11,6 +11,8 @@ class AppLocalization { static const _strings = { '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()); } diff --git a/compass_app/app/lib/ui/core/ui/home_button.dart b/compass_app/app/lib/ui/core/ui/home_button.dart index 290b605e4..fcdb775c6 100644 --- a/compass_app/app/lib/ui/core/ui/home_button.dart +++ b/compass_app/app/lib/ui/core/ui/home_button.dart @@ -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( diff --git a/compass_app/app/lib/ui/core/ui/search_bar.dart b/compass_app/app/lib/ui/core/ui/search_bar.dart index 585871fb5..b0a2c4aba 100644 --- a/compass_app/app/lib/ui/core/ui/search_bar.dart +++ b/compass_app/app/lib/ui/core/ui/search_bar.dart @@ -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(), ], ); } diff --git a/compass_app/app/lib/ui/home/view_models/home_viewmodel.dart b/compass_app/app/lib/ui/home/view_models/home_viewmodel.dart new file mode 100644 index 000000000..e79429c35 --- /dev/null +++ b/compass_app/app/lib/ui/home/view_models/home_viewmodel.dart @@ -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 _bookings = []; + + late Command0 load; + + List get bookings => _bookings; + + Future _load() async { + try { + final result = await _bookingRepository.getBookingsList(); + switch (result) { + case Ok>(): + _bookings = result.value; + _log.fine('Loaded bookings'); + case Error>(): + _log.warning('Failed to load bookings', result.error); + } + return result; + } finally { + notifyListeners(); + } + } +} diff --git a/compass_app/app/lib/ui/home/widgets/home_screen.dart b/compass_app/app/lib/ui/home/widgets/home_screen.dart new file mode 100644 index 000000000..dc748839d --- /dev/null +++ b/compass_app/app/lib/ui/home/widgets/home_screen.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/compass_app/app/lib/ui/results/widgets/results_screen.dart b/compass_app/app/lib/ui/results/widgets/results_screen.dart index 2e270b18b..27c992aea 100644 --- a/compass_app/app/lib/ui/results/widgets/results_screen.dart +++ b/compass_app/app/lib/ui/results/widgets/results_screen.dart @@ -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 { @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 { void _onResult() { if (widget.viewModel.updateItineraryConfig.completed) { widget.viewModel.updateItineraryConfig.clearResult(); - context.go('/activities'); + context.go(Routes.activities); } if (widget.viewModel.updateItineraryConfig.error) { diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart index 3ee5dad5a..8e44a7619 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart @@ -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), + ], + ), ), ); } diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart index de026e366..3a35a0c22 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart @@ -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 { void _onResult() { if (widget.viewModel.updateItineraryConfig.completed) { widget.viewModel.updateItineraryConfig.clearResult(); - context.go('/results'); + context.go(Routes.results); } if (widget.viewModel.updateItineraryConfig.error) { diff --git a/compass_app/app/macos/Runner.xcodeproj/project.pbxproj b/compass_app/app/macos/Runner.xcodeproj/project.pbxproj index 5e992c04e..79cdc239e 100644 --- a/compass_app/app/macos/Runner.xcodeproj/project.pbxproj +++ b/compass_app/app/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 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 = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; /* 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 = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 1D6CC27981A89B68E6FDD92F /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 624CAC5D902868ED5B3541A6 /* Pods_Runner.framework */, + D3F947B2B2872CA43E01D9D3 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -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; diff --git a/compass_app/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/compass_app/app/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16e..21a3cc14c 100644 --- a/compass_app/app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/compass_app/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/compass_app/app/macos/Runner/AppDelegate.swift b/compass_app/app/macos/Runner/AppDelegate.swift index d53ef6437..8e02df288 100644 --- a/compass_app/app/macos/Runner/AppDelegate.swift +++ b/compass_app/app/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true diff --git a/compass_app/app/pubspec.yaml b/compass_app/app/pubspec.yaml index d38909024..e4067fcc5 100644 --- a/compass_app/app/pubspec.yaml +++ b/compass_app/app/pubspec.yaml @@ -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 diff --git a/compass_app/app/test/data/repositories/activity/activity_repository_local_test.dart b/compass_app/app/test/data/repositories/activity/activity_repository_local_test.dart index d10530260..95758431c 100644 --- a/compass_app/app/test/data/repositories/activity/activity_repository_local_test.dart +++ b/compass_app/app/test/data/repositories/activity/activity_repository_local_test.dart @@ -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'); diff --git a/compass_app/app/test/data/repositories/booking/booking_repository_remote_test.dart b/compass_app/app/test/data/repositories/booking/booking_repository_remote_test.dart new file mode 100644 index 000000000..8fe05a74d --- /dev/null +++ b/compass_app/app/test/data/repositories/booking/booking_repository_remote_test.dart @@ -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>()); + 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]); + }); + }); +} diff --git a/compass_app/app/test/data/repositories/destination/destination_repository_local_test.dart b/compass_app/app/test/data/repositories/destination/destination_repository_local_test.dart index 90fbf0338..5534aa608 100644 --- a/compass_app/app/test/data/repositories/destination/destination_repository_local_test.dart +++ b/compass_app/app/test/data/repositories/destination/destination_repository_local_test.dart @@ -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 diff --git a/compass_app/app/test/domain/components/booking/booking_create_component_test.dart b/compass_app/app/test/domain/components/booking/booking_create_component_test.dart index 5f3075d3d..e3c6f4f73 100644 --- a/compass_app/app/test/domain/components/booking/booking_create_component_test.dart +++ b/compass_app/app/test/domain/components/booking/booking_create_component_test.dart @@ -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( diff --git a/compass_app/app/test/ui/booking/booking_screen_test.dart b/compass_app/app/test/ui/booking/booking_screen_test.dart index 168aa0352..1453fb5f9 100644 --- a/compass_app/app/test/ui/booking/booking_screen_test.dart +++ b/compass_app/app/test/ui/booking/booking_screen_test.dart @@ -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); diff --git a/compass_app/app/test/ui/home/widgets/home_screen_test.dart b/compass_app/app/test/ui/home/widgets/home_screen_test.dart new file mode 100644 index 000000000..b2f1c45de --- /dev/null +++ b/compass_app/app/test/ui/home/widgets/home_screen_test.dart @@ -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); + }); + }); +} diff --git a/compass_app/app/testing/fakes/repositories/fake_booking_repository.dart b/compass_app/app/testing/fakes/repositories/fake_booking_repository.dart new file mode 100644 index 000000000..543a52df8 --- /dev/null +++ b/compass_app/app/testing/fakes/repositories/fake_booking_repository.dart @@ -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 bookings = List.empty(growable: true); + + @override + Future> createBooking(Booking booking) async { + bookings.add(booking); + return Result.ok(null); + } + + @override + Future> getBooking(int id) async { + return Result.ok(bookings[id]); + } + + @override + Future>> getBookingsList() async { + return Result.ok(_createSummaries()); + } + + List _createSummaries() { + return bookings + .mapIndexed( + (index, booking) => BookingSummary( + id: index, + name: + '${booking.destination.name}, ${booking.destination.continent}', + startDate: booking.startDate, + endDate: booking.endDate, + ), + ) + .toList(); + } +} diff --git a/compass_app/app/testing/fakes/services/fake_api_client.dart b/compass_app/app/testing/fakes/services/fake_api_client.dart index 612018d0e..9fe5a8934 100644 --- a/compass_app/app/testing/fakes/services/fake_api_client.dart +++ b/compass_app/app/testing/fakes/services/fake_api_client.dart @@ -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>> getActivityByDestination(String ref) { + Future>> 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> getBooking(int id) async { + return Result.ok(kBookingApiModel); + } + + @override + Future>> getBookings() async { + return Result.ok([kBookingApiModel]); + } + + List bookings = []; + + @override + Future> postBooking(BookingApiModel booking) async { + final bookingWithId = booking.copyWith(id: bookings.length); + bookings.add(bookingWithId); + return Result.ok(bookingWithId); + } } diff --git a/compass_app/app/testing/fakes/services/fake_auth_api_client.dart b/compass_app/app/testing/fakes/services/fake_auth_api_client.dart index 737286b17..219d80b5a 100644 --- a/compass_app/app/testing/fakes/services/fake_auth_api_client.dart +++ b/compass_app/app/testing/fakes/services/fake_auth_api_client.dart @@ -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'; diff --git a/compass_app/app/testing/models/booking.dart b/compass_app/app/testing/models/booking.dart index 63a5664e8..0d270ea81 100644 --- a/compass_app/app/testing/models/booking.dart +++ b/compass_app/app/testing/models/booking.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], +); diff --git a/compass_app/server/bin/compass_server.dart b/compass_app/server/bin/compass_server.dart index 5d3d00105..43593a8c1 100644 --- a/compass_app/server/bin/compass_server.dart +++ b/compass_app/server/bin/compass_server.dart @@ -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 args) async { diff --git a/compass_app/server/lib/config/assets.dart b/compass_app/server/lib/config/assets.dart index 134977fb7..b28b45ee2 100644 --- a/compass_app/server/lib/config/assets.dart +++ b/compass_app/server/lib/config/assets.dart @@ -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 destinations = + (json.decode(File(Assets._destinations).readAsStringSync()) as List) + .map((element) => Destination.fromJson(element)) + .toList(); + static final List activities = + (json.decode(File(Assets._activities).readAsStringSync()) as List) + .map((element) => Activity.fromJson(element)) + .toList(); } diff --git a/compass_app/server/lib/model/booking/booking.dart b/compass_app/server/lib/model/booking/booking.dart new file mode 100644 index 000000000..a661421e2 --- /dev/null +++ b/compass_app/server/lib/model/booking/booking.dart @@ -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 activitiesRef, + }) = _Booking; + + factory Booking.fromJson(Map json) => + _$BookingFromJson(json); +} diff --git a/compass_app/server/lib/model/booking/booking.freezed.dart b/compass_app/server/lib/model/booking/booking.freezed.dart new file mode 100644 index 000000000..032c42d33 --- /dev/null +++ b/compass_app/server/lib/model/booking/booking.freezed.dart @@ -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 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 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 get activitiesRef => throw _privateConstructorUsedError; + + /// Serializes this Booking to a JSON map. + Map 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 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 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, + ) 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 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, + )); + } +} + +/// @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 activitiesRef}) + : _activitiesRef = activitiesRef; + + factory _$BookingImpl.fromJson(Map 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 _activitiesRef; + + /// List of chosen activities + @override + List 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 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 activitiesRef}) = _$BookingImpl; + + factory _Booking.fromJson(Map 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 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; +} diff --git a/compass_app/server/lib/model/booking/booking.g.dart b/compass_app/server/lib/model/booking/booking.g.dart new file mode 100644 index 000000000..829d766bf --- /dev/null +++ b/compass_app/server/lib/model/booking/booking.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'booking.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$BookingImpl _$$BookingImplFromJson(Map 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) + .map((e) => e as String) + .toList(), + ); + +Map _$$BookingImplToJson(_$BookingImpl instance) => + { + 'id': instance.id, + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + 'name': instance.name, + 'destinationRef': instance.destinationRef, + 'activitiesRef': instance.activitiesRef, + }; diff --git a/compass_app/server/lib/routes/booking.dart b/compass_app/server/lib/routes/booking.dart new file mode 100644 index 000000000..677224887 --- /dev/null +++ b/compass_app/server/lib/routes/booking.dart @@ -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 _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('/', (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; + } +} diff --git a/compass_app/server/lib/routes/destination.dart b/compass_app/server/lib/routes/destination.dart index f6cf2f049..239b17b80 100644 --- a/compass_app/server/lib/routes/destination.dart +++ b/compass_app/server/lib/routes/destination.dart @@ -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 destinations = - (json.decode(File(Assets.destinations).readAsStringSync()) as List) - .map((element) => Destination.fromJson(element)) - .toList(); - final List 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('//activity', (Request request, String id) { - final list = activities + final list = Assets.activities .where((activity) => activity.destinationRef == id) .toList(); return Response.ok( diff --git a/compass_app/server/pubspec.yaml b/compass_app/server/pubspec.yaml index 8c28c3674..6a720bb83 100644 --- a/compass_app/server/pubspec.yaml +++ b/compass_app/server/pubspec.yaml @@ -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 diff --git a/compass_app/server/test/server_test.dart b/compass_app/server/test/server_test.dart index 90524b6f2..ebd0edc0f 100644 --- a/compass_app/server/test/server_test.dart +++ b/compass_app/server/test/server_test.dart @@ -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; + // 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'),