Compass App: Add "Activities", "Itinerary Config" and MVVM Commands (#2366)

Part of the WIP for the Compass App example. Merge to `compass-app`.

This PR introduces:

- A new feature for Activities (UI unfinished).
- A repository for the current Itinerary Configuration.
- A `Command` utils class to be used in View Models.

**Activities**

- PR adds the `compass_app/app/assets/activities.json` (large file!)
- Created `ActivityRepository` with local and remote implementation.
- Added `getActivitiesByDestination` to `ApiClient`
- Added `Activity` data model
- Created `ActivitiesScreen` and `ActivitiesViewModel`. 
- WIP: Decided to finish the UI later due to the size the PR was taking.
- WIP: Server implementation for Activities will be completed in another
PR.

**Itinerary Configuration**

- Created the `ItineraryConfigRepository` with an "in-memory"
implementation. (local database or shared preferences could potentially
be implemented too)
- Refactored the way screens share data, instead of passing data using
the navigator query parameters, the screens store the state (the
itinerary configuration) in this repository, and load it when the screen
is opened.
- This allows to navigate between screens, back and forth, and keep the
selection of data the user made.

**Commands**

- To handle button taps and other running actions.
- Encapsulates an action, exposes the running state (to show progress
indicators), and ensures that the action cannot execute if it is already
running (to avoid multiple taps on buttons).
- Two implementations included, one without arguments `Command0`, and
one that supports a single argument `Command1`.
- Commands also provide an `onComplete` callback, in case the UI needs
to do something when the action finished running (e.g. navigate).
- Tests are included.

**TODO in further PRs**

- Finish the Activities UI and continue implementing the app flow.
- Introduce an error handling solution.
- Move the data jsons into a common folder (maybe a package?) so they
can be shared between app and server and don't duplicate files.

**Screencast**

As it can be observed, the state of the screen is recovered from the
stored "itinerary config".

Note: Activites screen appears empty, the list is just printed on
terminal at the moment.

[Screencast from 2024-07-23
10-58-40.webm](https://github.com/user-attachments/assets/54805c66-2938-48dd-8f63-a26b1e88eab6)

## 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 1 year ago committed by GitHub
parent be0b3dc0d1
commit 175195eae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

File diff suppressed because it is too large Load Diff

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

@ -0,0 +1,44 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

@ -1,12 +1,17 @@
import 'package:provider/single_child_widget.dart'; import 'package:provider/single_child_widget.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../data/repositories/activity/activity_repository.dart';
import '../data/repositories/activity/activity_repository_local.dart';
import '../data/repositories/activity/activity_repository_remote.dart';
import '../data/repositories/continent/continent_repository.dart'; import '../data/repositories/continent/continent_repository.dart';
import '../data/repositories/continent/continent_repository_local.dart'; import '../data/repositories/continent/continent_repository_local.dart';
import '../data/repositories/continent/continent_repository_remote.dart'; import '../data/repositories/continent/continent_repository_remote.dart';
import '../data/repositories/destination/destination_repository.dart'; import '../data/repositories/destination/destination_repository.dart';
import '../data/repositories/destination/destination_repository_local.dart'; import '../data/repositories/destination/destination_repository_local.dart';
import '../data/repositories/destination/destination_repository_remote.dart'; import '../data/repositories/destination/destination_repository_remote.dart';
import '../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../data/repositories/itinerary_config/itinerary_config_repository_memory.dart';
import '../data/services/api_client.dart'; import '../data/services/api_client.dart';
/// Configure dependencies for remote data. /// Configure dependencies for remote data.
@ -25,6 +30,14 @@ List<SingleChildWidget> get providersRemote {
apiClient: apiClient, apiClient: apiClient,
) as ContinentRepository, ) as ContinentRepository,
), ),
Provider.value(
value: ActivityRepositoryRemote(
apiClient: apiClient,
) as ActivityRepository,
),
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
]; ];
} }
@ -38,5 +51,11 @@ List<SingleChildWidget> get providersLocal {
Provider.value( Provider.value(
value: ContinentRepositoryLocal() as ContinentRepository, value: ContinentRepositoryLocal() as ContinentRepository,
), ),
Provider.value(
value: ActivityRepositoryLocal() as ActivityRepository,
),
Provider.value(
value: ItineraryConfigRepositoryMemory() as ItineraryConfigRepository,
),
]; ];
} }

@ -0,0 +1,9 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
/// Data source for activities.
abstract class ActivityRepository {
/// Get activities by [Destination] ref.
Future<Result<List<Activity>>> getByDestination(String ref);
}

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

@ -0,0 +1,33 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import 'activity_repository.dart';
/// Remote data source for [Activity].
/// Implements basic local caching.
/// See: https://docs.flutter.dev/get-started/fwe/local-caching
class ActivityRepositoryRemote implements ActivityRepository {
ActivityRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
final Map<String, List<Activity>> _cachedData = {};
@override
Future<Result<List<Activity>>> getByDestination(String ref) async {
if (!_cachedData.containsKey(ref)) {
// No cached data, request activities
final result = await _apiClient.getActivityByDestination(ref);
if (result is Ok) {
_cachedData[ref] = result.asOk.value;
}
return result;
} else {
// Return cached data if available
return Result.ok(_cachedData[ref]!);
}
}
}

@ -6,4 +6,6 @@ import '../../../utils/result.dart';
abstract class DestinationRepository { abstract class DestinationRepository {
/// Get complete list of destinations /// Get complete list of destinations
Future<Result<List<Destination>>> getDestinations(); Future<Result<List<Destination>>> getDestinations();
// TODO: Consider creating getByContinent instead of filtering in ViewModel
} }

@ -0,0 +1,14 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
/// Data source for the current [ItineraryConfig]
abstract class ItineraryConfigRepository {
/// Get current [ItineraryConfig], may be empty if no configuration started.
/// Method is async to support writing to database, file, etc.
Future<Result<ItineraryConfig>> getItineraryConfig();
/// Sets [ItineraryConfig], overrides the previous one stored.
/// Returns Result.Ok if set is successful.
Future<Result<void>> setItineraryConfig(ItineraryConfig itineraryConfig);
}

@ -0,0 +1,24 @@
import 'dart:async';
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import 'itinerary_config_repository.dart';
/// In-memory implementation of [ItineraryConfigRepository].
class ItineraryConfigRepositoryMemory implements ItineraryConfigRepository {
ItineraryConfig? _itineraryConfig;
@override
Future<Result<ItineraryConfig>> getItineraryConfig() async {
return Result.ok(_itineraryConfig ?? const ItineraryConfig());
}
@override
Future<Result<bool>> setItineraryConfig(
ItineraryConfig itineraryConfig,
) async {
_itineraryConfig = itineraryConfig;
return Result.ok(true);
}
}

@ -46,4 +46,26 @@ class ApiClient {
client.close(); client.close();
} }
} }
Future<Result<List<Activity>>> getActivityByDestination(String ref) async {
final client = HttpClient();
try {
final request =
await client.get('localhost', 8080, '/destination/$ref/activity');
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final json = jsonDecode(stringData) as List<dynamic>;
final activities =
json.map((element) => Activity.fromJson(element)).toList();
return Result.ok(activities);
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
} }

