[Compass App] Booking screen (#2380)

This PR adds the Booking screen at the end of the main app flow.

After the user selects `Activity`s, these get stored in the
`ItineraryConfig` and then the user navigates to the `BookingScreen`.
In the `BookingScreen`, a `Booking` is generated with the
`BookingCreateComponent`.
This component communicates with multiple repositories, and it is a bit
more complex than the average view model, something that we want to show
as discussed in the previous PRs.

<details>
<summary>Screenshots</summary>


![image](https://github.com/user-attachments/assets/6a9d8d5b-0d2c-4724-8aca-d750186651b7)

![image](https://github.com/user-attachments/assets/0ef4d00e-e67b-4ec6-9ea3-28511ed4c2b8)

</details>

In the `BookingScreen`, the user can tap on "share trip" which displays
the OS share sheet functionality. This uses the plugin `share_plus`, but
the functionality is also wrapped in the `BookingShareComponent`, which
takes a `Booking` object and creates a shareable string, then calls to
the `Share.share()` method from `share_plus`. But the `share_plus`
dependency is also injected into the `BookingShareComponent`, allowing
us to unit test without plugin dependencies.

This is an example of a shared booking to instant messaging:


![image](https://github.com/user-attachments/assets/5a559080-4f9a-45e6-a736-ab849a7adc39)

**TODO**

- I want to take a look at the whole experience on mobile, as I noticed
some inconsistent UI and navigation issues that I couldn't see on
Desktop. I will submit those in a new PR.
- We also talked about user authentication in the design document. I
will work on that once we are happy with the main app flow.

## Pre-launch Checklist

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

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

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

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

@ -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<SingleChildWidget> _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<SingleChildWidget> get providersRemote {
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
..._sharedProviders,
];
}
@ -57,5 +75,6 @@ List<SingleChildWidget> get providersLocal {
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
..._sharedProviders,
];
}

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

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

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

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

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

@ -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<Result<Booking>> 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<Destination>) {
_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<List<Activity>>) {
_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<Result<Destination>> _fetchDestination(String destinationRef) async {
final result = await _destinationRepository.getDestinations();
switch (result) {
case Ok<List<Destination>>():
final destination = result.value
.firstWhere((destination) => destination.ref == destinationRef);
return Ok(destination);
case Error<List<Destination>>():
return Result.error(result.error);
}
}
}

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

@ -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<Result<void>> _loadBooking() async {
_log.fine('Loading booking');
final itineraryConfig =
await _itineraryConfigRepository.getItineraryConfig();
switch (itineraryConfig) {
case Ok<ItineraryConfig>():
_log.fine('Loaded stored ItineraryConfig');
final result = await _createComponent.createFrom(itineraryConfig.value);
switch (result) {
case Ok<Booking>():
_log.fine('Created Booking');
_booking = result.value;
notifyListeners();
return Result.ok(null);
case Error<Booking>():
_log.warning('Booking error: ${result.error}');
notifyListeners();
return Result.error(result.asError.error);
}
case Error<ItineraryConfig>():
_log.warning('ItineraryConfig error: ${itineraryConfig.error}');
notifyListeners();
return Result.error(itineraryConfig.error);
}
}
}

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

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

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

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

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

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

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

@ -0,0 +1,3 @@
import 'dart:ui';
final kBlurFilter = ImageFilter.blur(sigmaX: 2, sigmaY: 2);

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

@ -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<TagChipTheme>()?.chipColor ??
color: chipColor ??
Theme.of(context).extension<TagChipTheme>()?.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<TagChipTheme>()
?.onChipColor,
size: 10,
color: onChipColor ??
Theme.of(context)
.extension<TagChipTheme>()
?.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<TagChipTheme>()?.onChipColor ??
fontSize: fontSize,
color: onChipColor ??
Theme.of(context).extension<TagChipTheme>()?.onChipColor ??
Colors.white,
textBaseline: TextBaseline.alphabetic,
),

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

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

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

@ -32,9 +32,9 @@ class _SearchFormSubmitState extends State<SearchFormSubmit> {
@override
void didUpdateWidget(covariant SearchFormSubmit oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult);
widget.viewModel.updateItineraryConfig.addListener(_onResult);
super.didUpdateWidget(oldWidget);
}
@override

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
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);
}

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

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

@ -19,6 +19,7 @@ dependencies:
intl: any
logging: ^1.2.0
provider: ^6.1.2
share_plus: ^7.2.2
dev_dependencies:
flutter_test:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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<String, List<Activity>> 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<Result<List<Activity>>> getByDestination(String ref) async {
return Result.ok(activities[ref]!);
}
}

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

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

@ -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<String, List<Activity>> activities = {
"DESTINATION": [kActivity],
kDestination1.ref: [kActivity],
};
@override
Future<Result<List<Activity>>> getByDestination(String ref) {
return SynchronousFuture(Result.ok(activities[ref]!));
}
}

@ -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<Result<List<Continent>>> getContinents() async {
return Result.ok([
Future<Result<List<Continent>>> getContinents() {
return SynchronousFuture(Result.ok([
const Continent(name: 'CONTINENT', imageUrl: 'URL'),
const Continent(name: 'CONTINENT2', imageUrl: 'URL'),
const Continent(name: 'CONTINENT3', imageUrl: 'URL'),
]);
]));
}
}

