diff --git a/compass_app/app/android/settings.gradle b/compass_app/app/android/settings.gradle index 536165d35..7e64ce60d 100644 --- a/compass_app/app/android/settings.gradle +++ b/compass_app/app/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "1.8.0" apply false } include ":app" diff --git a/compass_app/app/lib/config/dependencies.dart b/compass_app/app/lib/config/dependencies.dart index 079358f8a..84d73cd16 100644 --- a/compass_app/app/lib/config/dependencies.dart +++ b/compass_app/app/lib/config/dependencies.dart @@ -13,6 +13,23 @@ import '../data/repositories/destination/destination_repository_remote.dart'; import '../data/repositories/itinerary_config/itinerary_config_repository.dart'; import '../data/repositories/itinerary_config/itinerary_config_repository_memory.dart'; import '../data/services/api_client.dart'; +import '../ui/booking/components/booking_create_component.dart'; +import '../ui/booking/components/booking_share_component.dart'; + +/// Shared providers for all configurations. +List _sharedProviders = [ + Provider( + lazy: true, + create: (context) => BookingCreateComponent( + destinationRepository: context.read(), + activityRepository: context.read(), + ), + ), + Provider( + lazy: true, + create: (context) => BookingShareComponent.withSharePlus(), + ), +]; /// Configure dependencies for remote data. /// This dependency list uses repositories that connect to a remote server. @@ -38,6 +55,7 @@ List get providersRemote { Provider.value( value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository, ), + ..._sharedProviders, ]; } @@ -57,5 +75,6 @@ List get providersLocal { Provider.value( value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository, ), + ..._sharedProviders, ]; } diff --git a/compass_app/app/lib/routing/router.dart b/compass_app/app/lib/routing/router.dart index 86972c636..173b00d28 100644 --- a/compass_app/app/lib/routing/router.dart +++ b/compass_app/app/lib/routing/router.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import '../ui/activities/view_models/activities_viewmodel.dart'; import '../ui/activities/widgets/activities_screen.dart'; +import '../ui/booking/widgets/booking_screen.dart'; +import '../ui/booking/view_models/booking_viewmodel.dart'; import '../ui/results/view_models/results_viewmodel.dart'; import '../ui/results/widgets/results_screen.dart'; import '../ui/search_form/view_models/search_form_viewmodel.dart'; @@ -47,6 +49,19 @@ final router = GoRouter( ); }, ), + GoRoute( + path: 'booking', + builder: (context, state) { + final viewModel = BookingViewModel( + itineraryConfigRepository: context.read(), + bookingComponent: context.read(), + shareComponent: context.read(), + ); + return BookingScreen( + viewModel: viewModel, + ); + }, + ), ], ), ], diff --git a/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart b/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart index afcfb3011..651d23db2 100644 --- a/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart +++ b/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart @@ -14,14 +14,7 @@ class ActivitiesViewModel extends ChangeNotifier { }) : _activityRepository = activityRepository, _itineraryConfigRepository = itineraryConfigRepository { loadActivities = Command0(_loadActivities)..execute(); - saveActivities = Command0(() async { - _log.shout( - 'Save activities not implemented', - null, - StackTrace.current, - ); - return Result.error(Exception('Not implemented')); - }); + saveActivities = Command0(_saveActivities); } final _log = Logger('ActivitiesViewModel'); @@ -62,6 +55,8 @@ class ActivitiesViewModel extends ChangeNotifier { return Result.error(Exception('Destination not found')); } + _selectedActivities.addAll(result.asOk.value.activities); + final resultActivities = await _activityRepository.getByDestination(destinationRef); switch (resultActivities) { @@ -118,4 +113,26 @@ class ActivitiesViewModel extends ChangeNotifier { _log.finest('Activity $activityRef removed'); notifyListeners(); } + + Future> _saveActivities() async { + final resultConfig = await _itineraryConfigRepository.getItineraryConfig(); + if (resultConfig is Error) { + _log.warning( + 'Failed to load stored ItineraryConfig', + resultConfig.asError.error, + ); + return resultConfig; + } + + final itineraryConfig = resultConfig.asOk.value; + final result = await _itineraryConfigRepository.setItineraryConfig( + itineraryConfig.copyWith(activities: _selectedActivities.toList())); + if (result is Error) { + _log.warning( + 'Failed to store ItineraryConfig', + result.asError.error, + ); + } + return result; + } } 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 c9c1290aa..22e1ffc43 100644 --- a/compass_app/app/lib/ui/activities/widgets/activities_header.dart +++ b/compass_app/app/lib/ui/activities/widgets/activities_header.dart @@ -11,28 +11,32 @@ class ActivitiesHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: Dimens.of(context).paddingScreenHorizontal, - right: Dimens.of(context).paddingScreenHorizontal, - top: Dimens.of(context).paddingScreenVertical, - bottom: Dimens.paddingVertical, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CustomBackButton( - onTap: () { - // Navigate to ResultsScreen and edit search - context.go('/results'); - }, - ), - Text( - AppLocalization.of(context).activities, - style: Theme.of(context).textTheme.titleLarge, - ), - const HomeButton(), - ], + return SafeArea( + top: true, + bottom: false, + child: Padding( + padding: EdgeInsets.only( + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + top: Dimens.of(context).paddingScreenVertical, + bottom: Dimens.paddingVertical, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CustomBackButton( + onTap: () { + // Navigate to ResultsScreen and edit search + context.go('/results'); + }, + ), + Text( + AppLocalization.of(context).activities, + style: Theme.of(context).textTheme.titleLarge, + ), + const HomeButton(), + ], + ), ), ); } 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 cb35c3767..ad45e3d3e 100644 --- a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart +++ b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../core/localization/applocalization.dart'; import '../../core/themes/dimens.dart'; @@ -43,67 +44,71 @@ class _ActivitiesScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: ListenableBuilder( - listenable: widget.viewModel.loadActivities, - builder: (context, child) { - if (widget.viewModel.loadActivities.completed) { - return child!; - } - return Column( - children: [ - const ActivitiesHeader(), - if (widget.viewModel.loadActivities.running) - const Expanded( - child: Center(child: CircularProgressIndicator())), - if (widget.viewModel.loadActivities.error) - Expanded( - child: Center( - child: ErrorIndicator( - title: AppLocalization.of(context) - .errorWhileLoadingActivities, - label: AppLocalization.of(context).tryAgain, - onPressed: widget.viewModel.loadActivities.execute, - ), - ), - ), - ], - ); - }, - child: ListenableBuilder( - listenable: widget.viewModel, + return PopScope( + canPop: false, + onPopInvokedWithResult: (d, r) => context.go('/results'), + child: Scaffold( + body: ListenableBuilder( + listenable: widget.viewModel.loadActivities, builder: (context, child) { + if (widget.viewModel.loadActivities.completed) { + return child!; + } return Column( children: [ - Expanded( - child: CustomScrollView( - slivers: [ - const SliverToBoxAdapter( - child: ActivitiesHeader(), - ), - ActivitiesTitle( - viewModel: widget.viewModel, - activityTimeOfDay: ActivityTimeOfDay.daytime, - ), - ActivitiesList( - viewModel: widget.viewModel, - activityTimeOfDay: ActivityTimeOfDay.daytime, + const ActivitiesHeader(), + if (widget.viewModel.loadActivities.running) + const Expanded( + child: Center(child: CircularProgressIndicator())), + if (widget.viewModel.loadActivities.error) + Expanded( + child: Center( + child: ErrorIndicator( + title: AppLocalization.of(context) + .errorWhileLoadingActivities, + label: AppLocalization.of(context).tryAgain, + onPressed: widget.viewModel.loadActivities.execute, ), - ActivitiesTitle( - viewModel: widget.viewModel, - activityTimeOfDay: ActivityTimeOfDay.evening, - ), - ActivitiesList( - viewModel: widget.viewModel, - activityTimeOfDay: ActivityTimeOfDay.evening, - ), - ], + ), ), - ), - _BottomArea(viewModel: widget.viewModel), ], ); }, + child: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, child) { + return Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + const SliverToBoxAdapter( + child: ActivitiesHeader(), + ), + ActivitiesTitle( + viewModel: widget.viewModel, + activityTimeOfDay: ActivityTimeOfDay.daytime, + ), + ActivitiesList( + viewModel: widget.viewModel, + activityTimeOfDay: ActivityTimeOfDay.daytime, + ), + ActivitiesTitle( + viewModel: widget.viewModel, + activityTimeOfDay: ActivityTimeOfDay.evening, + ), + ActivitiesList( + viewModel: widget.viewModel, + activityTimeOfDay: ActivityTimeOfDay.evening, + ), + ], + ), + ), + _BottomArea(viewModel: widget.viewModel), + ], + ); + }, + ), ), ), ); @@ -112,7 +117,7 @@ class _ActivitiesScreenState extends State { void _onResult() { if (widget.viewModel.saveActivities.completed) { widget.viewModel.saveActivities.clearResult(); - // TODO + context.go('/booking'); } if (widget.viewModel.saveActivities.error) { @@ -159,6 +164,7 @@ class _BottomArea extends StatelessWidget { style: Theme.of(context).textTheme.labelLarge, ), FilledButton( + key: const Key('confirm-button'), onPressed: viewModel.selectedActivities.isNotEmpty ? viewModel.saveActivities.execute : null, diff --git a/compass_app/app/lib/ui/activities/widgets/activity_entry.dart b/compass_app/app/lib/ui/activities/widgets/activity_entry.dart index 54178dbe8..241481b30 100644 --- a/compass_app/app/lib/ui/activities/widgets/activity_entry.dart +++ b/compass_app/app/lib/ui/activities/widgets/activity_entry.dart @@ -42,13 +42,14 @@ class ActivityEntry extends StatelessWidget { ), Text( activity.name, - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), + const SizedBox(width: 20), CustomCheckbox( key: ValueKey('${activity.ref}-checkbox'), value: selected, diff --git a/compass_app/app/lib/ui/booking/components/booking_create_component.dart b/compass_app/app/lib/ui/booking/components/booking_create_component.dart new file mode 100644 index 000000000..23bf9faf8 --- /dev/null +++ b/compass_app/app/lib/ui/booking/components/booking_create_component.dart @@ -0,0 +1,85 @@ +import 'package:compass_model/model.dart'; +import 'package:logging/logging.dart'; + +import '../../../data/repositories/activity/activity_repository.dart'; +import '../../../data/repositories/destination/destination_repository.dart'; +import '../../../utils/result.dart'; + +/// Component for creating [Booking] objects from [ItineraryConfig]. +/// +/// Fetches [Destination] and [Activity] objects from repositories, +/// checks if dates are set and creates a [Booking] object. +class BookingCreateComponent { + BookingCreateComponent({ + required DestinationRepository destinationRepository, + required ActivityRepository activityRepository, + }) : _destinationRepository = destinationRepository, + _activityRepository = activityRepository; + + final DestinationRepository _destinationRepository; + final ActivityRepository _activityRepository; + final _log = Logger('BookingComponent'); + + /// Create [Booking] from a stored [ItineraryConfig] + Future> createFrom(ItineraryConfig itineraryConfig) async { + // Get Destination object from repository + if (itineraryConfig.destination == null) { + _log.warning('Destination is not set'); + return Result.error(Exception('Destination is not set')); + } + final destinationResult = + await _fetchDestination(itineraryConfig.destination!); + if (destinationResult is Error) { + _log.warning('Error fetching destination: ${destinationResult.error}'); + return Result.error(destinationResult.error); + } + _log.fine('Destination loaded: ${destinationResult.asOk.value.ref}'); + + // Get Activity objects from repository + if (itineraryConfig.activities.isEmpty) { + _log.warning('Activities are not set'); + return Result.error(Exception('Activities are not set')); + } + final activitiesResult = await _activityRepository.getByDestination( + itineraryConfig.destination!, + ); + if (activitiesResult is Error>) { + _log.warning('Error fetching activities: ${activitiesResult.error}'); + return Result.error(activitiesResult.error); + } + final activities = activitiesResult.asOk.value + .where( + (activity) => itineraryConfig.activities.contains(activity.ref), + ) + .toList(); + _log.fine('Activities loaded (${activities.length})'); + + // Check if dates are set + if (itineraryConfig.startDate == null || itineraryConfig.endDate == null) { + _log.warning('Dates are not set'); + 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, + ), + ); + } + + Future> _fetchDestination(String destinationRef) async { + final result = await _destinationRepository.getDestinations(); + switch (result) { + case Ok>(): + final destination = result.value + .firstWhere((destination) => destination.ref == destinationRef); + return Ok(destination); + case Error>(): + return Result.error(result.error); + } + } +} diff --git a/compass_app/app/lib/ui/booking/components/booking_share_component.dart b/compass_app/app/lib/ui/booking/components/booking_share_component.dart new file mode 100644 index 000000000..10f9e6266 --- /dev/null +++ b/compass_app/app/lib/ui/booking/components/booking_share_component.dart @@ -0,0 +1,42 @@ +import 'package:compass_model/model.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../utils/result.dart'; +import '../../core/ui/date_format_start_end.dart'; + +typedef ShareFunction = Future Function(String text); + +/// Component for sharing a booking. +class BookingShareComponent { + BookingShareComponent._(this._share); + + /// Create a [BookingShareComponent] that uses `share_plus` package. + factory BookingShareComponent.withSharePlus() => + BookingShareComponent._(Share.share); + + /// Create a [BookingShareComponent] with a custom share function. + factory BookingShareComponent.custom(ShareFunction share) => + BookingShareComponent._(share); + + final ShareFunction _share; + final _log = Logger('BookingShareComponent'); + + Future> shareBooking(Booking booking) async { + final text = 'Trip to ${booking.destination.name}\n' + 'on ${dateFormatStartEnd(DateTimeRange(start: booking.startDate, end: booking.endDate))}\n' + 'Activities:\n' + '${booking.activity.map((a) => ' - ${a.name}').join('\n')}.'; + + _log.info('Sharing booking: $text'); + try { + await _share(text); + _log.fine('Shared booking'); + return Result.ok(null); + } on Exception catch (error) { + _log.severe('Failed to share booking', error); + return Result.error(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 new file mode 100644 index 000000000..e270ee6cf --- /dev/null +++ b/compass_app/app/lib/ui/booking/view_models/booking_viewmodel.dart @@ -0,0 +1,61 @@ +import 'package:compass_model/model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart'; +import '../../../utils/command.dart'; +import '../../../utils/result.dart'; +import '../components/booking_create_component.dart'; +import '../components/booking_share_component.dart'; + +class BookingViewModel extends ChangeNotifier { + BookingViewModel({ + required BookingCreateComponent bookingComponent, + required BookingShareComponent shareComponent, + required ItineraryConfigRepository itineraryConfigRepository, + }) : _createComponent = bookingComponent, + _shareComponent = shareComponent, + _itineraryConfigRepository = itineraryConfigRepository { + loadBooking = Command0(_loadBooking)..execute(); + shareBooking = Command0(() => _shareComponent.shareBooking(_booking!)); + } + + final BookingCreateComponent _createComponent; + final BookingShareComponent _shareComponent; + final ItineraryConfigRepository _itineraryConfigRepository; + final _log = Logger('BookingViewModel'); + Booking? _booking; + + Booking? get booking => _booking; + + late final Command0 loadBooking; + + /// Share the current booking using the OS share dialog. + late final Command0 shareBooking; + + Future> _loadBooking() async { + _log.fine('Loading booking'); + final itineraryConfig = + await _itineraryConfigRepository.getItineraryConfig(); + switch (itineraryConfig) { + case Ok(): + _log.fine('Loaded stored ItineraryConfig'); + final result = await _createComponent.createFrom(itineraryConfig.value); + switch (result) { + case Ok(): + _log.fine('Created Booking'); + _booking = result.value; + notifyListeners(); + return Result.ok(null); + case Error(): + _log.warning('Booking error: ${result.error}'); + notifyListeners(); + return Result.error(result.asError.error); + } + case Error(): + _log.warning('ItineraryConfig error: ${itineraryConfig.error}'); + notifyListeners(); + return Result.error(itineraryConfig.error); + } + } +} diff --git a/compass_app/app/lib/ui/booking/widgets/booking_body.dart b/compass_app/app/lib/ui/booking/widgets/booking_body.dart new file mode 100644 index 000000000..fb73779a3 --- /dev/null +++ b/compass_app/app/lib/ui/booking/widgets/booking_body.dart @@ -0,0 +1,98 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter/material.dart'; + +import '../../core/themes/dimens.dart'; +import '../view_models/booking_viewmodel.dart'; +import 'booking_header.dart'; + +class BookingBody extends StatelessWidget { + const BookingBody({ + super.key, + required this.viewModel, + }); + + final BookingViewModel viewModel; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + final booking = viewModel.booking; + if (booking == null) return const SizedBox(); + return CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: BookingHeader(booking: booking)), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final activity = booking.activity[index]; + return _Activity(activity: activity); + }, + childCount: booking.activity.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 200)), + ], + ); + }, + ); + } +} + +class _Activity extends StatelessWidget { + const _Activity({ + required this.activity, + }); + + final Activity activity; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + top: Dimens.paddingVertical, + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: activity.imageUrl, + height: 80, + width: 80, + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + activity.timeOfDay.name.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall, + ), + Text( + activity.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + activity.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/compass_app/app/lib/ui/booking/widgets/booking_header.dart b/compass_app/app/lib/ui/booking/widgets/booking_header.dart new file mode 100644 index 000000000..89bacdff1 --- /dev/null +++ b/compass_app/app/lib/ui/booking/widgets/booking_header.dart @@ -0,0 +1,198 @@ +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 '../../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'; + +class BookingHeader extends StatelessWidget { + const BookingHeader({ + super.key, + required this.booking, + }); + + final Booking booking; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Top(booking: booking), + Padding( + padding: Dimens.of(context).edgeInsetsScreenHorizontal, + child: Text( + booking.destination.knownFor, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + const SizedBox(height: Dimens.paddingVertical), + _Tags(booking: booking), + const SizedBox(height: Dimens.paddingVertical), + Padding( + padding: Dimens.of(context).edgeInsetsScreenHorizontal, + child: Text( + AppLocalization.of(context).yourChosenActivities, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ); + } +} + +class _Top extends StatelessWidget { + const _Top({ + required this.booking, + }); + + final Booking booking; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 260, + child: Stack( + fit: StackFit.expand, + children: [ + _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, + child: const SafeArea( + top: true, + child: HomeButton(blur: true), + ), + ), + ], + ), + ); + } +} + +class _Tags extends StatelessWidget { + const _Tags({ + required this.booking, + }); + + final Booking booking; + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + final chipColor = switch (brightness) { + Brightness.dark => AppColors.whiteTransparent, + Brightness.light => AppColors.blackTransparent, + }; + return Padding( + padding: Dimens.of(context).edgeInsetsScreenHorizontal, + child: Wrap( + spacing: 6, + runSpacing: 6, + children: booking.destination.tags + .map( + (tag) => TagChip( + tag: tag, + fontSize: 16, + height: 32, + chipColor: chipColor, + onChipColor: Theme.of(context).colorScheme.onSurface, + ), + ) + .toList(), + ), + ); + } +} + +class _Headline extends StatelessWidget { + const _Headline({ + required this.booking, + }); + + final Booking booking; + + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.bottomStart, + child: Padding( + padding: Dimens.of(context).edgeInsetsScreenSymmetric, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + booking.destination.name, + style: Theme.of(context).textTheme.headlineLarge, + ), + Text( + dateFormatStartEnd( + DateTimeRange( + start: booking.startDate, + end: booking.endDate, + ), + ), + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + ), + ); + } +} + +class _HeaderImage extends StatelessWidget { + const _HeaderImage({ + required this.booking, + }); + + final Booking booking; + + @override + Widget build(BuildContext context) { + return CachedNetworkImage( + fit: BoxFit.fitWidth, + imageUrl: booking.destination.imageUrl, + ); + } +} + +class _Gradient extends StatelessWidget { + const _Gradient(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Theme.of(context).colorScheme.surface, + ], + ), + ), + ); + } +} diff --git a/compass_app/app/lib/ui/booking/widgets/booking_screen.dart b/compass_app/app/lib/ui/booking/widgets/booking_screen.dart new file mode 100644 index 000000000..dda5c04a1 --- /dev/null +++ b/compass_app/app/lib/ui/booking/widgets/booking_screen.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.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 { + const BookingScreen({ + super.key, + required this.viewModel, + }); + + final BookingViewModel viewModel; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (d, r) => context.go('/activities'), + child: Scaffold( + body: ListenableBuilder( + listenable: viewModel.loadBooking, + builder: (context, child) { + if (viewModel.loadBooking.running) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (viewModel.loadBooking.error) { + return Center( + child: ErrorIndicator( + title: AppLocalization.of(context).errorWhileLoadingBooking, + label: AppLocalization.of(context).tryAgain, + onPressed: viewModel.loadBooking.execute, + ), + ); + } + return child!; + }, + child: Stack( + children: [ + BookingBody(viewModel: viewModel), + BookingShareButton(viewModel: viewModel), + ], + ), + ), + ), + ); + } +} 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 new file mode 100644 index 000000000..d38bb552e --- /dev/null +++ b/compass_app/app/lib/ui/booking/widgets/booking_share_button.dart @@ -0,0 +1,50 @@ +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 88a715d74..ae41e20f1 100644 --- a/compass_app/app/lib/ui/core/localization/applocalization.dart +++ b/compass_app/app/lib/ui/core/localization/applocalization.dart @@ -14,6 +14,7 @@ class AppLocalization { 'confirm': 'Confirm', 'daytime': 'Daytime', 'errorWhileLoadingActivities': 'Error while loading activities', + 'errorWhileLoadingBooking': 'Error while loading booking', 'errorWhileLoadingContinents': 'Error while loading continents', 'errorWhileLoadingDestinations': 'Error while loading destinations', 'errorWhileSavingActivities': 'Error while saving activities', @@ -22,7 +23,9 @@ class AppLocalization { 'search': 'Search', 'searchDestination': 'Search destination', 'selected': '{1} selected', + 'shareTrip': 'Share Trip', 'tryAgain': 'Try again', + 'yourChosenActivities': 'Your chosen activities', 'when': 'When', }; @@ -40,6 +43,8 @@ class AppLocalization { String get errorWhileLoadingActivities => _get('errorWhileLoadingActivities'); + String get errorWhileLoadingBooking => _get('errorWhileLoadingBooking'); + String get errorWhileLoadingContinents => _get('errorWhileLoadingContinents'); String get errorWhileLoadingDestinations => @@ -55,8 +60,12 @@ class AppLocalization { String get searchDestination => _get('searchDestination'); + String get shareTrip => _get('shareTrip'); + String get tryAgain => _get('tryAgain'); + String get yourChosenActivities => _get('yourChosenActivities'); + String get when => _get('when'); String selected(int value) => diff --git a/compass_app/app/lib/ui/core/themes/theme.dart b/compass_app/app/lib/ui/core/themes/theme.dart index 98ad13d9c..4dd9777d7 100644 --- a/compass_app/app/lib/ui/core/themes/theme.dart +++ b/compass_app/app/lib/ui/core/themes/theme.dart @@ -4,17 +4,30 @@ import 'package:flutter/material.dart'; class AppTheme { static const _textTheme = TextTheme( + headlineLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w500, + ), + headlineSmall: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w400, + ), titleMedium: TextStyle( fontSize: 18, fontWeight: FontWeight.w500, ), + bodyLarge: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w400, + ), bodyMedium: TextStyle( fontSize: 16, fontWeight: FontWeight.w400, ), - bodyLarge: TextStyle( - fontSize: 18, + bodySmall: TextStyle( + fontSize: 14, fontWeight: FontWeight.w400, + color: AppColors.grey3, ), labelSmall: TextStyle( fontSize: 10, diff --git a/compass_app/app/lib/ui/core/ui/back_button.dart b/compass_app/app/lib/ui/core/ui/back_button.dart index 10d1f2023..5a6351e20 100644 --- a/compass_app/app/lib/ui/core/ui/back_button.dart +++ b/compass_app/app/lib/ui/core/ui/back_button.dart @@ -2,14 +2,17 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../themes/colors.dart'; +import 'blur_filter.dart'; /// Custom back button to pop navigation. class CustomBackButton extends StatelessWidget { const CustomBackButton({ super.key, this.onTap, + this.blur = false, }); + final bool blur; final GestureTapCallback? onTap; @override @@ -17,28 +20,39 @@ class CustomBackButton extends StatelessWidget { return SizedBox( height: 40.0, width: 40.0, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey1), - borderRadius: BorderRadius.circular(8.0), - ), - child: InkWell( - borderRadius: BorderRadius.circular(8.0), - onTap: () { - if (onTap != null) { - onTap!(); - } else { - context.pop(); - } - }, - child: Center( - child: Icon( - size: 24.0, - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, + child: Stack( + children: [ + if (blur) + ClipRect( + child: BackdropFilter( + filter: kBlurFilter, + child: const SizedBox(height: 40.0, width: 40.0), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(8.0), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8.0), + onTap: () { + if (onTap != null) { + onTap!(); + } else { + context.pop(); + } + }, + child: Center( + child: Icon( + size: 24.0, + Icons.arrow_back, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ), - ), + ], ), ); } diff --git a/compass_app/app/lib/ui/core/ui/blur_filter.dart b/compass_app/app/lib/ui/core/ui/blur_filter.dart new file mode 100644 index 000000000..d7fd66cfd --- /dev/null +++ b/compass_app/app/lib/ui/core/ui/blur_filter.dart @@ -0,0 +1,3 @@ +import 'dart:ui'; + +final kBlurFilter = ImageFilter.blur(sigmaX: 2, sigmaY: 2); 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 912b0c4ce..290b605e4 100644 --- a/compass_app/app/lib/ui/core/ui/home_button.dart +++ b/compass_app/app/lib/ui/core/ui/home_button.dart @@ -2,34 +2,53 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../themes/colors.dart'; +import 'blur_filter.dart'; /// Home button to navigate back to the '/' path. class HomeButton extends StatelessWidget { - const HomeButton({super.key}); + const HomeButton({ + super.key, + this.blur = false, + }); + + final bool blur; @override Widget build(BuildContext context) { return SizedBox( height: 40.0, width: 40.0, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey1), - borderRadius: BorderRadius.circular(8.0), - ), - child: InkWell( - borderRadius: BorderRadius.circular(8.0), - onTap: () { - context.go('/'); - }, - child: Center( - child: Icon( - size: 24.0, - Icons.home_outlined, - color: Theme.of(context).colorScheme.onSurface, + child: Stack( + fit: StackFit.expand, + children: [ + if (blur) + ClipRect( + child: BackdropFilter( + filter: kBlurFilter, + child: const SizedBox(height: 40.0, width: 40.0), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(8.0), + color: Colors.transparent, + ), + child: InkWell( + borderRadius: BorderRadius.circular(8.0), + onTap: () { + context.go('/'); + }, + child: Center( + child: Icon( + size: 24.0, + Icons.home_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ), - ), + ], ), ); } diff --git a/compass_app/app/lib/ui/core/ui/tag_chip.dart b/compass_app/app/lib/ui/core/ui/tag_chip.dart index ef084c9af..ba89e09ad 100644 --- a/compass_app/app/lib/ui/core/ui/tag_chip.dart +++ b/compass_app/app/lib/ui/core/ui/tag_chip.dart @@ -8,23 +8,33 @@ class TagChip extends StatelessWidget { const TagChip({ super.key, required this.tag, + this.fontSize = 10, + this.height = 20, + this.chipColor, + this.onChipColor, }); final String tag; + final double fontSize; + final double height; + final Color? chipColor; + final Color? onChipColor; + @override Widget build(BuildContext context) { return ClipRRect( - borderRadius: BorderRadius.circular(10.0), + borderRadius: BorderRadius.circular(height / 2), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), child: DecoratedBox( decoration: BoxDecoration( - color: Theme.of(context).extension()?.chipColor ?? + color: chipColor ?? + Theme.of(context).extension()?.chipColor ?? AppColors.whiteTransparent, ), child: SizedBox( - height: 20.0, + height: height, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Row( @@ -33,10 +43,12 @@ class TagChip extends StatelessWidget { children: [ Icon( _iconFrom(tag), - color: Theme.of(context) - .extension() - ?.onChipColor, - size: 10, + color: onChipColor ?? + Theme.of(context) + .extension() + ?.onChipColor ?? + Colors.white, + size: fontSize, ), const SizedBox(width: 4), Text( @@ -83,8 +95,9 @@ class TagChip extends StatelessWidget { _textStyle(BuildContext context) => GoogleFonts.openSans( textStyle: TextStyle( fontWeight: FontWeight.w500, - fontSize: 10, - color: Theme.of(context).extension()?.onChipColor ?? + fontSize: fontSize, + color: onChipColor ?? + Theme.of(context).extension()?.onChipColor ?? Colors.white, textBaseline: TextBaseline.alphabetic, ), diff --git a/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart b/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart index 3b285673c..ece6dd944 100644 --- a/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart +++ b/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart @@ -90,8 +90,11 @@ class ResultsViewModel extends ChangeNotifier { } final itineraryConfig = resultConfig.asOk.value; - final result = await _itineraryConfigRepository.setItineraryConfig( - itineraryConfig.copyWith(destination: destinationRef)); + final result = await _itineraryConfigRepository + .setItineraryConfig(itineraryConfig.copyWith( + destination: destinationRef, + activities: [], + )); if (result is Error) { _log.warning( 'Failed to store ItineraryConfig', 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 b2e6aa71e..2e270b18b 100644 --- a/compass_app/app/lib/ui/results/widgets/results_screen.dart +++ b/compass_app/app/lib/ui/results/widgets/results_screen.dart @@ -115,17 +115,21 @@ class _AppSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - top: Dimens.of(context).paddingScreenVertical, - bottom: Dimens.dimensMobile.paddingScreenVertical, - ), - child: AppSearchBar( - config: widget.viewModel.config, - onTap: () { - // Navigate to SearchFormScreen and edit search - context.go('/'); - }, + return SafeArea( + top: true, + bottom: false, + child: Padding( + padding: EdgeInsets.only( + top: Dimens.of(context).paddingScreenVertical, + bottom: Dimens.dimensMobile.paddingScreenVertical, + ), + child: AppSearchBar( + config: widget.viewModel.config, + onTap: () { + // Navigate to SearchFormScreen and edit search + context.pop(); + }, + ), ), ); } 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 42f4f39c5..a2101f1fa 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 @@ -28,14 +28,18 @@ class SearchFormScreen extends StatelessWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: EdgeInsets.only( - top: Dimens.of(context).paddingScreenVertical, - left: Dimens.of(context).paddingScreenHorizontal, - right: Dimens.of(context).paddingScreenHorizontal, - bottom: Dimens.paddingVertical, + 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(), ), SearchFormContinent(viewModel: viewModel), SearchFormDate(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 b97a526bc..de026e366 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 @@ -32,9 +32,9 @@ class _SearchFormSubmitState extends State { @override void didUpdateWidget(covariant SearchFormSubmit oldWidget) { - super.didUpdateWidget(oldWidget); oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult); widget.viewModel.updateItineraryConfig.addListener(_onResult); + super.didUpdateWidget(oldWidget); } @override diff --git a/compass_app/app/linux/flutter/generated_plugin_registrant.cc b/compass_app/app/linux/flutter/generated_plugin_registrant.cc index e71a16d23..f6f23bfe9 100644 --- a/compass_app/app/linux/flutter/generated_plugin_registrant.cc +++ b/compass_app/app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/compass_app/app/linux/flutter/generated_plugins.cmake b/compass_app/app/linux/flutter/generated_plugins.cmake index 2e1de87a7..f16b4c342 100644 --- a/compass_app/app/linux/flutter/generated_plugins.cmake +++ b/compass_app/app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift b/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift index 2bfe7e4f1..9ac62d532 100644 --- a/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/compass_app/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import path_provider_foundation +import share_plus import sqflite func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/compass_app/app/pubspec.yaml b/compass_app/app/pubspec.yaml index f922c1e20..53ed1fa58 100644 --- a/compass_app/app/pubspec.yaml +++ b/compass_app/app/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: intl: any logging: ^1.2.0 provider: ^6.1.2 + share_plus: ^7.2.2 dev_dependencies: flutter_test: diff --git a/compass_app/app/test/data/repositories/activity/activity_repository_remote_test.dart b/compass_app/app/test/data/repositories/activity/activity_repository_remote_test.dart index 19d0daa28..fe03b17bb 100644 --- a/compass_app/app/test/data/repositories/activity/activity_repository_remote_test.dart +++ b/compass_app/app/test/data/repositories/activity/activity_repository_remote_test.dart @@ -3,7 +3,7 @@ import 'package:compass_app/data/repositories/activity/activity_repository_remot import 'package:compass_app/utils/result.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../../../util/fakes/services/fake_api_client.dart'; +import '../../../../testing/fakes/services/fake_api_client.dart'; void main() { group('ActivityRepositoryRemote tests', () { diff --git a/compass_app/app/test/data/repositories/continent/continent_repository_remote_test.dart b/compass_app/app/test/data/repositories/continent/continent_repository_remote_test.dart index 047c388aa..0b182ef48 100644 --- a/compass_app/app/test/data/repositories/continent/continent_repository_remote_test.dart +++ b/compass_app/app/test/data/repositories/continent/continent_repository_remote_test.dart @@ -3,7 +3,7 @@ import 'package:compass_app/data/repositories/continent/continent_repository_rem import 'package:compass_app/utils/result.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../../../util/fakes/services/fake_api_client.dart'; +import '../../../../testing/fakes/services/fake_api_client.dart'; void main() { group('ContinentRepositoryRemote tests', () { diff --git a/compass_app/app/test/data/repositories/destination/destination_repository_remote_test.dart b/compass_app/app/test/data/repositories/destination/destination_repository_remote_test.dart index f630da4ec..990c5190f 100644 --- a/compass_app/app/test/data/repositories/destination/destination_repository_remote_test.dart +++ b/compass_app/app/test/data/repositories/destination/destination_repository_remote_test.dart @@ -3,7 +3,7 @@ import 'package:compass_app/data/repositories/destination/destination_repository import 'package:compass_app/utils/result.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../../../util/fakes/services/fake_api_client.dart'; +import '../../../../testing/fakes/services/fake_api_client.dart'; void main() { group('DestinationRepositoryRemote tests', () { diff --git a/compass_app/app/test/ui/activities/activities_screen_test.dart b/compass_app/app/test/ui/activities/activities_screen_test.dart index 3d17abc52..ab13b0ef4 100644 --- a/compass_app/app/test/ui/activities/activities_screen_test.dart +++ b/compass_app/app/test/ui/activities/activities_screen_test.dart @@ -1,18 +1,21 @@ import 'package:compass_app/ui/activities/view_models/activities_viewmodel.dart'; import 'package:compass_app/ui/activities/widgets/activities_screen.dart'; import 'package:compass_app/ui/activities/widgets/activity_entry.dart'; -import 'package:compass_app/ui/core/themes/theme.dart'; import 'package:compass_model/model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:mocktail_image_network/mocktail_image_network.dart'; -import '../../util/fakes/repositories/fake_activities_repository.dart'; -import '../../util/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../testing/app.dart'; +import '../../../testing/fakes/repositories/fake_activities_repository.dart'; +import '../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../testing/mocks.dart'; void main() { group('ResultsScreen widget tests', () { late ActivitiesViewModel viewModel; + late MockGoRouter goRouter; setUp(() { viewModel = ActivitiesViewModel( @@ -27,18 +30,14 @@ void main() { ), ), ); + goRouter = MockGoRouter(); }); - // Build and render the ResultsScreen widget Future loadScreen(WidgetTester tester) async { - // Load some data - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.lightTheme, - home: ActivitiesScreen( - viewModel: viewModel, - ), - ), + await testApp( + tester, + ActivitiesScreen(viewModel: viewModel), + goRouter: goRouter, ); } @@ -57,11 +56,23 @@ void main() { }); }); - testWidgets('should select activity', (WidgetTester tester) async { + testWidgets('should select activity and confirm', + (WidgetTester tester) async { await mockNetworkImages(() async { await loadScreen(tester); + // Select one activity await tester.tap(find.byKey(const ValueKey('REF-checkbox'))); expect(viewModel.selectedActivities, contains('REF')); + + // Text 1 selected should appear + await tester.pumpAndSettle(); + expect(find.text('1 selected'), findsOneWidget); + + // Submit selection + await tester.tap(find.byKey(const ValueKey('confirm-button'))); + + // Should navigate to results screen + verify(() => goRouter.go('/booking')).called(1); }); }); }); diff --git a/compass_app/app/test/ui/booking/booking_create_component_test.dart b/compass_app/app/test/ui/booking/booking_create_component_test.dart new file mode 100644 index 000000000..f189180ae --- /dev/null +++ b/compass_app/app/test/ui/booking/booking_create_component_test.dart @@ -0,0 +1,31 @@ +import 'package:compass_app/ui/booking/components/booking_create_component.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../testing/fakes/repositories/fake_activities_repository.dart'; +import '../../../testing/fakes/repositories/fake_destination_repository.dart'; +import '../../../testing/models/activity.dart'; +import '../../../testing/models/booking.dart'; +import '../../../testing/models/destination.dart'; + +void main() { + group('BookingCreateComponent tests', () { + test('Create booking', () async { + final component = BookingCreateComponent( + activityRepository: FakeActivityRepository(), + destinationRepository: FakeDestinationRepository(), + ); + + final booking = await component.createFrom( + ItineraryConfig( + startDate: DateTime(2024, 01, 01), + endDate: DateTime(2024, 02, 12), + destination: kDestination1.ref, + activities: [kActivity.ref], + ), + ); + + expect(booking.asOk.value, kBooking); + }); + }); +} diff --git a/compass_app/app/test/ui/booking/booking_screen_test.dart b/compass_app/app/test/ui/booking/booking_screen_test.dart new file mode 100644 index 000000000..56f0c9a17 --- /dev/null +++ b/compass_app/app/test/ui/booking/booking_screen_test.dart @@ -0,0 +1,77 @@ +import 'package:compass_app/ui/booking/components/booking_create_component.dart'; +import 'package:compass_app/ui/booking/components/booking_share_component.dart'; +import 'package:compass_app/ui/booking/view_models/booking_viewmodel.dart'; +import 'package:compass_app/ui/booking/widgets/booking_screen.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../testing/app.dart'; +import '../../../testing/fakes/repositories/fake_activities_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/destination.dart'; + +void main() { + group('BookingScreen widget tests', () { + late MockGoRouter goRouter; + late BookingViewModel viewModel; + late bool shared; + + setUp(() { + shared = false; + viewModel = BookingViewModel( + itineraryConfigRepository: FakeItineraryConfigRepository( + itineraryConfig: ItineraryConfig( + continent: 'Europe', + startDate: DateTime(2024, 01, 01), + endDate: DateTime(2024, 01, 31), + guests: 2, + destination: kDestination1.ref, + activities: [kActivity.ref], + ), + ), + bookingComponent: BookingCreateComponent( + activityRepository: FakeActivityRepository(), + destinationRepository: FakeDestinationRepository(), + ), + shareComponent: BookingShareComponent.custom((text) async { + shared = true; + }), + ); + goRouter = MockGoRouter(); + }); + + Future loadScreen(WidgetTester tester) async { + await testApp( + tester, + BookingScreen(viewModel: viewModel), + goRouter: goRouter, + ); + } + + testWidgets('should load screen', (WidgetTester tester) async { + await loadScreen(tester); + expect(find.byType(BookingScreen), findsOneWidget); + }); + + testWidgets('should display booking', (WidgetTester tester) async { + await loadScreen(tester); + + // Wait for list to load + await tester.pumpAndSettle(); + + expect(find.text('name1'), findsOneWidget); + expect(find.text('tags1'), findsOneWidget); + }); + + testWidgets('should share booking', (WidgetTester tester) async { + await loadScreen(tester); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('share-button'))); + expect(shared, true); + }); + }); +} diff --git a/compass_app/app/test/ui/booking/booking_share_component_test.dart b/compass_app/app/test/ui/booking/booking_share_component_test.dart new file mode 100644 index 000000000..b553f2b80 --- /dev/null +++ b/compass_app/app/test/ui/booking/booking_share_component_test.dart @@ -0,0 +1,31 @@ +import 'package:compass_app/ui/booking/components/booking_share_component.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../testing/models/activity.dart'; +import '../../../testing/models/destination.dart'; + +void main() { + group('BookingShareComponent tests', () { + test('Share booking', () async { + String? sharedText; + final component = BookingShareComponent.custom((text) async { + sharedText = text; + }); + final booking = Booking( + startDate: DateTime(2024, 01, 01), + endDate: DateTime(2024, 02, 12), + destination: kDestination1, + activity: [kActivity], + ); + await component.shareBooking(booking); + expect( + sharedText, + 'Trip to name1\n' + 'on 1 Jan - 12 Feb\n' + 'Activities:\n' + ' - NAME.', + ); + }); + }); +} diff --git a/compass_app/app/test/ui/results/results_screen_test.dart b/compass_app/app/test/ui/results/results_screen_test.dart index 8904e554d..8d7390517 100644 --- a/compass_app/app/test/ui/results/results_screen_test.dart +++ b/compass_app/app/test/ui/results/results_screen_test.dart @@ -2,14 +2,13 @@ import 'package:compass_app/ui/results/view_models/results_viewmodel.dart'; import 'package:compass_app/ui/results/widgets/results_screen.dart'; import 'package:compass_model/model.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:mocktail_image_network/mocktail_image_network.dart'; -import '../../util/fakes/repositories/fake_destination_repository.dart'; -import '../../util/fakes/repositories/fake_itinerary_config_repository.dart'; -import '../../util/mocks.dart'; +import '../../../testing/app.dart'; +import '../../../testing/fakes/repositories/fake_destination_repository.dart'; +import '../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../testing/mocks.dart'; void main() { group('ResultsScreen widget tests', () { @@ -31,18 +30,11 @@ void main() { goRouter = MockGoRouter(); }); - // Build and render the ResultsScreen widget Future loadScreen(WidgetTester tester) async { - // Load some data - await tester.pumpWidget( - MaterialApp( - home: InheritedGoRouter( - goRouter: goRouter, - child: ResultsScreen( - viewModel: viewModel, - ), - ), - ), + await testApp( + tester, + ResultsScreen(viewModel: viewModel), + goRouter: goRouter, ); } diff --git a/compass_app/app/test/ui/results/results_viewmodel_test.dart b/compass_app/app/test/ui/results/results_viewmodel_test.dart index e3223e242..910a23a4e 100644 --- a/compass_app/app/test/ui/results/results_viewmodel_test.dart +++ b/compass_app/app/test/ui/results/results_viewmodel_test.dart @@ -2,8 +2,8 @@ import 'package:compass_app/ui/results/view_models/results_viewmodel.dart'; import 'package:compass_model/model.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../../util/fakes/repositories/fake_destination_repository.dart'; -import '../../util/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../testing/fakes/repositories/fake_destination_repository.dart'; +import '../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; void main() { group('ResultsViewModel tests', () { diff --git a/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart b/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart index 77a0a208c..e88865be3 100644 --- a/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart +++ b/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; -import '../../../util/fakes/repositories/fake_continent_repository.dart'; -import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; void main() { group('SearchFormViewModel Tests', () { diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart index 3daab7d18..5b03b6947 100644 --- a/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart +++ b/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart @@ -1,11 +1,10 @@ import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; import 'package:compass_app/ui/search_form/widgets/search_form_continent.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail_image_network/mocktail_image_network.dart'; -import '../../../util/fakes/repositories/fake_continent_repository.dart'; -import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../../testing/app.dart'; +import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; void main() { group('SearchFormContinent widget tests', () { @@ -19,17 +18,7 @@ void main() { }); loadWidget(WidgetTester tester) async { - await mockNetworkImages(() async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SearchFormContinent( - viewModel: viewModel, - ), - ), - ), - ); - }); + await testApp(tester, SearchFormContinent(viewModel: viewModel)); } testWidgets('Should load and select continent', diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart index b5c0899c5..3a479d8d3 100644 --- a/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart +++ b/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart @@ -3,8 +3,9 @@ import 'package:compass_app/ui/search_form/widgets/search_form_date.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; -import '../../../util/fakes/repositories/fake_continent_repository.dart'; -import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../../testing/app.dart'; +import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; void main() { group('SearchFormDate widget tests', () { @@ -18,15 +19,7 @@ void main() { }); loadWidget(WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SearchFormDate( - viewModel: viewModel, - ), - ), - ), - ); + await testApp(tester, SearchFormDate(viewModel: viewModel)); } testWidgets('should display date in different month', diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart index 5d962abb3..9fea4687f 100644 --- a/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart +++ b/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart @@ -3,8 +3,9 @@ import 'package:compass_app/ui/search_form/widgets/search_form_guests.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; -import '../../../util/fakes/repositories/fake_continent_repository.dart'; -import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../../testing/app.dart'; +import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; void main() { group('SearchFormGuests widget tests', () { @@ -18,15 +19,7 @@ void main() { }); loadWidget(WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SearchFormGuests( - viewModel: viewModel, - ), - ), - ), - ); + await testApp(tester, SearchFormGuests(viewModel: viewModel)); } testWidgets('Increase number of guests', (WidgetTester tester) async { diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart index 7e0562735..971e62008 100644 --- a/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart +++ b/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart @@ -2,13 +2,12 @@ import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dar import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:mocktail_image_network/mocktail_image_network.dart'; -import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart'; -import '../../../util/mocks.dart'; -import '../../../util/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/app.dart'; +import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../../testing/mocks.dart'; void main() { group('SearchFormScreen widget tests', () { @@ -24,20 +23,11 @@ void main() { }); loadWidget(WidgetTester tester) async { - await mockNetworkImages(() async { - await tester.pumpWidget( - MaterialApp( - home: InheritedGoRouter( - goRouter: goRouter, - child: Scaffold( - body: SearchFormScreen( - viewModel: viewModel, - ), - ), - ), - ), - ); - }); + await testApp( + tester, + SearchFormScreen(viewModel: viewModel), + goRouter: goRouter, + ); } testWidgets('Should fill form and perform search', diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart index a0bd8e3a8..13b853bbf 100644 --- a/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart +++ b/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart @@ -2,12 +2,12 @@ import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dar import 'package:compass_app/ui/search_form/widgets/search_form_submit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; -import '../../../util/fakes/repositories/fake_continent_repository.dart'; -import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart'; -import '../../../util/mocks.dart'; +import '../../../../testing/app.dart'; +import '../../../../testing/fakes/repositories/fake_continent_repository.dart'; +import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart'; +import '../../../../testing/mocks.dart'; void main() { group('SearchFormSubmit widget tests', () { @@ -23,17 +23,10 @@ void main() { }); loadWidget(WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: InheritedGoRouter( - goRouter: goRouter, - child: Scaffold( - body: SearchFormSubmit( - viewModel: viewModel, - ), - ), - ), - ), + await testApp( + tester, + SearchFormSubmit(viewModel: viewModel), + goRouter: goRouter, ); } diff --git a/compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart b/compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart deleted file mode 100644 index 969d789ef..000000000 --- a/compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:compass_app/data/repositories/activity/activity_repository.dart'; -import 'package:compass_app/utils/result.dart'; -import 'package:compass_model/src/model/activity/activity.dart'; - -class FakeActivityRepository implements ActivityRepository { - Map> activities = { - "DESTINATION": [ - const Activity( - description: 'DESCRIPTION', - destinationRef: 'DESTINATION', - duration: 3, - familyFriendly: true, - imageUrl: 'http://example.com/img.png', - locationName: 'LOCATION NAME', - name: 'NAME', - price: 3, - ref: 'REF', - timeOfDay: TimeOfDay.afternoon, - ), - ], - }; - - @override - Future>> getByDestination(String ref) async { - return Result.ok(activities[ref]!); - } -} diff --git a/compass_app/app/test/util/fakes/repositories/fake_destination_repository.dart b/compass_app/app/test/util/fakes/repositories/fake_destination_repository.dart deleted file mode 100644 index c6d57e3de..000000000 --- a/compass_app/app/test/util/fakes/repositories/fake_destination_repository.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:compass_model/model.dart'; -import 'package:compass_app/data/repositories/destination/destination_repository.dart'; -import 'package:compass_app/utils/result.dart'; - -// TODO: Move to a better place -class FakeDestinationRepository implements DestinationRepository { - @override - Future>> getDestinations() { - return Future.value( - Result.ok( - [ - const Destination( - ref: 'ref1', - name: 'name1', - country: 'country1', - continent: 'Europe', - knownFor: 'knownFor1', - tags: ['tags1'], - imageUrl: 'imageUrl1', - ), - const Destination( - ref: 'ref2', - name: 'name2', - country: 'country2', - continent: 'Europe', - knownFor: 'knownFor2', - tags: ['tags2'], - imageUrl: 'imageUrl2', - ), - ], - ), - ); - } -} diff --git a/compass_app/app/testing/app.dart b/compass_app/app/testing/app.dart new file mode 100644 index 000000000..9ec6b262c --- /dev/null +++ b/compass_app/app/testing/app.dart @@ -0,0 +1,36 @@ +import 'package:compass_app/ui/core/localization/applocalization.dart'; +import 'package:compass_app/ui/core/themes/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; + +import 'mocks.dart'; + +testApp( + WidgetTester tester, + Widget body, { + GoRouter? goRouter, +}) async { + tester.view.devicePixelRatio = 1.0; + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await mockNetworkImages(() async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: [ + GlobalWidgetsLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + AppLocalizationDelegate(), + ], + theme: AppTheme.lightTheme, + home: InheritedGoRouter( + goRouter: goRouter ?? MockGoRouter(), + child: Scaffold( + body: body, + ), + ), + ), + ); + }); +} diff --git a/compass_app/app/testing/fakes/repositories/fake_activities_repository.dart b/compass_app/app/testing/fakes/repositories/fake_activities_repository.dart new file mode 100644 index 000000000..60eae5dbd --- /dev/null +++ b/compass_app/app/testing/fakes/repositories/fake_activities_repository.dart @@ -0,0 +1,19 @@ +import 'package:compass_app/data/repositories/activity/activity_repository.dart'; +import 'package:compass_app/utils/result.dart'; +import 'package:compass_model/src/model/activity/activity.dart'; +import 'package:flutter/foundation.dart'; + +import '../../models/activity.dart'; +import '../../models/destination.dart'; + +class FakeActivityRepository implements ActivityRepository { + Map> activities = { + "DESTINATION": [kActivity], + kDestination1.ref: [kActivity], + }; + + @override + Future>> getByDestination(String ref) { + return SynchronousFuture(Result.ok(activities[ref]!)); + } +} diff --git a/compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart b/compass_app/app/testing/fakes/repositories/fake_continent_repository.dart similarity index 75% rename from compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart rename to compass_app/app/testing/fakes/repositories/fake_continent_repository.dart index d971ebe03..1e58c9fe8 100644 --- a/compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart +++ b/compass_app/app/testing/fakes/repositories/fake_continent_repository.dart @@ -1,14 +1,15 @@ import 'package:compass_model/model.dart'; import 'package:compass_app/data/repositories/continent/continent_repository.dart'; import 'package:compass_app/utils/result.dart'; +import 'package:flutter/foundation.dart'; class FakeContinentRepository implements ContinentRepository { @override - Future>> getContinents() async { - return Result.ok([ + Future>> getContinents() { + return SynchronousFuture(Result.ok([ const Continent(name: 'CONTINENT', imageUrl: 'URL'), const Continent(name: 'CONTINENT2', imageUrl: 'URL'), const Continent(name: 'CONTINENT3', imageUrl: 'URL'), - ]); + ])); } } diff --git a/compass_app/app/testing/fakes/repositories/fake_destination_repository.dart b/compass_app/app/testing/fakes/repositories/fake_destination_repository.dart new file mode 100644 index 000000000..89de911d2 --- /dev/null +++ b/compass_app/app/testing/fakes/repositories/fake_destination_repository.dart @@ -0,0 +1,13 @@ +import 'package:compass_model/model.dart'; +import 'package:compass_app/data/repositories/destination/destination_repository.dart'; +import 'package:compass_app/utils/result.dart'; +import 'package:flutter/foundation.dart'; + +import '../../models/destination.dart'; + +class FakeDestinationRepository implements DestinationRepository { + @override + Future>> getDestinations() { + return SynchronousFuture(Result.ok([kDestination1, kDestination2])); + } +} diff --git a/compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart b/compass_app/app/testing/fakes/repositories/fake_itinerary_config_repository.dart similarity index 60% rename from compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart rename to compass_app/app/testing/fakes/repositories/fake_itinerary_config_repository.dart index f01f23bc3..54e48d0f6 100644 --- a/compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart +++ b/compass_app/app/testing/fakes/repositories/fake_itinerary_config_repository.dart @@ -1,6 +1,7 @@ import 'package:compass_app/data/repositories/itinerary_config/itinerary_config_repository.dart'; import 'package:compass_app/utils/result.dart'; import 'package:compass_model/src/model/itinerary_config/itinerary_config.dart'; +import 'package:flutter/foundation.dart'; class FakeItineraryConfigRepository implements ItineraryConfigRepository { FakeItineraryConfigRepository({this.itineraryConfig}); @@ -8,14 +9,14 @@ class FakeItineraryConfigRepository implements ItineraryConfigRepository { ItineraryConfig? itineraryConfig; @override - Future> getItineraryConfig() async { - return Result.ok(itineraryConfig ?? const ItineraryConfig()); + Future> getItineraryConfig() { + return SynchronousFuture( + Result.ok(itineraryConfig ?? const ItineraryConfig())); } @override - Future> setItineraryConfig( - ItineraryConfig itineraryConfig) async { + Future> setItineraryConfig(ItineraryConfig itineraryConfig) { this.itineraryConfig = itineraryConfig; - return Result.ok(null); + return SynchronousFuture(Result.ok(null)); } } diff --git a/compass_app/app/test/util/fakes/services/fake_api_client.dart b/compass_app/app/testing/fakes/services/fake_api_client.dart similarity index 93% rename from compass_app/app/test/util/fakes/services/fake_api_client.dart rename to compass_app/app/testing/fakes/services/fake_api_client.dart index 1106dd350..7613b0975 100644 --- a/compass_app/app/test/util/fakes/services/fake_api_client.dart +++ b/compass_app/app/testing/fakes/services/fake_api_client.dart @@ -1,6 +1,7 @@ import 'package:compass_app/data/services/api_client.dart'; import 'package:compass_app/utils/result.dart'; import 'package:compass_model/model.dart'; +import 'package:flutter/foundation.dart'; class FakeApiClient implements ApiClient { // Should not increase when using cached data @@ -44,11 +45,11 @@ class FakeApiClient implements ApiClient { } @override - Future>> getActivityByDestination(String ref) async { + Future>> getActivityByDestination(String ref) { requestCount++; if (ref == 'alaska') { - return Result.ok([ + return SynchronousFuture(Result.ok([ const Activity( name: 'Glacier Trekking and Ice Climbing', description: @@ -63,9 +64,9 @@ class FakeApiClient implements ApiClient { imageUrl: 'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg', ), - ]); + ])); } - return Result.ok([]); + return SynchronousFuture(Result.ok([])); } } diff --git a/compass_app/app/test/util/mocks.dart b/compass_app/app/testing/mocks.dart similarity index 100% rename from compass_app/app/test/util/mocks.dart rename to compass_app/app/testing/mocks.dart diff --git a/compass_app/app/testing/models/activity.dart b/compass_app/app/testing/models/activity.dart new file mode 100644 index 000000000..ffb79d8d2 --- /dev/null +++ b/compass_app/app/testing/models/activity.dart @@ -0,0 +1,14 @@ +import 'package:compass_model/model.dart'; + +const kActivity = Activity( + description: 'DESCRIPTION', + destinationRef: 'DESTINATION', + duration: 3, + familyFriendly: true, + imageUrl: 'http://example.com/img.png', + locationName: 'LOCATION NAME', + name: 'NAME', + price: 3, + ref: 'REF', + timeOfDay: TimeOfDay.afternoon, +); diff --git a/compass_app/app/testing/models/booking.dart b/compass_app/app/testing/models/booking.dart new file mode 100644 index 000000000..63a5664e8 --- /dev/null +++ b/compass_app/app/testing/models/booking.dart @@ -0,0 +1,11 @@ +import 'package:compass_model/model.dart'; + +import 'activity.dart'; +import 'destination.dart'; + +final kBooking = Booking( + startDate: DateTime(2024, 01, 01), + endDate: DateTime(2024, 02, 12), + destination: kDestination1, + activity: [kActivity], +); diff --git a/compass_app/app/testing/models/destination.dart b/compass_app/app/testing/models/destination.dart new file mode 100644 index 000000000..d8acd676c --- /dev/null +++ b/compass_app/app/testing/models/destination.dart @@ -0,0 +1,21 @@ +import 'package:compass_model/model.dart'; + +const kDestination1 = Destination( + ref: 'ref1', + name: 'name1', + country: 'country1', + continent: 'Europe', + knownFor: 'knownFor1', + tags: ['tags1'], + imageUrl: 'imageUrl1', +); + +const kDestination2 = Destination( + ref: 'ref2', + name: 'name2', + country: 'country2', + continent: 'Europe', + knownFor: 'knownFor2', + tags: ['tags2'], + imageUrl: 'imageUrl2', +); diff --git a/compass_app/app/windows/flutter/generated_plugin_registrant.cc b/compass_app/app/windows/flutter/generated_plugin_registrant.cc index 8b6d4680a..c3384ec52 100644 --- a/compass_app/app/windows/flutter/generated_plugin_registrant.cc +++ b/compass_app/app/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/compass_app/app/windows/flutter/generated_plugins.cmake b/compass_app/app/windows/flutter/generated_plugins.cmake index b93c4c30c..01d383628 100644 --- a/compass_app/app/windows/flutter/generated_plugins.cmake +++ b/compass_app/app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/compass_app/model/lib/model.dart b/compass_app/model/lib/model.dart index f6b71aa98..54578522d 100644 --- a/compass_app/model/lib/model.dart +++ b/compass_app/model/lib/model.dart @@ -1,6 +1,7 @@ library; export 'src/model/activity/activity.dart'; +export 'src/model/booking/booking.dart'; export 'src/model/continent/continent.dart'; export 'src/model/destination/destination.dart'; export 'src/model/itinerary_config/itinerary_config.dart'; diff --git a/compass_app/model/lib/src/model/booking/booking.dart b/compass_app/model/lib/src/model/booking/booking.dart new file mode 100644 index 000000000..b03bde055 --- /dev/null +++ b/compass_app/model/lib/src/model/booking/booking.dart @@ -0,0 +1,25 @@ +import 'package:compass_model/model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'booking.freezed.dart'; +part 'booking.g.dart'; + +@freezed +class Booking with _$Booking { + const factory Booking({ + /// Start date of the trip + required DateTime startDate, + + /// End date of the trip + required DateTime endDate, + + /// Destination of the trip + required Destination destination, + + /// List of chosen activities + required List activity, + }) = _Booking; + + factory Booking.fromJson(Map json) => + _$BookingFromJson(json); +} diff --git a/compass_app/model/lib/src/model/booking/booking.freezed.dart b/compass_app/model/lib/src/model/booking/booking.freezed.dart new file mode 100644 index 000000000..ad28f41a9 --- /dev/null +++ b/compass_app/model/lib/src/model/booking/booking.freezed.dart @@ -0,0 +1,271 @@ +// 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 { + /// Start date of the trip + DateTime get startDate => throw _privateConstructorUsedError; + + /// End date of the trip + DateTime get endDate => throw _privateConstructorUsedError; + + /// Destination of the trip + Destination get destination => throw _privateConstructorUsedError; + + /// List of chosen activities + List get activity => 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( + {DateTime startDate, + DateTime endDate, + Destination destination, + List activity}); + + $DestinationCopyWith<$Res> get destination; +} + +/// @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? startDate = null, + Object? endDate = null, + Object? destination = null, + Object? activity = null, + }) { + return _then(_value.copyWith( + 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, + destination: null == destination + ? _value.destination + : destination // ignore: cast_nullable_to_non_nullable + as Destination, + activity: null == activity + ? _value.activity + : activity // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } + + /// Create a copy of Booking + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $DestinationCopyWith<$Res> get destination { + return $DestinationCopyWith<$Res>(_value.destination, (value) { + return _then(_value.copyWith(destination: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$BookingImplCopyWith<$Res> implements $BookingCopyWith<$Res> { + factory _$$BookingImplCopyWith( + _$BookingImpl value, $Res Function(_$BookingImpl) then) = + __$$BookingImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {DateTime startDate, + DateTime endDate, + Destination destination, + List activity}); + + @override + $DestinationCopyWith<$Res> get destination; +} + +/// @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? startDate = null, + Object? endDate = null, + Object? destination = null, + Object? activity = null, + }) { + return _then(_$BookingImpl( + 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, + destination: null == destination + ? _value.destination + : destination // ignore: cast_nullable_to_non_nullable + as Destination, + activity: null == activity + ? _value._activity + : activity // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$BookingImpl implements _Booking { + const _$BookingImpl( + {required this.startDate, + required this.endDate, + required this.destination, + required final List activity}) + : _activity = activity; + + factory _$BookingImpl.fromJson(Map json) => + _$$BookingImplFromJson(json); + + /// Start date of the trip + @override + final DateTime startDate; + + /// End date of the trip + @override + final DateTime endDate; + + /// Destination of the trip + @override + final Destination destination; + + /// List of chosen activities + final List _activity; + + /// List of chosen activities + @override + List get activity { + if (_activity is EqualUnmodifiableListView) return _activity; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_activity); + } + + @override + String toString() { + return 'Booking(startDate: $startDate, endDate: $endDate, destination: $destination, activity: $activity)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$BookingImpl && + (identical(other.startDate, startDate) || + other.startDate == startDate) && + (identical(other.endDate, endDate) || other.endDate == endDate) && + (identical(other.destination, destination) || + other.destination == destination) && + const DeepCollectionEquality().equals(other._activity, _activity)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, startDate, endDate, destination, + const DeepCollectionEquality().hash(_activity)); + + /// 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( + {required final DateTime startDate, + required final DateTime endDate, + required final Destination destination, + required final List activity}) = _$BookingImpl; + + factory _Booking.fromJson(Map json) = _$BookingImpl.fromJson; + + /// Start date of the trip + @override + DateTime get startDate; + + /// End date of the trip + @override + DateTime get endDate; + + /// Destination of the trip + @override + Destination get destination; + + /// List of chosen activities + @override + List get activity; + + /// 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/model/lib/src/model/booking/booking.g.dart b/compass_app/model/lib/src/model/booking/booking.g.dart new file mode 100644 index 000000000..2cc0ca23f --- /dev/null +++ b/compass_app/model/lib/src/model/booking/booking.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'booking.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$BookingImpl _$$BookingImplFromJson(Map json) => + _$BookingImpl( + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + destination: + Destination.fromJson(json['destination'] as Map), + activity: (json['activity'] as List) + .map((e) => Activity.fromJson(e as Map)) + .toList(), + ); + +Map _$$BookingImplToJson(_$BookingImpl instance) => + { + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + 'destination': instance.destination, + 'activity': instance.activity, + }; diff --git a/compass_app/model/lib/src/model/itinerary_config/itinerary_config.dart b/compass_app/model/lib/src/model/itinerary_config/itinerary_config.dart index da21e0f6e..2c7e77c9e 100644 --- a/compass_app/model/lib/src/model/itinerary_config/itinerary_config.dart +++ b/compass_app/model/lib/src/model/itinerary_config/itinerary_config.dart @@ -21,6 +21,9 @@ class ItineraryConfig with _$ItineraryConfig { /// Selected [Destination] reference String? destination, + + /// Selected [Activity] references + @Default([]) List activities, }) = _ItineraryConfig; factory ItineraryConfig.fromJson(Map json) => diff --git a/compass_app/model/lib/src/model/itinerary_config/itinerary_config.freezed.dart b/compass_app/model/lib/src/model/itinerary_config/itinerary_config.freezed.dart index 515765474..38facb873 100644 --- a/compass_app/model/lib/src/model/itinerary_config/itinerary_config.freezed.dart +++ b/compass_app/model/lib/src/model/itinerary_config/itinerary_config.freezed.dart @@ -35,6 +35,9 @@ mixin _$ItineraryConfig { /// Selected [Destination] reference String? get destination => throw _privateConstructorUsedError; + /// Selected [Activity] references + List get activities => throw _privateConstructorUsedError; + /// Serializes this ItineraryConfig to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -56,7 +59,8 @@ abstract class $ItineraryConfigCopyWith<$Res> { DateTime? startDate, DateTime? endDate, int? guests, - String? destination}); + String? destination, + List activities}); } /// @nodoc @@ -79,6 +83,7 @@ class _$ItineraryConfigCopyWithImpl<$Res, $Val extends ItineraryConfig> Object? endDate = freezed, Object? guests = freezed, Object? destination = freezed, + Object? activities = null, }) { return _then(_value.copyWith( continent: freezed == continent @@ -101,6 +106,10 @@ class _$ItineraryConfigCopyWithImpl<$Res, $Val extends ItineraryConfig> ? _value.destination : destination // ignore: cast_nullable_to_non_nullable as String?, + activities: null == activities + ? _value.activities + : activities // ignore: cast_nullable_to_non_nullable + as List, ) as $Val); } } @@ -118,7 +127,8 @@ abstract class _$$ItineraryConfigImplCopyWith<$Res> DateTime? startDate, DateTime? endDate, int? guests, - String? destination}); + String? destination, + List activities}); } /// @nodoc @@ -139,6 +149,7 @@ class __$$ItineraryConfigImplCopyWithImpl<$Res> Object? endDate = freezed, Object? guests = freezed, Object? destination = freezed, + Object? activities = null, }) { return _then(_$ItineraryConfigImpl( continent: freezed == continent @@ -161,6 +172,10 @@ class __$$ItineraryConfigImplCopyWithImpl<$Res> ? _value.destination : destination // ignore: cast_nullable_to_non_nullable as String?, + activities: null == activities + ? _value._activities + : activities // ignore: cast_nullable_to_non_nullable + as List, )); } } @@ -173,7 +188,9 @@ class _$ItineraryConfigImpl implements _ItineraryConfig { this.startDate, this.endDate, this.guests, - this.destination}); + this.destination, + final List activities = const []}) + : _activities = activities; factory _$ItineraryConfigImpl.fromJson(Map json) => _$$ItineraryConfigImplFromJson(json); @@ -198,9 +215,21 @@ class _$ItineraryConfigImpl implements _ItineraryConfig { @override final String? destination; + /// Selected [Activity] references + final List _activities; + + /// Selected [Activity] references + @override + @JsonKey() + List get activities { + if (_activities is EqualUnmodifiableListView) return _activities; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_activities); + } + @override String toString() { - return 'ItineraryConfig(continent: $continent, startDate: $startDate, endDate: $endDate, guests: $guests, destination: $destination)'; + return 'ItineraryConfig(continent: $continent, startDate: $startDate, endDate: $endDate, guests: $guests, destination: $destination, activities: $activities)'; } @override @@ -215,13 +244,15 @@ class _$ItineraryConfigImpl implements _ItineraryConfig { (identical(other.endDate, endDate) || other.endDate == endDate) && (identical(other.guests, guests) || other.guests == guests) && (identical(other.destination, destination) || - other.destination == destination)); + other.destination == destination) && + const DeepCollectionEquality() + .equals(other._activities, _activities)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, continent, startDate, endDate, guests, destination); + int get hashCode => Object.hash(runtimeType, continent, startDate, endDate, + guests, destination, const DeepCollectionEquality().hash(_activities)); /// Create a copy of ItineraryConfig /// with the given fields replaced by the non-null parameter values. @@ -246,7 +277,8 @@ abstract class _ItineraryConfig implements ItineraryConfig { final DateTime? startDate, final DateTime? endDate, final int? guests, - final String? destination}) = _$ItineraryConfigImpl; + final String? destination, + final List activities}) = _$ItineraryConfigImpl; factory _ItineraryConfig.fromJson(Map json) = _$ItineraryConfigImpl.fromJson; @@ -271,6 +303,10 @@ abstract class _ItineraryConfig implements ItineraryConfig { @override String? get destination; + /// Selected [Activity] references + @override + List get activities; + /// Create a copy of ItineraryConfig /// with the given fields replaced by the non-null parameter values. @override diff --git a/compass_app/model/lib/src/model/itinerary_config/itinerary_config.g.dart b/compass_app/model/lib/src/model/itinerary_config/itinerary_config.g.dart index 065292843..1176e4239 100644 --- a/compass_app/model/lib/src/model/itinerary_config/itinerary_config.g.dart +++ b/compass_app/model/lib/src/model/itinerary_config/itinerary_config.g.dart @@ -18,6 +18,10 @@ _$ItineraryConfigImpl _$$ItineraryConfigImplFromJson( : DateTime.parse(json['endDate'] as String), guests: (json['guests'] as num?)?.toInt(), destination: json['destination'] as String?, + activities: (json['activities'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], ); Map _$$ItineraryConfigImplToJson( @@ -28,4 +32,5 @@ Map _$$ItineraryConfigImplToJson( 'endDate': instance.endDate?.toIso8601String(), 'guests': instance.guests, 'destination': instance.destination, + 'activities': instance.activities, };