@ -1,6 +1,8 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../ui/activities/view_models/activities_viewmodel.dart';
import '../ui/activities/widgets/activities_screen.dart';
import '../ui/results/view_models/results_viewmodel.dart'; import '../ui/results/view_models/results_viewmodel.dart';
import '../ui/results/widgets/results_screen.dart'; import '../ui/results/widgets/results_screen.dart';
import '../ui/search_form/view_models/search_form_viewmodel.dart'; import '../ui/search_form/view_models/search_form_viewmodel.dart';
@ -9,12 +11,15 @@ import '../ui/search_form/widgets/search_form_screen.dart';
/// Top go_router entry point /// Top go_router entry point
final router = GoRouter( final router = GoRouter(
initialLocation: '/', initialLocation: '/',
debugLogDiagnostics: true,
routes: [ routes: [
GoRoute( GoRoute(
path: '/', path: '/',
builder: (context, state) { builder: (context, state) {
final viewModel = final viewModel = SearchFormViewModel(
SearchFormViewModel(continentRepository: context.read()); continentRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return SearchFormScreen(viewModel: viewModel); return SearchFormScreen(viewModel: viewModel);
}, },
routes: [ routes: [
@ -23,11 +28,23 @@ final router = GoRouter(
builder: (context, state) { builder: (context, state) {
final viewModel = ResultsViewModel( final viewModel = ResultsViewModel(
destinationRepository: context.read(), destinationRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return ResultsScreen(
viewModel: viewModel,
);
},
),
GoRoute(
path: 'activities',
builder: (context, state) {
final viewModel = ActivitiesViewModel(
activityRepository: context.read(),
itineraryConfigRepository: context.read(),
);
return ActivitiesScreen(
viewModel: viewModel,
); );
final parameters = state.uri.queryParameters;
// TODO: Pass the rest of query parameters to the ViewModel
viewModel.search(continent: parameters['continent']);
return ResultsScreen(viewModel: viewModel);
}, },
), ),
], ],

@ -0,0 +1,82 @@
import 'package:compass_model/model.dart';
import 'package:flutter/foundation.dart';
import '../../../data/repositories/activity/activity_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
class ActivitiesViewModel extends ChangeNotifier {
ActivitiesViewModel({
required ActivityRepository activityRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _activityRepository = activityRepository,
_itineraryConfigRepository = itineraryConfigRepository {
loadActivities = Command0(_loadActivities)..execute();
}
final ActivityRepository _activityRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
List<Activity> _activities = <Activity>[];
final Set<String> _selectedActivities = <String>{};
/// List of [Activity] per destination.
List<Activity> get activities => _activities;
/// Selected [Activity] by ref.
Set<String> get selectedActivities => _selectedActivities;
/// Load list of [Activity] for a [Destination] by ref.
late final Command0 loadActivities;
Future<void> _loadActivities() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
if (result is Error) {
// TODO: Handle error
print(result.asError.error);
return;
}
final destinationRef = result.asOk.value.destination;
if (destinationRef == null) {
// TODO: Error here
return;
}
final resultActivities =
await _activityRepository.getByDestination(destinationRef);
switch (resultActivities) {
case Ok():
{
_activities = resultActivities.value;
print(_activities);
}
case Error():
{
// TODO: Handle error
print(resultActivities.error);
}
}
notifyListeners();
}
/// Add [Activity] to selected list.
void addActivity(String activityRef) {
assert(
activities.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.add(activityRef);
notifyListeners();
}
/// Remove [Activity] from selected list.
void removeActivity(String activityRef) {
assert(
activities.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.remove(activityRef);
notifyListeners();
}
}

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/ui/back_button.dart';
import '../../core/ui/home_button.dart';
import '../view_models/activities_viewmodel.dart';
class ActivitiesScreen extends StatelessWidget {
const ActivitiesScreen({
super.key,
required this.viewModel,
});
final ActivitiesViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListenableBuilder(
listenable: viewModel.loadActivities,
builder: (context, child) {
if (viewModel.loadActivities.running) {
return const Center(child: CircularProgressIndicator());
}
return child!;
},
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomBackButton(
onTap: () {
// Navigate to ResultsScreen and edit search
context.go('/results');
},
),
const HomeButton(),
],
),
),
),
// TODO: Display "activities" here
],
),
);
},
),
),
);
}
}

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../themes/colors.dart';
/// Custom back button to pop navigation.
class CustomBackButton extends StatelessWidget {
const CustomBackButton({
super.key,
this.onTap,
});
final GestureTapCallback? onTap;
@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: () {
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,20 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
final _dateFormatDay = DateFormat('d');
final _dateFormatDayMonth = DateFormat('d MMM');
String dateFormatStartEnd(DateTimeRange dateTimeRange) {
final start = dateTimeRange.start;
final end = dateTimeRange.end;
final dayMonthEnd = _dateFormatDayMonth.format(end);
if (start.month == end.month) {
final dayStart = _dateFormatDay.format(start);
return '$dayStart - $dayMonthEnd';
}
final dayMonthStart = _dateFormatDayMonth.format(start);
return '$dayMonthStart - $dayMonthEnd';
}

@ -1,38 +1,44 @@
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'date_format_start_end.dart';
import '../themes/colors.dart'; import '../themes/colors.dart';
import 'home_button.dart'; import 'home_button.dart';
/// Application top search bar. /// Application top search bar.
/// ///
/// Displays a search bar with the current query. /// Displays a search bar with the current configuration.
/// Includes [HomeButton] to navigate back to the '/' path. /// Includes [HomeButton] to navigate back to the '/' path.
class AppSearchBar extends StatelessWidget { class AppSearchBar extends StatelessWidget {
const AppSearchBar({ const AppSearchBar({
super.key, super.key,
this.query, this.config,
this.onTap,
}); });
final String? query; final ItineraryConfig? config;
final GestureTapCallback? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Container( child: InkWell(
height: 64, borderRadius: BorderRadius.circular(16.0),
decoration: BoxDecoration( onTap: onTap,
border: Border.all(color: AppColors.grey1), child: Container(
borderRadius: BorderRadius.circular(16.0), height: 64,
), decoration: BoxDecoration(
child: Padding( border: Border.all(color: AppColors.grey1),
padding: const EdgeInsets.symmetric(horizontal: 20), borderRadius: BorderRadius.circular(16.0),
child: Align( ),
alignment: AlignmentDirectional.centerStart, child: Padding(
child: query != null padding: const EdgeInsets.symmetric(horizontal: 20),
? _QueryText(query: query!) child: Align(
: const _EmptySearch(), alignment: AlignmentDirectional.centerStart,
child: _QueryText(config: config),
),
), ),
), ),
), ),
@ -46,15 +52,27 @@ class AppSearchBar extends StatelessWidget {
class _QueryText extends StatelessWidget { class _QueryText extends StatelessWidget {
const _QueryText({ const _QueryText({
required this.query, required this.config,
}); });
final String query; final ItineraryConfig? config;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (config == null) {
return const _EmptySearch();
}
final ItineraryConfig(:continent, :startDate, :endDate, :guests) = config!;
if (startDate == null ||
endDate == null ||
guests == null ||
continent == null) {
return const _EmptySearch();
}
return Text( return Text(
query, '$continent - ${dateFormatStartEnd(DateTimeRange(start: startDate, end: endDate))} - Guests: $guests',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
); );

@ -1,6 +1,8 @@
import 'package:compass_model/model.dart'; import 'package:compass_model/model.dart';
import '../../../data/repositories/destination/destination_repository.dart'; import '../../../data/repositories/destination/destination_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart'; import '../../../utils/result.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@ -9,40 +11,54 @@ import 'package:flutter/cupertino.dart';
class ResultsViewModel extends ChangeNotifier { class ResultsViewModel extends ChangeNotifier {
ResultsViewModel({ ResultsViewModel({
required DestinationRepository destinationRepository, required DestinationRepository destinationRepository,
}) : _destinationRepository = destinationRepository; required ItineraryConfigRepository itineraryConfigRepository,
}) : _destinationRepository = destinationRepository,
_itineraryConfigRepository = itineraryConfigRepository {
updateItineraryConfig = Command1<bool, String>(_updateItineraryConfig);
search = Command0(_search)..execute();
}
final DestinationRepository _destinationRepository; final DestinationRepository _destinationRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
// Setters are private // Setters are private
List<Destination> _destinations = []; List<Destination> _destinations = [];
bool _loading = false;
String? _continent;
/// List of destinations, may be empty but never null /// List of destinations, may be empty but never null
List<Destination> get destinations => _destinations; List<Destination> get destinations => _destinations;
/// Loading state ItineraryConfig? _itineraryConfig;
bool get loading => _loading;
/// Return a formatted String with all the filter options /// Filter options to display on search bar
String get filters => _continent ?? ''; ItineraryConfig get config => _itineraryConfig ?? const ItineraryConfig();
/// Perform search /// Perform search
Future<void> search({String? continent}) async { late final Command0 search;
// Set loading state and notify the view
_loading = true; /// Store ViewModel data into [ItineraryConfigRepository] before navigating.
_continent = continent; late final Command1<bool, String> updateItineraryConfig;
Future<void> _search() async {
// Load current itinerary config
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
// TODO: Handle error
// ignore: avoid_print
print(resultConfig.asError.error);
return;
}
_itineraryConfig = resultConfig.asOk.value;
notifyListeners(); notifyListeners();
final result = await _destinationRepository.getDestinations(); final result = await _destinationRepository.getDestinations();
// Set loading state to false
_loading = false;
switch (result) { switch (result) {
case Ok(): case Ok():
{ {
// If the result is Ok, update the list of destinations // If the result is Ok, update the list of destinations
_destinations = result.value _destinations = result.value
.where((destination) => _filter(destination, continent)) .where((destination) =>
destination.continent == _itineraryConfig!.continent)
.toList(); .toList();
} }
case Error(): case Error():
@ -57,7 +73,32 @@ class ResultsViewModel extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
bool _filter(Destination destination, String? continent) { Future<bool> _updateItineraryConfig(String destinationRef) async {
return (continent == null || destination.continent == continent); assert(destinationRef.isNotEmpty, "destinationRef should not be empty");
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
// TODO: Handle error
// ignore: avoid_print
print(resultConfig.asError.error);
return false;
}
final itineraryConfig = resultConfig.asOk.value;
final result = await _itineraryConfigRepository.setItineraryConfig(
itineraryConfig.copyWith(destination: destinationRef));
switch (result) {
case Ok<void>():
{
return true;
}
case Error<void>():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
return false;
}
}
} }
} }

@ -9,15 +9,16 @@ class ResultCard extends StatelessWidget {
const ResultCard({ const ResultCard({
super.key, super.key,
required this.destination, required this.destination,
required this.onTap,
}); });
final Destination destination; final Destination destination;
final GestureTapCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(10.0), borderRadius: BorderRadius.circular(10.0),
// TODO: Improve image loading and caching
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@ -50,7 +51,16 @@ class ResultCard extends StatelessWidget {
), ),
], ],
), ),
) ),
// Handle taps
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
),
),
),
], ],
), ),
); );

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../utils/result.dart';
import '../../core/ui/search_bar.dart'; import '../../core/ui/search_bar.dart';
import '../view_models/results_viewmodel.dart'; import '../view_models/results_viewmodel.dart';
import 'result_card.dart'; import 'result_card.dart';
@ -16,26 +18,38 @@ class ResultsScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: ListenableBuilder( body: ListenableBuilder(
listenable: viewModel, listenable: viewModel.search,
builder: (context, child) { builder: (context, child) {
if (viewModel.loading) { if (viewModel.search.running) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Padding( return child!;
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: AppSearchBar(query: viewModel.filters),
),
),
_Grid(viewModel: viewModel),
],
),
);
}, },
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: AppSearchBar(
config: viewModel.config,
onTap: () {
// Navigate to SearchFormScreen and edit search
context.go('/');
},
),
),
),
_Grid(viewModel: viewModel),
],
),
);
},
),
), ),
); );
} }
@ -59,9 +73,20 @@ class _Grid extends StatelessWidget {
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final destination = viewModel.destinations[index];
return ResultCard( return ResultCard(
key: ValueKey(viewModel.destinations[index].ref), key: ValueKey(destination.ref),
destination: viewModel.destinations[index], destination: destination,
onTap: () {
viewModel.updateItineraryConfig.execute(
argument: destination.ref,
onComplete: (result) {
if (result) {
context.go('/activities');
}
},
);
},
); );
}, },
childCount: viewModel.destinations.length, childCount: viewModel.destinations.length,