@ -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<Result<List<Destination>>> getDestinations() {
return SynchronousFuture(Result.ok([kDestination1, kDestination2]));
}
}

@ -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<Result<ItineraryConfig>> getItineraryConfig() async {
return Result.ok(itineraryConfig ?? const ItineraryConfig());
Future<Result<ItineraryConfig>> getItineraryConfig() {
return SynchronousFuture(
Result.ok(itineraryConfig ?? const ItineraryConfig()));
}
@override
Future<Result<void>> setItineraryConfig(
ItineraryConfig itineraryConfig) async {
Future<Result<void>> setItineraryConfig(ItineraryConfig itineraryConfig) {
this.itineraryConfig = itineraryConfig;
return Result.ok(null);
return SynchronousFuture(Result.ok(null));
}
}

@ -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<Result<List<Activity>>> getActivityByDestination(String ref) async {
Future<Result<List<Activity>>> 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([]));
}
}

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

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

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

@ -6,6 +6,12 @@
#include "generated_plugin_registrant.h"
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
share_plus
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

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

@ -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> activity,
}) = _Booking;
factory Booking.fromJson(Map<String, Object?> json) =>
_$BookingFromJson(json);
}

@ -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>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
Booking _$BookingFromJson(Map<String, dynamic> json) {
return _Booking.fromJson(json);
}
/// @nodoc
mixin _$Booking {
/// 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<Activity> get activity => throw _privateConstructorUsedError;
/// Serializes this Booking to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Booking
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$BookingCopyWith<Booking> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $BookingCopyWith<$Res> {
factory $BookingCopyWith(Booking value, $Res Function(Booking) then) =
_$BookingCopyWithImpl<$Res, Booking>;
@useResult
$Res call(
{DateTime startDate,
DateTime endDate,
Destination destination,
List<Activity> 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<Activity>,
) 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> 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<Activity>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$BookingImpl implements _Booking {
const _$BookingImpl(
{required this.startDate,
required this.endDate,
required this.destination,
required final List<Activity> activity})
: _activity = activity;
factory _$BookingImpl.fromJson(Map<String, dynamic> 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> _activity;
/// List of chosen activities
@override
List<Activity> 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<String, dynamic> 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> activity}) = _$BookingImpl;
factory _Booking.fromJson(Map<String, dynamic> 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<Activity> 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;
}

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'booking.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$BookingImpl _$$BookingImplFromJson(Map<String, dynamic> json) =>
_$BookingImpl(
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
destination:
Destination.fromJson(json['destination'] as Map<String, dynamic>),
activity: (json['activity'] as List<dynamic>)
.map((e) => Activity.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$BookingImplToJson(_$BookingImpl instance) =>
<String, dynamic>{
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate.toIso8601String(),
'destination': instance.destination,
'activity': instance.activity,
};

@ -21,6 +21,9 @@ class ItineraryConfig with _$ItineraryConfig {
/// Selected [Destination] reference
String? destination,
/// Selected [Activity] references
@Default([]) List<String> activities,
}) = _ItineraryConfig;
factory ItineraryConfig.fromJson(Map<String, Object?> json) =>

@ -35,6 +35,9 @@ mixin _$ItineraryConfig {
/// Selected [Destination] reference
String? get destination => throw _privateConstructorUsedError;
/// Selected [Activity] references
List<String> get activities => throw _privateConstructorUsedError;
/// Serializes this ItineraryConfig to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -56,7 +59,8 @@ abstract class $ItineraryConfigCopyWith<$Res> {
DateTime? startDate,
DateTime? endDate,
int? guests,
String? destination});
String? destination,
List<String> 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<String>,
) as $Val);
}
}
@ -118,7 +127,8 @@ abstract class _$$ItineraryConfigImplCopyWith<$Res>
DateTime? startDate,
DateTime? endDate,
int? guests,
String? destination});
String? destination,
List<String> 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<String>,
));
}
}
@ -173,7 +188,9 @@ class _$ItineraryConfigImpl implements _ItineraryConfig {
this.startDate,
this.endDate,
this.guests,
this.destination});
this.destination,
final List<String> activities = const []})
: _activities = activities;
factory _$ItineraryConfigImpl.fromJson(Map<String, dynamic> json) =>
_$$ItineraryConfigImplFromJson(json);
@ -198,9 +215,21 @@ class _$ItineraryConfigImpl implements _ItineraryConfig {
@override
final String? destination;
/// Selected [Activity] references
final List<String> _activities;
/// Selected [Activity] references
@override
@JsonKey()
List<String> 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<String> activities}) = _$ItineraryConfigImpl;
factory _ItineraryConfig.fromJson(Map<String, dynamic> json) =
_$ItineraryConfigImpl.fromJson;
@ -271,6 +303,10 @@ abstract class _ItineraryConfig implements ItineraryConfig {
@override
String? get destination;
/// Selected [Activity] references
@override
List<String> get activities;
/// Create a copy of ItineraryConfig
/// with the given fields replaced by the non-null parameter values.
@override

@ -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<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
);
Map<String, dynamic> _$$ItineraryConfigImplToJson(
@ -28,4 +32,5 @@ Map<String, dynamic> _$$ItineraryConfigImplToJson(
'endDate': instance.endDate?.toIso8601String(),
'guests': instance.guests,
'destination': instance.destination,
'activities': instance.activities,
};

Loading…
Cancel
Save