@ -1,12 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:compass_model/model.dart'; import 'package:compass_model/model.dart';
import '../../../data/repositories/continent/continent_repository.dart'; import '../../../data/repositories/continent/continent_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart'; import '../../../utils/result.dart';
final _dateFormat = DateFormat('yyyy-MM-dd');
/// View model for the search form. /// View model for the search form.
/// ///
/// Contains the form selected options /// Contains the form selected options
@ -14,11 +13,15 @@ final _dateFormat = DateFormat('yyyy-MM-dd');
class SearchFormViewModel extends ChangeNotifier { class SearchFormViewModel extends ChangeNotifier {
SearchFormViewModel({ SearchFormViewModel({
required ContinentRepository continentRepository, required ContinentRepository continentRepository,
}) : _continentRepository = continentRepository { required ItineraryConfigRepository itineraryConfigRepository,
load(); }) : _continentRepository = continentRepository,
_itineraryConfigRepository = itineraryConfigRepository {
updateItineraryConfig = Command0(_updateItineraryConfig);
load = Command0(_load)..execute();
} }
final ContinentRepository _continentRepository; final ContinentRepository _continentRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
List<Continent> _continents = []; List<Continent> _continents = [];
String? _selectedContinent; String? _selectedContinent;
DateTimeRange? _dateRange; DateTimeRange? _dateRange;
@ -28,48 +31,10 @@ class SearchFormViewModel extends ChangeNotifier {
bool get valid => bool get valid =>
_guests > 0 && _selectedContinent != null && _dateRange != null; _guests > 0 && _selectedContinent != null && _dateRange != null;
/// Returns the search query string to call the Results screen
/// e.g. 'destination=Europe&checkIn=2024-05-09&checkOut=2024-05-24&guests=1',
/// Must be called only if [valid] is true
get searchQuery {
assert(valid, "Called searchQuery when the form is not valid");
assert(
_selectedContinent != null, "Called searchQuery without a continent");
assert(_dateRange != null, "Called searchQuery without a date range");
assert(_guests > 0, "Called searchQuery without guests");
final startDate = _dateRange!.start;
final endDate = _dateRange!.end;
final uri = Uri(queryParameters: {
'continent': _selectedContinent!,
'checkIn': _dateFormat.format(startDate),
'checkOut': _dateFormat.format(endDate),
'guests': _guests.toString(),
});
return uri.query;
}
/// List of continents. /// List of continents.
/// Loaded in [load] method. /// Loaded in [load] command.
List<Continent> get continents => _continents; List<Continent> get continents => _continents;
/// Load the list of continents.
Future<void> load() async {
final result = await _continentRepository.getContinents();
switch (result) {
case Ok():
{
_continents = result.value;
}
case Error():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
}
}
notifyListeners();
}
/// Selected continent. /// Selected continent.
/// Null means no continent is selected. /// Null means no continent is selected.
String? get selectedContinent => _selectedContinent; String? get selectedContinent => _selectedContinent;
@ -105,4 +70,82 @@ class SearchFormViewModel extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
} }
/// Load the list of continents and current itinerary config.
late final Command0 load;
/// Store ViewModel data into [ItineraryConfigRepository] before navigating.
late final Command0<bool> updateItineraryConfig;
Future<void> _load() async {
await _loadContinents();
await _loadItineraryConfig();
}
Future<void> _loadContinents() async {
final result = await _continentRepository.getContinents();
switch (result) {
case Ok():
{
_continents = result.value;
}
case Error():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
}
}
notifyListeners();
}
Future<void> _loadItineraryConfig() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
switch (result) {
case Ok<ItineraryConfig>():
{
final itineraryConfig = result.value;
_selectedContinent = itineraryConfig.continent;
if (itineraryConfig.startDate != null &&
itineraryConfig.endDate != null) {
_dateRange = DateTimeRange(
start: itineraryConfig.startDate!,
end: itineraryConfig.endDate!,
);
}
_guests = itineraryConfig.guests ?? 0;
notifyListeners();
}
case Error<ItineraryConfig>():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
}
}
}
Future<bool> _updateItineraryConfig() async {
assert(valid, "called when valid was false");
final result =
await _itineraryConfigRepository.setItineraryConfig(ItineraryConfig(
continent: _selectedContinent,
startDate: _dateRange!.start,
endDate: _dateRange!.end,
guests: _guests,
));
switch (result) {
case Ok<void>():
{
return true;
}
case Error<void>():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
return false;
}
}
}
} }

@ -24,26 +24,37 @@ class SearchFormContinent extends StatelessWidget {
return SizedBox( return SizedBox(
height: 140, height: 140,
child: ListenableBuilder( child: ListenableBuilder(
listenable: viewModel, listenable: viewModel.load,
builder: (context, child) { builder: (context, child) {
return ListView.separated( if (viewModel.load.running) {
scrollDirection: Axis.horizontal, return const Center(
itemCount: viewModel.continents.length, child: CircularProgressIndicator(),
padding: const EdgeInsets.symmetric(horizontal: 20), );
itemBuilder: (BuildContext context, int index) { }
final Continent(:imageUrl, :name) = viewModel.continents[index]; return child!;
return _CarouselItem(
key: ValueKey(name),
imageUrl: imageUrl,
name: name,
viewModel: viewModel,
);
},
separatorBuilder: (BuildContext context, int index) {
return const SizedBox(width: 8);
},
);
}, },
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: viewModel.continents.length,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemBuilder: (BuildContext context, int index) {
final Continent(:imageUrl, :name) = viewModel.continents[index];
return _CarouselItem(
key: ValueKey(name),
imageUrl: imageUrl,
name: name,
viewModel: viewModel,
);
},
separatorBuilder: (BuildContext context, int index) {
return const SizedBox(width: 8);
},
);
},
),
), ),
); );
} }

@ -1,12 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/ui/date_format_start_end.dart';
import '../../core/themes/colors.dart'; import '../../core/themes/colors.dart';
import '../view_models/search_form_viewmodel.dart'; import '../view_models/search_form_viewmodel.dart';
final _dateFormatDay = DateFormat('d');
final _dateFormatDayMonth = DateFormat('d MMM');
/// Date selection form field. /// Date selection form field.
/// ///
/// Opens a date range picker dialog when tapped. /// Opens a date range picker dialog when tapped.
@ -52,7 +50,7 @@ class SearchFormDate extends StatelessWidget {
final dateRange = viewModel.dateRange; final dateRange = viewModel.dateRange;
if (dateRange != null) { if (dateRange != null) {
return Text( return Text(
_dateFormat(dateRange), dateFormatStartEnd(dateRange),
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
); );
} else { } else {
@ -70,19 +68,4 @@ class SearchFormDate extends StatelessWidget {
), ),
); );
} }
String _dateFormat(DateTimeRange dateTimeRange) {
final start = dateTimeRange.start;
final end = dateTimeRange.end;
final dayMonthEnd = _dateFormatDayMonth.format(end);
if (start.month == end.month) {
final dayStart = _dateFormatDay.format(start);
return '$dayStart - $dayMonthEnd';
}
final dayMonthStart = _dateFormatDayMonth.format(start);
return '$dayMonthStart - $dayMonthEnd';
}
} }

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../utils/result.dart';
import '../../results/widgets/results_screen.dart'; import '../../results/widgets/results_screen.dart';
import '../view_models/search_form_viewmodel.dart'; import '../view_models/search_form_viewmodel.dart';
@ -33,7 +34,15 @@ class SearchFormSubmit extends StatelessWidget {
return FilledButton( return FilledButton(
key: const ValueKey('submit_button'), key: const ValueKey('submit_button'),
onPressed: viewModel.valid onPressed: viewModel.valid
? () => context.go('/results?${viewModel.searchQuery}') ? () async {
await viewModel.updateItineraryConfig.execute(
onComplete: (result) {
if (result) {
context.go('/results');
}
},
);
}
: null, : null,
child: child, child: child,
); );

@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
typedef CommandAction0<T> = Future<T> Function();
typedef CommandAction1<T, A> = Future<T> Function(A);
typedef OnComplete<T> = Function(T);
/// Facilitates interaction with a ViewModel.
///
/// Encapsulates an action,
/// exposes its running state,
/// and ensures that it can't be launched again until it finishes.
///
/// Use [Command0] for actions without arguments.
/// Use [Command1] for actions with one argument.
abstract class Command<T> extends ChangeNotifier {
Command();
bool _running = false;
/// True when the action is running.
bool get running => _running;
/// Internal execute implementation
Future<void> _execute(
CommandAction0<T> action,
OnComplete<T>? onComplete,
) async {
// Ensure the action can't launch multiple times.
// e.g. avoid multiple taps on button
if (_running) return;
// Notify listeners.
// e.g. button shows loading state
_running = true;
notifyListeners();
try {
final result = await action();
onComplete?.call(result);
} finally {
_running = false;
notifyListeners();
}
}
}
/// [Command] without arguments.
/// Takes a [CommandAction0] as action.
class Command0<T> extends Command<T> {
Command0(this._action);
final CommandAction0<T> _action;
/// Executes the action.
/// onComplete is called when the action completes.
Future<void> execute({OnComplete<T>? onComplete}) async {
await _execute(() => _action(), onComplete);
}
}
/// [Command] with one argument.
/// Takes a [CommandAction1] as action.
class Command1<T, A> extends Command<T> {
Command1(this._action);
final CommandAction1<T, A> _action;
/// Executes the action with the argument.
/// onComplete is called when the action completes.
Future<void> execute({required A argument, OnComplete<T>? onComplete}) async {
await _execute(() => _action(argument), onComplete);
}
}

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"

@ -0,0 +1,43 @@
platform :osx, '10.14'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

@ -27,4 +27,5 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/activities.json
- assets/destinations.json - assets/destinations.json

@ -0,0 +1,23 @@
import 'package:compass_app/data/repositories/activity/activity_repository_local.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ActivityRepositoryLocal tests', () {
// To load assets
TestWidgetsFlutterBinding.ensureInitialized();
final repository = ActivityRepositoryLocal();
test('should get by destination ref', () async {
final result = await repository.getByDestination('alaska');
expect(result, isA<Ok>());
final list = result.asOk.value;
expect(list.length, 20);
final activity = list.first;
expect(activity.name, 'Glacier Trekking and Ice Climbing');
});
});
}

@ -0,0 +1,45 @@
import 'package:compass_app/data/repositories/activity/activity_repository.dart';
import 'package:compass_app/data/repositories/activity/activity_repository_remote.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../util/fakes/services/fake_api_client.dart';
void main() {
group('ActivityRepositoryRemote tests', () {
late FakeApiClient apiClient;
late ActivityRepository repository;
setUp(() {
apiClient = FakeApiClient();
repository = ActivityRepositoryRemote(apiClient: apiClient);
});
test('should get activities for destination', () async {
final result = await repository.getByDestination('alaska');
expect(result, isA<Ok>());
final list = result.asOk.value;
expect(list.length, 1);
final destination = list.first;
expect(destination.name, 'Glacier Trekking and Ice Climbing');
// Only one request happened
expect(apiClient.requestCount, 1);
});
test('should get destinations from cache', () async {
// Request destination once
var result = await repository.getByDestination('alaska');
expect(result, isA<Ok>());
// Request destination another time
result = await repository.getByDestination('alaska');
expect(result, isA<Ok>());
// Only one request happened
expect(apiClient.requestCount, 1);
});
});
}

@ -1,14 +1,51 @@
import 'package:compass_app/ui/results/view_models/results_viewmodel.dart'; import 'package:compass_app/ui/results/view_models/results_viewmodel.dart';
import 'package:compass_app/ui/results/widgets/results_screen.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_test/flutter_test.dart';
import 'package:flutter/material.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 'package:mocktail_image_network/mocktail_image_network.dart';
import '../../util/fakes/repositories/fake_destination_repository.dart'; import '../../util/fakes/repositories/fake_destination_repository.dart';
import '../../util/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../util/mocks.dart';
void main() { void main() {
// TODO: Add more cases
group('ResultsScreen widget tests', () { group('ResultsScreen widget tests', () {
late MockGoRouter goRouter;
late ResultsViewModel viewModel;
setUp(() {
viewModel = ResultsViewModel(
destinationRepository: FakeDestinationRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(
itineraryConfig: ItineraryConfig(
continent: 'Europe',
startDate: DateTime(2024, 01, 01),
endDate: DateTime(2024, 01, 31),
guests: 2,
),
),
);
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,
),
),
),
);
}
testWidgets('should load screen', (WidgetTester tester) async { testWidgets('should load screen', (WidgetTester tester) async {
await mockNetworkImages(() async { await mockNetworkImages(() async {
await loadScreen(tester); await loadScreen(tester);
@ -28,21 +65,20 @@ void main() {
expect(find.text('tags1'), findsOneWidget); expect(find.text('tags1'), findsOneWidget);
}); });
}); });
});
}
// Build and render the ResultsScreen widget testWidgets('should tap and navigate to activities',
Future<void> loadScreen(WidgetTester tester) async { (WidgetTester tester) async {
final viewModel = ResultsViewModel( await mockNetworkImages(() async {
destinationRepository: FakeDestinationRepository(), await loadScreen(tester);
);
// Load some data // Wait for list to load
viewModel.search(); await tester.pumpAndSettle();
await tester.pumpWidget(
MaterialApp( // warnIfMissed false because false negative
home: ResultsScreen( await tester.tap(find.text('NAME1'), warnIfMissed: false);
viewModel: viewModel,
), verify(() => goRouter.go('/activities')).called(1);
), });
); });
});
} }

@ -1,13 +1,23 @@
import 'package:compass_app/ui/results/view_models/results_viewmodel.dart'; 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 'package:flutter_test/flutter_test.dart';
import '../../util/fakes/repositories/fake_destination_repository.dart'; import '../../util/fakes/repositories/fake_destination_repository.dart';
import '../../util/fakes/repositories/fake_itinerary_config_repository.dart';
void main() { void main() {
group('ResultsViewModel tests', () { group('ResultsViewModel tests', () {
final viewModel = ResultsViewModel( final viewModel = ResultsViewModel(
destinationRepository: FakeDestinationRepository(), destinationRepository: FakeDestinationRepository(),
)..search(); itineraryConfigRepository: FakeItineraryConfigRepository(
itineraryConfig: ItineraryConfig(
continent: 'Europe',
startDate: DateTime(2024, 01, 01),
endDate: DateTime(2024, 01, 31),
guests: 2,
),
),
);
// perform a simple test // perform a simple test
// verifies that the list of items is properly loaded // verifies that the list of items is properly loaded

@ -3,14 +3,17 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.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_continent_repository.dart';
import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart';
void main() { void main() {
group('SearchFormViewModel Tests', () { group('SearchFormViewModel Tests', () {
late SearchFormViewModel viewModel; late SearchFormViewModel viewModel;
setUp(() { setUp(() {
viewModel = viewModel = SearchFormViewModel(
SearchFormViewModel(continentRepository: FakeContinentRepository()); continentRepository: FakeContinentRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
);
}); });
test('Initial values are correct', () { test('Initial values are correct', () {
@ -47,7 +50,7 @@ void main() {
expect(viewModel.guests, 0); expect(viewModel.guests, 0);
}); });
test('Set all values and obtain query', () { test('Set all values and save', () async {
expect(viewModel.valid, false); expect(viewModel.valid, false);
viewModel.guests = 2; viewModel.guests = 2;
@ -59,8 +62,11 @@ void main() {
viewModel.dateRange = newDateRange; viewModel.dateRange = newDateRange;
expect(viewModel.valid, true); expect(viewModel.valid, true);
expect(viewModel.searchQuery, await viewModel.updateItineraryConfig.execute(
'continent=CONTINENT&checkIn=2024-01-01&checkOut=2024-01-31&guests=2'); onComplete: (result) {
expect(result, true);
},
);
}); });
}); });
} }

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart'; import 'package:mocktail_image_network/mocktail_image_network.dart';
import '../../../util/fakes/repositories/fake_continent_repository.dart'; import '../../../util/fakes/repositories/fake_continent_repository.dart';
import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart';
void main() { void main() {
group('SearchFormContinent widget tests', () { group('SearchFormContinent widget tests', () {
@ -13,6 +14,7 @@ void main() {
setUp(() { setUp(() {
viewModel = SearchFormViewModel( viewModel = SearchFormViewModel(
continentRepository: FakeContinentRepository(), continentRepository: FakeContinentRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
); );
}); });

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../util/fakes/repositories/fake_continent_repository.dart'; import '../../../util/fakes/repositories/fake_continent_repository.dart';
import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart';
void main() { void main() {
group('SearchFormDate widget tests', () { group('SearchFormDate widget tests', () {
@ -12,6 +13,7 @@ void main() {
setUp(() { setUp(() {
viewModel = SearchFormViewModel( viewModel = SearchFormViewModel(
continentRepository: FakeContinentRepository(), continentRepository: FakeContinentRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
); );
}); });

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../util/fakes/repositories/fake_continent_repository.dart'; import '../../../util/fakes/repositories/fake_continent_repository.dart';
import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart';
void main() { void main() {
group('SearchFormGuests widget tests', () { group('SearchFormGuests widget tests', () {
@ -12,6 +13,7 @@ void main() {
setUp(() { setUp(() {
viewModel = SearchFormViewModel( viewModel = SearchFormViewModel(
continentRepository: FakeContinentRepository(), continentRepository: FakeContinentRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
); );
}); });

@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:mocktail_image_network/mocktail_image_network.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/mocks.dart';
import '../../../util/fakes/repositories/fake_continent_repository.dart'; import '../../../util/fakes/repositories/fake_continent_repository.dart';
@ -17,6 +18,7 @@ void main() {
setUp(() { setUp(() {
viewModel = SearchFormViewModel( viewModel = SearchFormViewModel(
continentRepository: FakeContinentRepository(), continentRepository: FakeContinentRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
); );
goRouter = MockGoRouter(); goRouter = MockGoRouter();
}); });
@ -60,9 +62,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('submit_button'))); await tester.tap(find.byKey(const ValueKey('submit_button')));
// Should navigate to results screen // Should navigate to results screen
verify(() => goRouter.go( verify(() => goRouter.go('/results')).called(1);
'/results?continent=CONTINENT&checkIn=2024-06-12&checkOut=2024-07-23&guests=1'))
.called(1);
}); });
}); });
} }

@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../../../util/fakes/repositories/fake_continent_repository.dart'; import '../../../util/fakes/repositories/fake_continent_repository.dart';
import '../../../util/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../../util/mocks.dart'; import '../../../util/mocks.dart';
void main() { void main() {
@ -16,6 +17,7 @@ void main() {
setUp(() { setUp(() {
viewModel = SearchFormViewModel( viewModel = SearchFormViewModel(
continentRepository: FakeContinentRepository(), continentRepository: FakeContinentRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
); );
goRouter = MockGoRouter(); goRouter = MockGoRouter();
}); });
@ -57,9 +59,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('submit_button'))); await tester.tap(find.byKey(const ValueKey('submit_button')));
// Should navigate to results screen // Should navigate to results screen
verify(() => goRouter.go( verify(() => goRouter.go('/results')).called(1);
'/results?continent=CONTINENT&checkIn=2024-01-01&checkOut=2024-01-31&guests=2'))
.called(1);
}); });
}); });
} }

@ -0,0 +1,23 @@
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 'fake_destination_repository.dart';
class FakeItineraryConfigRepository implements ItineraryConfigRepository {
FakeItineraryConfigRepository({this.itineraryConfig});
ItineraryConfig? itineraryConfig;
@override
Future<Result<ItineraryConfig>> getItineraryConfig() async {
return Result.ok(itineraryConfig ?? const ItineraryConfig());
}
@override
Future<Result<void>> setItineraryConfig(
ItineraryConfig itineraryConfig) async {
this.itineraryConfig = itineraryConfig;
return Result.ok(null);
}
}

@ -42,4 +42,30 @@ class FakeApiClient implements ApiClient {
], ],
); );
} }
@override
Future<Result<List<Activity>>> getActivityByDestination(String ref) async {
requestCount++;
if (ref == 'alaska') {
return Result.ok([
const Activity(
name: 'Glacier Trekking and Ice Climbing',
description:
'Embark on a thrilling adventure exploring the awe-inspiring glaciers of Alaska. Hike across the icy terrain, marvel at the deep blue crevasses, and even try your hand at ice climbing for an unforgettable experience.',
locationName: 'Matanuska Glacier or Mendenhall Glacier',
duration: 8,
timeOfDay: 'morning',
familyFriendly: false,
price: 4,
destinationRef: 'alaska',
ref: 'glacier-trekking-and-ice-climbing',
imageUrl:
'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg',
),
]);
}
return Result.ok([]);
}
} }

@ -0,0 +1,107 @@
import 'dart:math';
import 'package:compass_app/utils/command.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Command0 tests', () {
test('should complete void command', () async {
// Void action
final command = Command0<void>(() => Future.value());
bool completed = false;
// Run void action
await command.execute(onComplete: (_) {
completed = true;
});
// Action completed
expect(completed, true);
});
test('should complete bool command', () async {
// Action that returns bool
final command = Command0<bool>(() => Future.value(true));
bool completed = false;
// Run action with result
await command.execute(onComplete: (value) {
completed = value;
});
// Action completed with result
expect(completed, true);
});
test('running should be true', () async {
final command = Command0<void>(() => Future.value());
final future = command.execute();
// Action is running
expect(command.running, true);
// Await execution
await future;
// Action finished running
expect(command.running, false);
});
test('should only run once', () async {
int count = 0;
final command = Command0<int>(() => Future.value(count++));
final future = command.execute();
// Run multiple times
command.execute();
command.execute();
command.execute();
command.execute();
// Await execution
await future;
// Action is called once
expect(count, 1);
});
});
group('Command1 tests', () {
test('should complete void command, bool argument', () async {
// Void action with bool argument
final command = Command1<void, bool>((a) {
expect(a, true);
return Future.value();
});
bool completed = false;
// Run void action, ignore void return
await command.execute(
argument: true,
onComplete: (_) {
completed = true;
},
);
expect(completed, true);
});
test('should complete bool command, bool argument', () async {
// Action that returns bool argument
final command = Command1<bool, bool>((a) => Future.value(a));
bool completed = false;
// Run action with result and argument
await command.execute(
argument: true,
onComplete: (value) {
completed = value;
},
);
// Argument was passed to onComplete
expect(completed, true);
});
});
}

@ -1,4 +1,6 @@
library; library;
export 'src/model/activity/activity.dart';
export 'src/model/continent/continent.dart'; export 'src/model/continent/continent.dart';
export 'src/model/destination/destination.dart'; export 'src/model/destination/destination.dart';
export 'src/model/itinerary_config/itinerary_config.dart';

@ -0,0 +1,44 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'activity.freezed.dart';
part 'activity.g.dart';
@freezed
class Activity with _$Activity {
const factory Activity({
/// e.g. 'Glacier Trekking and Ice Climbing'
required String name,
/// e.g. 'Embark on a thrilling adventure exploring the awe-inspiring glaciers of Alaska. Hike across the icy terrain, marvel at the deep blue crevasses, and even try your hand at ice climbing for an unforgettable experience.'
required String description,
/// e.g. 'Matanuska Glacier or Mendenhall Glacier'
required String locationName,
/// Duration in days.
/// e.g. 8
required int duration,
/// e.g. 'morning'
required String timeOfDay,
/// e.g. false
required bool familyFriendly,
/// e.g. 4
required int price,
/// e.g. 'alaska'
required String destinationRef,
/// e.g. 'glacier-trekking-and-ice-climbing'
required String ref,
/// e.g. 'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg'
required String imageUrl,
}) = _Activity;
factory Activity.fromJson(Map<String, Object?> json) =>
_$ActivityFromJson(json);
}

@ -0,0 +1,425 @@
// 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 'activity.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');
Activity _$ActivityFromJson(Map<String, dynamic> json) {
return _Activity.fromJson(json);
}
/// @nodoc
mixin _$Activity {
/// e.g. 'Glacier Trekking and Ice Climbing'
String get name => throw _privateConstructorUsedError;
/// e.g. 'Embark on a thrilling adventure exploring the awe-inspiring glaciers of Alaska. Hike across the icy terrain, marvel at the deep blue crevasses, and even try your hand at ice climbing for an unforgettable experience.'
String get description => throw _privateConstructorUsedError;
/// e.g. 'Matanuska Glacier or Mendenhall Glacier'
String get locationName => throw _privateConstructorUsedError;
/// Duration in days.
/// e.g. 8
int get duration => throw _privateConstructorUsedError;
/// e.g. 'morning'
String get timeOfDay => throw _privateConstructorUsedError;
/// e.g. false
bool get familyFriendly => throw _privateConstructorUsedError;
/// e.g. 4
int get price => throw _privateConstructorUsedError;
/// e.g. 'alaska'
String get destinationRef => throw _privateConstructorUsedError;
/// e.g. 'glacier-trekking-and-ice-climbing'
String get ref => throw _privateConstructorUsedError;
/// e.g. 'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg'
String get imageUrl => throw _privateConstructorUsedError;
/// Serializes this Activity to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Activity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ActivityCopyWith<Activity> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ActivityCopyWith<$Res> {
factory $ActivityCopyWith(Activity value, $Res Function(Activity) then) =
_$ActivityCopyWithImpl<$Res, Activity>;
@useResult
$Res call(
{String name,
String description,
String locationName,
int duration,
String timeOfDay,
bool familyFriendly,
int price,
String destinationRef,
String ref,
String imageUrl});
}
/// @nodoc
class _$ActivityCopyWithImpl<$Res, $Val extends Activity>
implements $ActivityCopyWith<$Res> {
_$ActivityCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Activity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? description = null,
Object? locationName = null,
Object? duration = null,
Object? timeOfDay = null,
Object? familyFriendly = null,
Object? price = null,
Object? destinationRef = null,
Object? ref = null,
Object? imageUrl = null,
}) {
return _then(_value.copyWith(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
locationName: null == locationName
? _value.locationName
: locationName // ignore: cast_nullable_to_non_nullable
as String,
duration: null == duration
? _value.duration
: duration // ignore: cast_nullable_to_non_nullable
as int,
timeOfDay: null == timeOfDay
? _value.timeOfDay
: timeOfDay // ignore: cast_nullable_to_non_nullable
as String,
familyFriendly: null == familyFriendly
? _value.familyFriendly
: familyFriendly // ignore: cast_nullable_to_non_nullable
as bool,
price: null == price
? _value.price
: price // ignore: cast_nullable_to_non_nullable
as int,
destinationRef: null == destinationRef
? _value.destinationRef
: destinationRef // ignore: cast_nullable_to_non_nullable
as String,
ref: null == ref
? _value.ref
: ref // ignore: cast_nullable_to_non_nullable
as String,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$ActivityImplCopyWith<$Res>
implements $ActivityCopyWith<$Res> {
factory _$$ActivityImplCopyWith(
_$ActivityImpl value, $Res Function(_$ActivityImpl) then) =
__$$ActivityImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String name,
String description,
String locationName,
int duration,
String timeOfDay,
bool familyFriendly,
int price,
String destinationRef,
String ref,
String imageUrl});
}
/// @nodoc
class __$$ActivityImplCopyWithImpl<$Res>
extends _$ActivityCopyWithImpl<$Res, _$ActivityImpl>
implements _$$ActivityImplCopyWith<$Res> {
__$$ActivityImplCopyWithImpl(
_$ActivityImpl _value, $Res Function(_$ActivityImpl) _then)
: super(_value, _then);
/// Create a copy of Activity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? description = null,
Object? locationName = null,
Object? duration = null,
Object? timeOfDay = null,
Object? familyFriendly = null,
Object? price = null,
Object? destinationRef = null,
Object? ref = null,
Object? imageUrl = null,
}) {
return _then(_$ActivityImpl(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
locationName: null == locationName
? _value.locationName
: locationName // ignore: cast_nullable_to_non_nullable
as String,
duration: null == duration
? _value.duration
: duration // ignore: cast_nullable_to_non_nullable
as int,
timeOfDay: null == timeOfDay
? _value.timeOfDay
: timeOfDay // ignore: cast_nullable_to_non_nullable
as String,
familyFriendly: null == familyFriendly
? _value.familyFriendly
: familyFriendly // ignore: cast_nullable_to_non_nullable
as bool,
price: null == price
? _value.price
: price // ignore: cast_nullable_to_non_nullable
as int,
destinationRef: null == destinationRef
? _value.destinationRef
: destinationRef // ignore: cast_nullable_to_non_nullable
as String,
ref: null == ref
? _value.ref
: ref // ignore: cast_nullable_to_non_nullable
as String,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$ActivityImpl implements _Activity {
const _$ActivityImpl(
{required this.name,
required this.description,
required this.locationName,
required this.duration,
required this.timeOfDay,
required this.familyFriendly,
required this.price,
required this.destinationRef,
required this.ref,
required this.imageUrl});
factory _$ActivityImpl.fromJson(Map<String, dynamic> json) =>
_$$ActivityImplFromJson(json);
/// e.g. 'Glacier Trekking and Ice Climbing'
@override
final String name;
/// e.g. 'Embark on a thrilling adventure exploring the awe-inspiring glaciers of Alaska. Hike across the icy terrain, marvel at the deep blue crevasses, and even try your hand at ice climbing for an unforgettable experience.'
@override
final String description;
/// e.g. 'Matanuska Glacier or Mendenhall Glacier'
@override
final String locationName;
/// Duration in days.
/// e.g. 8
@override
final int duration;
/// e.g. 'morning'
@override
final String timeOfDay;
/// e.g. false
@override
final bool familyFriendly;
/// e.g. 4
@override
final int price;
/// e.g. 'alaska'
@override
final String destinationRef;
/// e.g. 'glacier-trekking-and-ice-climbing'
@override
final String ref;
/// e.g. 'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg'
@override
final String imageUrl;
@override
String toString() {
return 'Activity(name: $name, description: $description, locationName: $locationName, duration: $duration, timeOfDay: $timeOfDay, familyFriendly: $familyFriendly, price: $price, destinationRef: $destinationRef, ref: $ref, imageUrl: $imageUrl)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ActivityImpl &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
(identical(other.locationName, locationName) ||
other.locationName == locationName) &&
(identical(other.duration, duration) ||
other.duration == duration) &&
(identical(other.timeOfDay, timeOfDay) ||
other.timeOfDay == timeOfDay) &&
(identical(other.familyFriendly, familyFriendly) ||
other.familyFriendly == familyFriendly) &&
(identical(other.price, price) || other.price == price) &&
(identical(other.destinationRef, destinationRef) ||
other.destinationRef == destinationRef) &&
(identical(other.ref, ref) || other.ref == ref) &&
(identical(other.imageUrl, imageUrl) ||
other.imageUrl == imageUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
name,
description,
locationName,
duration,
timeOfDay,
familyFriendly,
price,
destinationRef,
ref,
imageUrl);
/// Create a copy of Activity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ActivityImplCopyWith<_$ActivityImpl> get copyWith =>
__$$ActivityImplCopyWithImpl<_$ActivityImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ActivityImplToJson(
this,
);
}
}
abstract class _Activity implements Activity {
const factory _Activity(
{required final String name,
required final String description,
required final String locationName,
required final int duration,
required final String timeOfDay,
required final bool familyFriendly,
required final int price,
required final String destinationRef,
required final String ref,
required final String imageUrl}) = _$ActivityImpl;
factory _Activity.fromJson(Map<String, dynamic> json) =
_$ActivityImpl.fromJson;
/// e.g. 'Glacier Trekking and Ice Climbing'
@override
String get name;
/// e.g. 'Embark on a thrilling adventure exploring the awe-inspiring glaciers of Alaska. Hike across the icy terrain, marvel at the deep blue crevasses, and even try your hand at ice climbing for an unforgettable experience.'
@override
String get description;
/// e.g. 'Matanuska Glacier or Mendenhall Glacier'
@override
String get locationName;
/// Duration in days.
/// e.g. 8
@override
int get duration;
/// e.g. 'morning'
@override
String get timeOfDay;
/// e.g. false
@override
bool get familyFriendly;
/// e.g. 4
@override
int get price;
/// e.g. 'alaska'
@override
String get destinationRef;
/// e.g. 'glacier-trekking-and-ice-climbing'
@override
String get ref;
/// e.g. 'https://storage.googleapis.com/tripedia-images/activities/alaska_glacier-trekking-and-ice-climbing.jpg'
@override
String get imageUrl;
/// Create a copy of Activity
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ActivityImplCopyWith<_$ActivityImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'activity.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$ActivityImpl _$$ActivityImplFromJson(Map<String, dynamic> json) =>
_$ActivityImpl(
name: json['name'] as String,
description: json['description'] as String,
locationName: json['locationName'] as String,
duration: (json['duration'] as num).toInt(),
timeOfDay: json['timeOfDay'] as String,
familyFriendly: json['familyFriendly'] as bool,
price: (json['price'] as num).toInt(),
destinationRef: json['destinationRef'] as String,
ref: json['ref'] as String,
imageUrl: json['imageUrl'] as String,
);
Map<String, dynamic> _$$ActivityImplToJson(_$ActivityImpl instance) =>
<String, dynamic>{
'name': instance.name,
'description': instance.description,
'locationName': instance.locationName,
'duration': instance.duration,
'timeOfDay': instance.timeOfDay,
'familyFriendly': instance.familyFriendly,
'price': instance.price,
'destinationRef': instance.destinationRef,
'ref': instance.ref,
'imageUrl': instance.imageUrl,
};

@ -0,0 +1,28 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'itinerary_config.freezed.dart';
part 'itinerary_config.g.dart';
@freezed
class ItineraryConfig with _$ItineraryConfig {
const factory ItineraryConfig({
/// [Continent] name
String? continent,
/// Start date (check in) of itinerary
DateTime? startDate,
/// End date (check out) of itinerary
DateTime? endDate,
/// Number of guests
int? guests,
/// Selected [Destination] reference
String? destination,
}) = _ItineraryConfig;
factory ItineraryConfig.fromJson(Map<String, Object?> json) =>
_$ItineraryConfigFromJson(json);
}

@ -0,0 +1,280 @@
// 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 'itinerary_config.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');
ItineraryConfig _$ItineraryConfigFromJson(Map<String, dynamic> json) {
return _ItineraryConfig.fromJson(json);
}
/// @nodoc
mixin _$ItineraryConfig {
/// [Continent] name
String? get continent => throw _privateConstructorUsedError;
/// Start date (check in) of itinerary
DateTime? get startDate => throw _privateConstructorUsedError;
/// End date (check out) of itinerary
DateTime? get endDate => throw _privateConstructorUsedError;
/// Number of guests
int? get guests => throw _privateConstructorUsedError;
/// Selected [Destination] reference
String? get destination => throw _privateConstructorUsedError;
/// Serializes this ItineraryConfig to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of ItineraryConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ItineraryConfigCopyWith<ItineraryConfig> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ItineraryConfigCopyWith<$Res> {
factory $ItineraryConfigCopyWith(
ItineraryConfig value, $Res Function(ItineraryConfig) then) =
_$ItineraryConfigCopyWithImpl<$Res, ItineraryConfig>;
@useResult
$Res call(
{String? continent,
DateTime? startDate,
DateTime? endDate,
int? guests,
String? destination});
}
/// @nodoc
class _$ItineraryConfigCopyWithImpl<$Res, $Val extends ItineraryConfig>
implements $ItineraryConfigCopyWith<$Res> {
_$ItineraryConfigCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ItineraryConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? continent = freezed,
Object? startDate = freezed,
Object? endDate = freezed,
Object? guests = freezed,
Object? destination = freezed,
}) {
return _then(_value.copyWith(
continent: freezed == continent
? _value.continent
: continent // ignore: cast_nullable_to_non_nullable
as String?,
startDate: freezed == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
endDate: freezed == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
guests: freezed == guests
? _value.guests
: guests // ignore: cast_nullable_to_non_nullable
as int?,
destination: freezed == destination
? _value.destination
: destination // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$ItineraryConfigImplCopyWith<$Res>
implements $ItineraryConfigCopyWith<$Res> {
factory _$$ItineraryConfigImplCopyWith(_$ItineraryConfigImpl value,
$Res Function(_$ItineraryConfigImpl) then) =
__$$ItineraryConfigImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String? continent,
DateTime? startDate,
DateTime? endDate,
int? guests,
String? destination});
}
/// @nodoc
class __$$ItineraryConfigImplCopyWithImpl<$Res>
extends _$ItineraryConfigCopyWithImpl<$Res, _$ItineraryConfigImpl>
implements _$$ItineraryConfigImplCopyWith<$Res> {
__$$ItineraryConfigImplCopyWithImpl(
_$ItineraryConfigImpl _value, $Res Function(_$ItineraryConfigImpl) _then)
: super(_value, _then);
/// Create a copy of ItineraryConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? continent = freezed,
Object? startDate = freezed,
Object? endDate = freezed,
Object? guests = freezed,
Object? destination = freezed,
}) {
return _then(_$ItineraryConfigImpl(
continent: freezed == continent
? _value.continent
: continent // ignore: cast_nullable_to_non_nullable
as String?,
startDate: freezed == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
endDate: freezed == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
guests: freezed == guests
? _value.guests
: guests // ignore: cast_nullable_to_non_nullable
as int?,
destination: freezed == destination
? _value.destination
: destination // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$ItineraryConfigImpl implements _ItineraryConfig {
const _$ItineraryConfigImpl(
{this.continent,
this.startDate,
this.endDate,
this.guests,
this.destination});
factory _$ItineraryConfigImpl.fromJson(Map<String, dynamic> json) =>
_$$ItineraryConfigImplFromJson(json);
/// [Continent] name
@override
final String? continent;
/// Start date (check in) of itinerary
@override
final DateTime? startDate;
/// End date (check out) of itinerary
@override
final DateTime? endDate;
/// Number of guests
@override
final int? guests;
/// Selected [Destination] reference
@override
final String? destination;
@override
String toString() {
return 'ItineraryConfig(continent: $continent, startDate: $startDate, endDate: $endDate, guests: $guests, destination: $destination)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ItineraryConfigImpl &&
(identical(other.continent, continent) ||
other.continent == continent) &&
(identical(other.startDate, startDate) ||
other.startDate == startDate) &&
(identical(other.endDate, endDate) || other.endDate == endDate) &&
(identical(other.guests, guests) || other.guests == guests) &&
(identical(other.destination, destination) ||
other.destination == destination));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, continent, startDate, endDate, guests, destination);
/// Create a copy of ItineraryConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ItineraryConfigImplCopyWith<_$ItineraryConfigImpl> get copyWith =>
__$$ItineraryConfigImplCopyWithImpl<_$ItineraryConfigImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ItineraryConfigImplToJson(
this,
);
}
}
abstract class _ItineraryConfig implements ItineraryConfig {
const factory _ItineraryConfig(
{final String? continent,
final DateTime? startDate,
final DateTime? endDate,
final int? guests,
final String? destination}) = _$ItineraryConfigImpl;
factory _ItineraryConfig.fromJson(Map<String, dynamic> json) =
_$ItineraryConfigImpl.fromJson;
/// [Continent] name
@override
String? get continent;
/// Start date (check in) of itinerary
@override
DateTime? get startDate;
/// End date (check out) of itinerary
@override
DateTime? get endDate;
/// Number of guests
@override
int? get guests;
/// Selected [Destination] reference
@override
String? get destination;
/// Create a copy of ItineraryConfig
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ItineraryConfigImplCopyWith<_$ItineraryConfigImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'itinerary_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$ItineraryConfigImpl _$$ItineraryConfigImplFromJson(
Map<String, dynamic> json) =>
_$ItineraryConfigImpl(
continent: json['continent'] as String?,
startDate: json['startDate'] == null
? null
: DateTime.parse(json['startDate'] as String),
endDate: json['endDate'] == null
? null
: DateTime.parse(json['endDate'] as String),
guests: (json['guests'] as num?)?.toInt(),
destination: json['destination'] as String?,
);
Map<String, dynamic> _$$ItineraryConfigImplToJson(
_$ItineraryConfigImpl instance) =>
<String, dynamic>{
'continent': instance.continent,
'startDate': instance.startDate?.toIso8601String(),
'endDate': instance.endDate?.toIso8601String(),
'guests': instance.guests,
'destination': instance.destination,
};
Loading…
Cancel
Save