From 0305894b0e8d57efd11dd5ae1e6e818c2890298c Mon Sep 17 00:00:00 2001 From: Miguel Beltran <m@beltran.work> Date: Fri, 2 Aug 2024 07:32:21 +0200 Subject: [PATCH] Compass App: Activities screen, error handling and logs (#2371) This PR introduces the Activities screen, handling of errors in view models and commands, and logs using the dart `logging` package. **Activities** - The screen loads a list of activities, split in daytime and evening activities, and the user can select them. - Server adds the endpoint `/destination/<id>/activitity` which was missing before. Screencast provided: [Screencast from 2024-07-29 16-29-02.webm](https://github.com/user-attachments/assets/a56024d8-0a9c-49e7-8fd0-c895da15badc) **Error handling** _UI Error handling:_ In the screencast you can see a `SnackBar` appearing, since the "Confirm" button is not yet implemented. The `saveActivities` Command returns an error `Result.error()`, then the error state is exposed by the Command and consumed by the listener in the `ActivityScreen`, which displays a `SnackBar` and consumes the state. Functionality is similar to the one found in [UI events - Consuming events can trigger state updates](https://developer.android.com/topic/architecture/ui-layer/events#consuming-trigger-updates) from the Android architecture guide, as the command state is "consumed" and cleared. The Snackbar also includes an action to "try again". Tapping on it calls to the failed Command `execute()` so users can run the action again. For example, here the `saveActivities` command failed, so `error` is `true`. Then we call to `clearResult()` to remove the failed status, and show a `SnackBar`, with the `SnackBarAction` that runs `saveActivities` again when tapped. ```dart if (widget.viewModel.saveActivities.error) { widget.viewModel.saveActivities.clearResult(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Error while saving activities'), action: SnackBarAction( label: "Try again", onPressed: widget.viewModel.saveActivities.execute, ), ), ); } ``` Since commands expose `running`, `error` and `completed`, it is easy to implement loading and error indicator widgets: [Screencast from 2024-07-29 16-55-42.webm](https://github.com/user-attachments/assets/fb5772d0-7b9d-4ded-8fa2-9ce347f4d555) As side node, we can easily simulate that state by adding these lines in any of the repository implementations: ```dart await Future.delayed(Durations.extralong1); return Result.error(Exception('ERROR!')); ``` _In-code error handling:_ The project introduces the `logging` package. In the entry point `main_development.dart` the log level is configured. Then in code, a `Logger` is creaded in each View Model with the name of the class. Then the log calls are used depending on the `Result` response, some finer traces are also added. By default, they are printed to the IDE debug console, for example: ``` [SearchFormViewModel] Continents (7) loaded [SearchFormViewModel] ItineraryConfig loaded [SearchFormViewModel] Selected continent: Asia [SearchFormViewModel] Selected date range: 2024-07-30 00:00:00.000 - 2024-08-08 00:00:00.000 [SearchFormViewModel] Set guests number: 1 [SearchFormViewModel] ItineraryConfig saved ``` **Other changes** - The json files containing destinations and activities are moved into the `app/assets/` folders, and the server is querying those files instead of their own copy. This is done to avoid file duplication but we can make a copy of those assets files for the server if we decide to. **TODO Next** - I will implement the "book a trip" screen which would complete the main application flow, which should introduce a more complex "component/use case" outside a view model. ## 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 --- compass_app/app/lib/config/assets.dart | 4 + .../activity/activity_repository_local.dart | 3 +- .../continent/continent_repository_local.dart | 2 +- .../destination_repository_local.dart | 3 +- compass_app/app/lib/main.dart | 8 + compass_app/app/lib/main_development.dart | 3 + compass_app/app/lib/main_staging.dart | 3 + .../view_models/activities_viewmodel.dart | 69 +- .../activities/widgets/activities_header.dart | 39 + .../activities/widgets/activities_list.dart | 57 + .../activities/widgets/activities_screen.dart | 175 ++- .../activities/widgets/activities_title.dart | 39 + .../ui/activities/widgets/activity_entry.dart | 61 + .../widgets/activity_time_of_day.dart | 1 + .../ui/core/localization/applocalization.dart | 78 ++ .../app/lib/ui/core/themes/colors.dart | 9 +- .../app/lib/ui/core/themes/dimens.dart | 53 + compass_app/app/lib/ui/core/themes/theme.dart | 14 + .../app/lib/ui/core/ui/custom_checkbox.dart | 46 + .../app/lib/ui/core/ui/error_indicator.dart | 59 + .../app/lib/ui/core/ui/search_bar.dart | 10 +- .../view_models/results_viewmodel.dart | 53 +- .../ui/results/widgets/results_screen.dart | 123 +- .../view_models/search_form_viewmodel.dart | 64 +- .../widgets/search_form_continent.dart | 14 +- .../search_form/widgets/search_form_date.dart | 19 +- .../widgets/search_form_guests.dart | 11 +- .../widgets/search_form_screen.dart | 13 +- .../widgets/search_form_submit.dart | 72 +- compass_app/app/lib/utils/command.dart | 52 +- compass_app/app/pubspec.yaml | 5 +- .../ui/activities/activities_screen_test.dart | 68 + .../search_form_viewmodel_test.dart | 7 +- .../fake_activities_repository.dart | 27 + .../fake_itinerary_config_repository.dart | 2 - .../util/fakes/services/fake_api_client.dart | 2 +- compass_app/app/test/utils/command_test.dart | 65 +- .../lib/src/model/activity/activity.dart | 10 +- .../src/model/activity/activity.freezed.dart | 16 +- .../lib/src/model/activity/activity.g.dart | 12 +- compass_app/server/assets/destinations.json | 1235 ----------------- compass_app/server/bin/compass_server.dart | 2 +- compass_app/server/lib/config/assets.dart | 4 + .../server/lib/routes/destination.dart | 41 +- compass_app/server/test/server_test.dart | 12 + 45 files changed, 1183 insertions(+), 1482 deletions(-) create mode 100644 compass_app/app/lib/config/assets.dart create mode 100644 compass_app/app/lib/ui/activities/widgets/activities_header.dart create mode 100644 compass_app/app/lib/ui/activities/widgets/activities_list.dart create mode 100644 compass_app/app/lib/ui/activities/widgets/activities_title.dart create mode 100644 compass_app/app/lib/ui/activities/widgets/activity_entry.dart create mode 100644 compass_app/app/lib/ui/activities/widgets/activity_time_of_day.dart create mode 100644 compass_app/app/lib/ui/core/localization/applocalization.dart create mode 100644 compass_app/app/lib/ui/core/themes/dimens.dart create mode 100644 compass_app/app/lib/ui/core/ui/custom_checkbox.dart create mode 100644 compass_app/app/lib/ui/core/ui/error_indicator.dart create mode 100644 compass_app/app/test/ui/activities/activities_screen_test.dart create mode 100644 compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart delete mode 100644 compass_app/server/assets/destinations.json create mode 100644 compass_app/server/lib/config/assets.dart diff --git a/compass_app/app/lib/config/assets.dart b/compass_app/app/lib/config/assets.dart new file mode 100644 index 000000000..b5a71146c --- /dev/null +++ b/compass_app/app/lib/config/assets.dart @@ -0,0 +1,4 @@ +class Assets { + static const activities = 'assets/activities.json'; + static const destinations = 'assets/destinations.json'; +} diff --git a/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart b/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart index cc216f95b..202c249de 100644 --- a/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart +++ b/compass_app/app/lib/data/repositories/activity/activity_repository_local.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:compass_model/model.dart'; import 'package:flutter/services.dart'; +import '../../../config/assets.dart'; import '../../../utils/result.dart'; import 'activity_repository.dart'; @@ -25,7 +26,7 @@ class ActivityRepositoryLocal implements ActivityRepository { } Future<String> _loadAsset() async { - return await rootBundle.loadString('assets/activities.json'); + return await rootBundle.loadString(Assets.activities); } List<Activity> _parse(String localData) { diff --git a/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart b/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart index c7812a035..50a4ef6af 100644 --- a/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart +++ b/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart @@ -6,7 +6,7 @@ import 'continent_repository.dart'; /// Local data source with all possible continents. class ContinentRepositoryLocal implements ContinentRepository { @override - Future<Result<List<Continent>>> getContinents() { + Future<Result<List<Continent>>> getContinents() async { return Future.value( Result.ok( [ diff --git a/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart b/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart index d3cd0e55c..7f0fc7787 100644 --- a/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart +++ b/compass_app/app/lib/data/repositories/destination/destination_repository_local.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:compass_model/model.dart'; import 'package:flutter/services.dart' show rootBundle; +import '../../../config/assets.dart'; import '../../../utils/result.dart'; import 'destination_repository.dart'; @@ -22,7 +23,7 @@ class DestinationRepositoryLocal implements DestinationRepository { } Future<String> _loadAsset() async { - return await rootBundle.loadString('assets/destinations.json'); + return await rootBundle.loadString(Assets.destinations); } List<Destination> _parse(String localData) { diff --git a/compass_app/app/lib/main.dart b/compass_app/app/lib/main.dart index c55835c8e..e5e208081 100644 --- a/compass_app/app/lib/main.dart +++ b/compass_app/app/lib/main.dart @@ -1,3 +1,6 @@ +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'ui/core/localization/applocalization.dart'; import 'ui/core/themes/theme.dart'; import 'routing/router.dart'; import 'package:flutter/material.dart'; @@ -17,6 +20,11 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( + localizationsDelegates: [ + GlobalWidgetsLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + AppLocalizationDelegate(), + ], scrollBehavior: AppCustomScrollBehavior(), theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, diff --git a/compass_app/app/lib/main_development.dart b/compass_app/app/lib/main_development.dart index 73ef5073a..abcb53577 100644 --- a/compass_app/app/lib/main_development.dart +++ b/compass_app/app/lib/main_development.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'config/dependencies.dart'; @@ -8,6 +9,8 @@ import 'main.dart'; /// Launch with `flutter run --target lib/main_development.dart`. /// Uses local data. void main() { + Logger.root.level = Level.ALL; + runApp( MultiProvider( providers: providersLocal, diff --git a/compass_app/app/lib/main_staging.dart b/compass_app/app/lib/main_staging.dart index bcc9f6800..a7f76bfc5 100644 --- a/compass_app/app/lib/main_staging.dart +++ b/compass_app/app/lib/main_staging.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'config/dependencies.dart'; @@ -8,6 +9,8 @@ import 'main.dart'; /// Launch with `flutter run --target lib/main_staging.dart`. /// Uses remote data from a server. void main() { + Logger.root.level = Level.ALL; + runApp( MultiProvider( providers: providersRemote, diff --git a/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart b/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart index ccfb597d6..afcfb3011 100644 --- a/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart +++ b/compass_app/app/lib/ui/activities/view_models/activities_viewmodel.dart @@ -1,5 +1,6 @@ import 'package:compass_model/model.dart'; import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; import '../../../data/repositories/activity/activity_repository.dart'; import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart'; @@ -13,15 +14,28 @@ 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')); + }); } + final _log = Logger('ActivitiesViewModel'); final ActivityRepository _activityRepository; final ItineraryConfigRepository _itineraryConfigRepository; - List<Activity> _activities = <Activity>[]; + List<Activity> _daytimeActivities = <Activity>[]; + List<Activity> _eveningActivities = <Activity>[]; final Set<String> _selectedActivities = <String>{}; - /// List of [Activity] per destination. - List<Activity> get activities => _activities; + /// List of daytime [Activity] per destination. + List<Activity> get daytimeActivities => _daytimeActivities; + + /// List of evening [Activity] per destination. + List<Activity> get eveningActivities => _eveningActivities; /// Selected [Activity] by ref. Set<String> get selectedActivities => _selectedActivities; @@ -29,18 +43,23 @@ class ActivitiesViewModel extends ChangeNotifier { /// Load list of [Activity] for a [Destination] by ref. late final Command0 loadActivities; - Future<void> _loadActivities() async { + /// Save list [selectedActivities] into itinerary configuration. + late final Command0 saveActivities; + + Future<Result<void>> _loadActivities() async { final result = await _itineraryConfigRepository.getItineraryConfig(); if (result is Error) { - // TODO: Handle error - print(result.asError.error); - return; + _log.warning( + 'Failed to load stored ItineraryConfig', + result.asError.error, + ); + return result; } final destinationRef = result.asOk.value.destination; if (destinationRef == null) { - // TODO: Error here - return; + _log.severe('Destination missing in ItineraryConfig'); + return Result.error(Exception('Destination not found')); } final resultActivities = @@ -48,35 +67,55 @@ class ActivitiesViewModel extends ChangeNotifier { switch (resultActivities) { case Ok(): { - _activities = resultActivities.value; - print(_activities); + _daytimeActivities = resultActivities.value + .where((activity) => [ + TimeOfDay.any, + TimeOfDay.morning, + TimeOfDay.afternoon, + ].contains(activity.timeOfDay)) + .toList(); + + _eveningActivities = resultActivities.value + .where((activity) => [ + TimeOfDay.evening, + TimeOfDay.night, + ].contains(activity.timeOfDay)) + .toList(); + + _log.fine('Activities (daytime: ${_daytimeActivities.length}, ' + 'evening: ${_eveningActivities.length}) loaded'); } case Error(): { - // TODO: Handle error - print(resultActivities.error); + _log.warning('Failed to load activities', resultActivities.error); } } + notifyListeners(); + return resultActivities; } /// Add [Activity] to selected list. void addActivity(String activityRef) { assert( - activities.any((activity) => activity.ref == activityRef), + (_daytimeActivities + _eveningActivities) + .any((activity) => activity.ref == activityRef), "Activity $activityRef not found", ); _selectedActivities.add(activityRef); + _log.finest('Activity $activityRef added'); notifyListeners(); } /// Remove [Activity] from selected list. void removeActivity(String activityRef) { assert( - activities.any((activity) => activity.ref == activityRef), + (_daytimeActivities + _eveningActivities) + .any((activity) => activity.ref == activityRef), "Activity $activityRef not found", ); _selectedActivities.remove(activityRef); + _log.finest('Activity $activityRef removed'); notifyListeners(); } } diff --git a/compass_app/app/lib/ui/activities/widgets/activities_header.dart b/compass_app/app/lib/ui/activities/widgets/activities_header.dart new file mode 100644 index 000000000..c9c1290aa --- /dev/null +++ b/compass_app/app/lib/ui/activities/widgets/activities_header.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/localization/applocalization.dart'; +import '../../core/themes/dimens.dart'; +import '../../core/ui/back_button.dart'; +import '../../core/ui/home_button.dart'; + +class ActivitiesHeader extends StatelessWidget { + const ActivitiesHeader({super.key}); + + @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(), + ], + ), + ); + } +} diff --git a/compass_app/app/lib/ui/activities/widgets/activities_list.dart b/compass_app/app/lib/ui/activities/widgets/activities_list.dart new file mode 100644 index 000000000..11c116196 --- /dev/null +++ b/compass_app/app/lib/ui/activities/widgets/activities_list.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../core/themes/dimens.dart'; +import '../view_models/activities_viewmodel.dart'; +import 'activity_entry.dart'; +import 'activity_time_of_day.dart'; + +class ActivitiesList extends StatelessWidget { + const ActivitiesList({ + super.key, + required this.viewModel, + required this.activityTimeOfDay, + }); + + final ActivitiesViewModel viewModel; + final ActivityTimeOfDay activityTimeOfDay; + + @override + Widget build(BuildContext context) { + final list = switch (activityTimeOfDay) { + ActivityTimeOfDay.daytime => viewModel.daytimeActivities, + ActivityTimeOfDay.evening => viewModel.eveningActivities, + }; + return SliverPadding( + padding: EdgeInsets.only( + top: Dimens.paddingVertical, + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + bottom: Dimens.paddingVertical, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final activity = list[index]; + return Padding( + padding: + EdgeInsets.only(bottom: index < list.length - 1 ? 20 : 0), + child: ActivityEntry( + key: ValueKey(activity.ref), + activity: activity, + selected: viewModel.selectedActivities.contains(activity.ref), + onChanged: (value) { + if (value!) { + viewModel.addActivity(activity.ref); + } else { + viewModel.removeActivity(activity.ref); + } + }, + ), + ); + }, + childCount: list.length, + ), + ), + ); + } +} diff --git a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart index 260db3454..cb35c3767 100644 --- a/compass_app/app/lib/ui/activities/widgets/activities_screen.dart +++ b/compass_app/app/lib/ui/activities/widgets/activities_screen.dart @@ -1,11 +1,15 @@ 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 '../../core/localization/applocalization.dart'; +import '../../core/themes/dimens.dart'; +import '../../core/ui/error_indicator.dart'; import '../view_models/activities_viewmodel.dart'; +import 'activities_header.dart'; +import 'activities_list.dart'; +import 'activities_title.dart'; +import 'activity_time_of_day.dart'; -class ActivitiesScreen extends StatelessWidget { +class ActivitiesScreen extends StatefulWidget { const ActivitiesScreen({ super.key, required this.viewModel, @@ -13,48 +17,157 @@ class ActivitiesScreen extends StatelessWidget { final ActivitiesViewModel viewModel; + @override + State<ActivitiesScreen> createState() => _ActivitiesScreenState(); +} + +class _ActivitiesScreenState extends State<ActivitiesScreen> { + @override + void initState() { + super.initState(); + widget.viewModel.saveActivities.addListener(_onResult); + } + + @override + void didUpdateWidget(covariant ActivitiesScreen oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.viewModel.saveActivities.removeListener(_onResult); + widget.viewModel.saveActivities.addListener(_onResult); + } + + @override + void dispose() { + widget.viewModel.saveActivities.removeListener(_onResult); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( body: ListenableBuilder( - listenable: viewModel.loadActivities, + listenable: widget.viewModel.loadActivities, builder: (context, child) { - if (viewModel.loadActivities.running) { - return const Center(child: CircularProgressIndicator()); + if (widget.viewModel.loadActivities.completed) { + return child!; } - 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: viewModel, + listenable: widget.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(), - ], + 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, + ), + ], ), - // TODO: Display "activities" here - ], - ), + ), + _BottomArea(viewModel: widget.viewModel), + ], ); }, ), ), ); } + + void _onResult() { + if (widget.viewModel.saveActivities.completed) { + widget.viewModel.saveActivities.clearResult(); + // TODO + } + + if (widget.viewModel.saveActivities.error) { + widget.viewModel.saveActivities.clearResult(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalization.of(context).errorWhileSavingActivities), + action: SnackBarAction( + label: AppLocalization.of(context).tryAgain, + onPressed: widget.viewModel.saveActivities.execute, + ), + ), + ); + } + } +} + +class _BottomArea extends StatelessWidget { + const _BottomArea({ + required this.viewModel, + }); + + final ActivitiesViewModel viewModel; + + @override + Widget build(BuildContext context) { + return SafeArea( + bottom: true, + child: Material( + elevation: 8, + child: Padding( + padding: EdgeInsets.only( + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenVertical, + top: Dimens.paddingVertical, + bottom: Dimens.of(context).paddingScreenVertical, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalization.of(context) + .selected(viewModel.selectedActivities.length), + style: Theme.of(context).textTheme.labelLarge, + ), + FilledButton( + onPressed: viewModel.selectedActivities.isNotEmpty + ? viewModel.saveActivities.execute + : null, + child: Text(AppLocalization.of(context).confirm), + ), + ], + ), + ), + ), + ); + } } diff --git a/compass_app/app/lib/ui/activities/widgets/activities_title.dart b/compass_app/app/lib/ui/activities/widgets/activities_title.dart new file mode 100644 index 000000000..7ed3fb9f3 --- /dev/null +++ b/compass_app/app/lib/ui/activities/widgets/activities_title.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import '../../core/localization/applocalization.dart'; +import '../../core/themes/dimens.dart'; +import '../view_models/activities_viewmodel.dart'; +import 'activity_time_of_day.dart'; + +class ActivitiesTitle extends StatelessWidget { + const ActivitiesTitle({ + super.key, + required this.activityTimeOfDay, + required this.viewModel, + }); + + final ActivitiesViewModel viewModel; + final ActivityTimeOfDay activityTimeOfDay; + + @override + Widget build(BuildContext context) { + final list = switch (activityTimeOfDay) { + ActivityTimeOfDay.daytime => viewModel.daytimeActivities, + ActivityTimeOfDay.evening => viewModel.eveningActivities, + }; + if (list.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox()); + } + return SliverToBoxAdapter( + child: Padding( + padding: Dimens.of(context).edgeInsetsScreenHorizontal, + child: Text(_label(context)), + ), + ); + } + + String _label(BuildContext context) => switch (activityTimeOfDay) { + ActivityTimeOfDay.daytime => AppLocalization.of(context).daytime, + ActivityTimeOfDay.evening => AppLocalization.of(context).evening, + }; +} diff --git a/compass_app/app/lib/ui/activities/widgets/activity_entry.dart b/compass_app/app/lib/ui/activities/widgets/activity_entry.dart new file mode 100644 index 000000000..54178dbe8 --- /dev/null +++ b/compass_app/app/lib/ui/activities/widgets/activity_entry.dart @@ -0,0 +1,61 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:compass_model/model.dart'; +import 'package:flutter/material.dart'; + +import '../../core/ui/custom_checkbox.dart'; + +class ActivityEntry extends StatelessWidget { + const ActivityEntry({ + super.key, + required this.activity, + required this.selected, + required this.onChanged, + }); + + final Activity activity; + final bool selected; + final ValueChanged<bool?> onChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 80, + 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, + ), + ], + ), + ), + CustomCheckbox( + key: ValueKey('${activity.ref}-checkbox'), + value: selected, + onChanged: onChanged, + ) + ], + ), + ); + } +} diff --git a/compass_app/app/lib/ui/activities/widgets/activity_time_of_day.dart b/compass_app/app/lib/ui/activities/widgets/activity_time_of_day.dart new file mode 100644 index 000000000..e026be10a --- /dev/null +++ b/compass_app/app/lib/ui/activities/widgets/activity_time_of_day.dart @@ -0,0 +1 @@ +enum ActivityTimeOfDay { daytime, evening } diff --git a/compass_app/app/lib/ui/core/localization/applocalization.dart b/compass_app/app/lib/ui/core/localization/applocalization.dart new file mode 100644 index 000000000..88a715d74 --- /dev/null +++ b/compass_app/app/lib/ui/core/localization/applocalization.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Simple Localizations similar to +/// https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization#an-alternative-class-for-the-apps-localized-resources +class AppLocalization { + static AppLocalization of(BuildContext context) { + return Localizations.of(context, AppLocalization); + } + + static const _strings = <String, String>{ + 'activities': 'Activities', + 'addDates': 'Add Dates', + 'confirm': 'Confirm', + 'daytime': 'Daytime', + 'errorWhileLoadingActivities': 'Error while loading activities', + 'errorWhileLoadingContinents': 'Error while loading continents', + 'errorWhileLoadingDestinations': 'Error while loading destinations', + 'errorWhileSavingActivities': 'Error while saving activities', + 'errorWhileSavingItinerary': 'Error while saving itinerary', + 'evening': 'Evening', + 'search': 'Search', + 'searchDestination': 'Search destination', + 'selected': '{1} selected', + 'tryAgain': 'Try again', + 'when': 'When', + }; + + // If string for "label" does not exist, will show "[LABEL]" + static String _get(String label) => + _strings[label] ?? '[${label.toUpperCase()}]'; + + String get activities => _get('activities'); + + String get addDates => _get('addDates'); + + String get confirm => _get('confirm'); + + String get daytime => _get('daytime'); + + String get errorWhileLoadingActivities => _get('errorWhileLoadingActivities'); + + String get errorWhileLoadingContinents => _get('errorWhileLoadingContinents'); + + String get errorWhileLoadingDestinations => + _get('errorWhileLoadingDestinations'); + + String get errorWhileSavingActivities => _get('errorWhileSavingActivities'); + + String get errorWhileSavingItinerary => _get('errorWhileSavingItinerary'); + + String get evening => _get('evening'); + + String get search => _get('search'); + + String get searchDestination => _get('searchDestination'); + + String get tryAgain => _get('tryAgain'); + + String get when => _get('when'); + + String selected(int value) => + _get('selected').replaceAll('{1}', value.toString()); +} + +class AppLocalizationDelegate extends LocalizationsDelegate<AppLocalization> { + @override + bool isSupported(Locale locale) => locale.languageCode == 'en'; + + @override + Future<AppLocalization> load(Locale locale) { + return SynchronousFuture(AppLocalization()); + } + + @override + bool shouldReload(covariant LocalizationsDelegate<AppLocalization> old) => + false; +} diff --git a/compass_app/app/lib/ui/core/themes/colors.dart b/compass_app/app/lib/ui/core/themes/colors.dart index b2a488873..397930b46 100644 --- a/compass_app/app/lib/ui/core/themes/colors.dart +++ b/compass_app/app/lib/ui/core/themes/colors.dart @@ -9,6 +9,7 @@ class AppColors { static const whiteTransparent = Color(0x4DFFFFFF); // Figma rgba(255, 255, 255, 0.3) static const blackTransparent = Color(0x4D000000); + static const red1 = Color(0xFFE74C3C); static const lightColorScheme = ColorScheme( brightness: Brightness.light, @@ -18,8 +19,8 @@ class AppColors { onSecondary: AppColors.white1, surface: Colors.white, onSurface: AppColors.black1, - error: Colors.red, - onError: Colors.white, + error: Colors.white, + onError: Colors.red, ); static const darkColorScheme = ColorScheme( @@ -30,7 +31,7 @@ class AppColors { onSecondary: AppColors.black1, surface: AppColors.black1, onSurface: Colors.white, - error: Colors.red, - onError: Colors.white, + error: Colors.black, + onError: AppColors.red1, ); } diff --git a/compass_app/app/lib/ui/core/themes/dimens.dart b/compass_app/app/lib/ui/core/themes/dimens.dart new file mode 100644 index 000000000..d2411e864 --- /dev/null +++ b/compass_app/app/lib/ui/core/themes/dimens.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +sealed class Dimens { + const Dimens(); + + /// General horizontal padding used to separate UI items + static const paddingHorizontal = 20.0; + + /// General vertical padding used to separate UI items + static const paddingVertical = 24.0; + + /// Horizontal padding for screen edges + abstract final double paddingScreenHorizontal; + + /// Vertical padding for screen edges + abstract final double paddingScreenVertical; + + /// Horizontal symmetric padding for screen edges + EdgeInsets get edgeInsetsScreenHorizontal => + EdgeInsets.symmetric(horizontal: paddingScreenHorizontal); + + /// Symmetric padding for screen edges + EdgeInsets get edgeInsetsScreenSymmetric => EdgeInsets.symmetric( + horizontal: paddingScreenHorizontal, vertical: paddingScreenVertical); + + static final dimensDesktop = DimensDesktop(); + static final dimensMobile = DimensMobile(); + + /// Get dimensions definition based on screen size + factory Dimens.of(BuildContext context) => + switch (MediaQuery.sizeOf(context).width) { + > 600 => dimensDesktop, + _ => dimensMobile, + }; +} + +/// Mobile dimensions +class DimensMobile extends Dimens { + @override + double paddingScreenHorizontal = Dimens.paddingHorizontal; + + @override + double paddingScreenVertical = Dimens.paddingVertical; +} + +/// Desktop/Web dimensions +class DimensDesktop extends Dimens { + @override + double paddingScreenHorizontal = 100.0; + + @override + double paddingScreenVertical = 64.0; +} diff --git a/compass_app/app/lib/ui/core/themes/theme.dart b/compass_app/app/lib/ui/core/themes/theme.dart index e8581d490..98ad13d9c 100644 --- a/compass_app/app/lib/ui/core/themes/theme.dart +++ b/compass_app/app/lib/ui/core/themes/theme.dart @@ -9,9 +9,23 @@ class AppTheme { fontWeight: FontWeight.w500, ), bodyMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + ), + bodyLarge: TextStyle( fontSize: 18, fontWeight: FontWeight.w400, ), + labelSmall: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: AppColors.grey3, + ), + labelLarge: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w400, + color: AppColors.grey3, + ), ); static const _inputDecorationTheme = InputDecorationTheme( diff --git a/compass_app/app/lib/ui/core/ui/custom_checkbox.dart b/compass_app/app/lib/ui/core/ui/custom_checkbox.dart new file mode 100644 index 000000000..a734d46cc --- /dev/null +++ b/compass_app/app/lib/ui/core/ui/custom_checkbox.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../themes/colors.dart'; + +class CustomCheckbox extends StatelessWidget { + const CustomCheckbox({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged<bool?> onChanged; + + @override + Widget build(BuildContext context) { + return InkResponse( + radius: 24, + onTap: () => onChanged(!value), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppColors.grey3), + ), + child: Material( + borderRadius: BorderRadius.circular(24), + color: value + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + child: SizedBox( + width: 24, + height: 24, + child: Visibility( + visible: value, + child: Icon( + Icons.check, + size: 14, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ), + ); + } +} diff --git a/compass_app/app/lib/ui/core/ui/error_indicator.dart b/compass_app/app/lib/ui/core/ui/error_indicator.dart new file mode 100644 index 000000000..3f28b3b0f --- /dev/null +++ b/compass_app/app/lib/ui/core/ui/error_indicator.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../themes/colors.dart'; + +class ErrorIndicator extends StatelessWidget { + const ErrorIndicator({ + super.key, + required this.title, + required this.label, + required this.onPressed, + }); + + final String title; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IntrinsicWidth( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.onError, + ), + const SizedBox(width: 10), + Text( + title, + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + ), + ), + ], + ), + ), + ), + ), + const SizedBox( + height: 10, + ), + FilledButton( + onPressed: onPressed, + style: const ButtonStyle( + backgroundColor: WidgetStatePropertyAll(AppColors.red1), + foregroundColor: WidgetStatePropertyAll(Colors.white), + ), + child: Text(label), + ), + ], + ); + } +} diff --git a/compass_app/app/lib/ui/core/ui/search_bar.dart b/compass_app/app/lib/ui/core/ui/search_bar.dart index 7edb6d213..89de234ca 100644 --- a/compass_app/app/lib/ui/core/ui/search_bar.dart +++ b/compass_app/app/lib/ui/core/ui/search_bar.dart @@ -1,6 +1,8 @@ import 'package:compass_model/model.dart'; import 'package:flutter/material.dart'; +import '../localization/applocalization.dart'; +import '../themes/dimens.dart'; import 'date_format_start_end.dart'; import '../themes/colors.dart'; import 'home_button.dart'; @@ -34,7 +36,9 @@ class AppSearchBar extends StatelessWidget { borderRadius: BorderRadius.circular(16.0), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric( + horizontal: Dimens.paddingHorizontal, + ), child: Align( alignment: AlignmentDirectional.centerStart, child: _QueryText(config: config), @@ -74,7 +78,7 @@ class _QueryText extends StatelessWidget { return Text( '$continent - ${dateFormatStartEnd(DateTimeRange(start: startDate, end: endDate))} - Guests: $guests', textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of(context).textTheme.bodyLarge, ); } } @@ -92,7 +96,7 @@ class _EmptySearch extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - 'Search destination', + AppLocalization.of(context).searchDestination, textAlign: TextAlign.start, style: Theme.of(context).inputDecorationTheme.hintStyle, ), diff --git a/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart b/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart index 2e50a9504..3b285673c 100644 --- a/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart +++ b/compass_app/app/lib/ui/results/view_models/results_viewmodel.dart @@ -1,4 +1,5 @@ import 'package:compass_model/model.dart'; +import 'package:logging/logging.dart'; import '../../../data/repositories/destination/destination_repository.dart'; import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart'; @@ -14,10 +15,12 @@ class ResultsViewModel extends ChangeNotifier { required ItineraryConfigRepository itineraryConfigRepository, }) : _destinationRepository = destinationRepository, _itineraryConfigRepository = itineraryConfigRepository { - updateItineraryConfig = Command1<bool, String>(_updateItineraryConfig); + updateItineraryConfig = Command1<void, String>(_updateItineraryConfig); search = Command0(_search)..execute(); } + final _log = Logger('ResultsViewModel'); + final DestinationRepository _destinationRepository; final ItineraryConfigRepository _itineraryConfigRepository; @@ -37,16 +40,17 @@ class ResultsViewModel extends ChangeNotifier { late final Command0 search; /// Store ViewModel data into [ItineraryConfigRepository] before navigating. - late final Command1<bool, String> updateItineraryConfig; + late final Command1<void, String> updateItineraryConfig; - Future<void> _search() async { + Future<Result<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; + _log.warning( + 'Failed to load stored ItineraryConfig', + resultConfig.asError.error, + ); + return resultConfig; } _itineraryConfig = resultConfig.asOk.value; notifyListeners(); @@ -60,45 +64,40 @@ class ResultsViewModel extends ChangeNotifier { .where((destination) => destination.continent == _itineraryConfig!.continent) .toList(); + _log.fine('Destinations (${_destinations.length}) loaded'); } case Error(): { - // TODO: Handle error - // ignore: avoid_print - print(result.error); + _log.warning('Failed to load destinations', result.error); } } // After finish loading results, notify the view notifyListeners(); + return result; } - Future<bool> _updateItineraryConfig(String destinationRef) async { + Future<Result<void>> _updateItineraryConfig(String destinationRef) async { 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; + _log.warning( + 'Failed to load stored ItineraryConfig', + resultConfig.asError.error, + ); + return resultConfig; } 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; - } + if (result is Error) { + _log.warning( + 'Failed to store ItineraryConfig', + result.asError.error, + ); } + return result; } } diff --git a/compass_app/app/lib/ui/results/widgets/results_screen.dart b/compass_app/app/lib/ui/results/widgets/results_screen.dart index dcddaef2b..b2e6aa71e 100644 --- a/compass_app/app/lib/ui/results/widgets/results_screen.dart +++ b/compass_app/app/lib/ui/results/widgets/results_screen.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../../../utils/result.dart'; +import '../../core/localization/applocalization.dart'; +import '../../core/themes/dimens.dart'; +import '../../core/ui/error_indicator.dart'; import '../../core/ui/search_bar.dart'; import '../view_models/results_viewmodel.dart'; import 'result_card.dart'; -class ResultsScreen extends StatelessWidget { +class ResultsScreen extends StatefulWidget { const ResultsScreen({ super.key, required this.viewModel, @@ -14,37 +16,70 @@ class ResultsScreen extends StatelessWidget { final ResultsViewModel viewModel; + @override + State<ResultsScreen> createState() => _ResultsScreenState(); +} + +class _ResultsScreenState extends State<ResultsScreen> { + @override + void initState() { + super.initState(); + widget.viewModel.updateItineraryConfig.addListener(_onResult); + } + + @override + void didUpdateWidget(covariant ResultsScreen oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult); + widget.viewModel.updateItineraryConfig.addListener(_onResult); + } + + @override + void dispose() { + widget.viewModel.updateItineraryConfig.removeListener(_onResult); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( body: ListenableBuilder( - listenable: viewModel.search, + listenable: widget.viewModel.search, builder: (context, child) { - if (viewModel.search.running) { - return const Center(child: CircularProgressIndicator()); + if (widget.viewModel.search.completed) { + return child!; } - return child!; + return Column( + children: [ + _AppSearchBar(widget: widget), + if (widget.viewModel.search.running) + const Expanded( + child: Center(child: CircularProgressIndicator())), + if (widget.viewModel.search.error) + Expanded( + child: Center( + child: ErrorIndicator( + title: AppLocalization.of(context) + .errorWhileLoadingDestinations, + label: AppLocalization.of(context).tryAgain, + onPressed: widget.viewModel.search.execute, + ), + ), + ), + ], + ); }, child: ListenableBuilder( - listenable: viewModel, + listenable: widget.viewModel, builder: (context, child) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), + padding: Dimens.of(context).edgeInsetsScreenHorizontal, 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('/'); - }, - ), - ), + child: _AppSearchBar(widget: widget), ), - _Grid(viewModel: viewModel), + _Grid(viewModel: widget.viewModel), ], ), ); @@ -53,6 +88,47 @@ class ResultsScreen extends StatelessWidget { ), ); } + + void _onResult() { + if (widget.viewModel.updateItineraryConfig.completed) { + widget.viewModel.updateItineraryConfig.clearResult(); + context.go('/activities'); + } + + if (widget.viewModel.updateItineraryConfig.error) { + widget.viewModel.updateItineraryConfig.clearResult(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalization.of(context).errorWhileSavingItinerary), + ), + ); + } + } +} + +class _AppSearchBar extends StatelessWidget { + const _AppSearchBar({ + required this.widget, + }); + + final ResultsScreen widget; + + @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('/'); + }, + ), + ); + } } class _Grid extends StatelessWidget { @@ -78,14 +154,7 @@ class _Grid extends StatelessWidget { key: ValueKey(destination.ref), destination: destination, onTap: () { - viewModel.updateItineraryConfig.execute( - argument: destination.ref, - onComplete: (result) { - if (result) { - context.go('/activities'); - } - }, - ); + viewModel.updateItineraryConfig.execute(destination.ref); }, ); }, diff --git a/compass_app/app/lib/ui/search_form/view_models/search_form_viewmodel.dart b/compass_app/app/lib/ui/search_form/view_models/search_form_viewmodel.dart index 4b479cb65..792b5058e 100644 --- a/compass_app/app/lib/ui/search_form/view_models/search_form_viewmodel.dart +++ b/compass_app/app/lib/ui/search_form/view_models/search_form_viewmodel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:compass_model/model.dart'; +import 'package:logging/logging.dart'; import '../../../data/repositories/continent/continent_repository.dart'; import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart'; @@ -20,6 +21,7 @@ class SearchFormViewModel extends ChangeNotifier { load = Command0(_load)..execute(); } + final _log = Logger('SearchFormViewModel'); final ContinentRepository _continentRepository; final ItineraryConfigRepository _itineraryConfigRepository; List<Continent> _continents = []; @@ -43,6 +45,7 @@ class SearchFormViewModel extends ChangeNotifier { /// Set to null to clear the selection. set selectedContinent(String? continent) { _selectedContinent = continent; + _log.finest('Selected continent: $continent'); notifyListeners(); } @@ -54,6 +57,7 @@ class SearchFormViewModel extends ChangeNotifier { /// Can be set to null to clear selection. set dateRange(DateTimeRange? dateRange) { _dateRange = dateRange; + _log.finest('Selected date range: $dateRange'); notifyListeners(); } @@ -68,6 +72,7 @@ class SearchFormViewModel extends ChangeNotifier { } else { _guests = quantity; } + _log.finest('Set guests number: $_guests'); notifyListeners(); } @@ -75,31 +80,34 @@ class SearchFormViewModel extends ChangeNotifier { late final Command0 load; /// Store ViewModel data into [ItineraryConfigRepository] before navigating. - late final Command0<bool> updateItineraryConfig; + late final Command0 updateItineraryConfig; - Future<void> _load() async { - await _loadContinents(); - await _loadItineraryConfig(); + Future<Result<void>> _load() async { + final result = await _loadContinents(); + if (result is Error) { + return result; + } + return await _loadItineraryConfig(); } - Future<void> _loadContinents() async { + Future<Result<void>> _loadContinents() async { final result = await _continentRepository.getContinents(); switch (result) { case Ok(): { _continents = result.value; + _log.fine('Continents (${_continents.length}) loaded'); } case Error(): { - // TODO: Handle error - // ignore: avoid_print - print(result.error); + _log.warning('Failed to load continents', result.asError.error); } } notifyListeners(); + return result; } - Future<void> _loadItineraryConfig() async { + Future<Result<void>> _loadItineraryConfig() async { final result = await _itineraryConfigRepository.getItineraryConfig(); switch (result) { case Ok<ItineraryConfig>(): @@ -114,38 +122,36 @@ class SearchFormViewModel extends ChangeNotifier { ); } _guests = itineraryConfig.guests ?? 0; + _log.fine('ItineraryConfig loaded'); notifyListeners(); } case Error<ItineraryConfig>(): { - // TODO: Handle error - // ignore: avoid_print - print(result.error); + _log.warning( + 'Failed to load stored ItineraryConfig', + result.asError.error, + ); } } + return result; } - Future<bool> _updateItineraryConfig() async { + Future<Result<void>> _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, - )); + final result = await _itineraryConfigRepository.setItineraryConfig( + ItineraryConfig( + continent: _selectedContinent, + startDate: _dateRange!.start, + endDate: _dateRange!.end, + guests: _guests, + ), + ); switch (result) { case Ok<void>(): - { - return true; - } + _log.fine('ItineraryConfig saved'); case Error<void>(): - { - // TODO: Handle error - // ignore: avoid_print - print(result.error); - return false; - } + _log.warning('Failed to store ItineraryConfig', result.error); } + return result; } } diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_continent.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_continent.dart index 4bae5d357..4b3c77756 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_continent.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_continent.dart @@ -3,7 +3,10 @@ import 'package:compass_model/model.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../core/localization/applocalization.dart'; import '../../core/themes/colors.dart'; +import '../../core/themes/dimens.dart'; +import '../../core/ui/error_indicator.dart'; import '../view_models/search_form_viewmodel.dart'; /// Continent selection carousel @@ -31,6 +34,15 @@ class SearchFormContinent extends StatelessWidget { child: CircularProgressIndicator(), ); } + if (viewModel.load.error) { + return Center( + child: ErrorIndicator( + title: AppLocalization.of(context).errorWhileLoadingContinents, + label: AppLocalization.of(context).tryAgain, + onPressed: viewModel.load.execute, + ), + ); + } return child!; }, child: ListenableBuilder( @@ -39,7 +51,7 @@ class SearchFormContinent extends StatelessWidget { return ListView.separated( scrollDirection: Axis.horizontal, itemCount: viewModel.continents.length, - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: Dimens.of(context).edgeInsetsScreenHorizontal, itemBuilder: (BuildContext context, int index) { final Continent(:imageUrl, :name) = viewModel.continents[index]; return _CarouselItem( diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_date.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_date.dart index 8b27ff672..f096a852b 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_date.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_date.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import '../../core/localization/applocalization.dart'; +import '../../core/themes/dimens.dart'; import '../../core/ui/date_format_start_end.dart'; import '../../core/themes/colors.dart'; import '../view_models/search_form_viewmodel.dart'; @@ -19,7 +20,11 @@ class SearchFormDate extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(top: 24, left: 20, right: 20), + padding: EdgeInsets.only( + top: Dimens.paddingVertical, + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + ), child: InkWell( borderRadius: BorderRadius.circular(16.0), onTap: () { @@ -36,12 +41,14 @@ class SearchFormDate extends StatelessWidget { borderRadius: BorderRadius.circular(16.0), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric( + horizontal: Dimens.paddingHorizontal, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'When', + AppLocalization.of(context).when, style: Theme.of(context).textTheme.titleMedium, ), ListenableBuilder( @@ -51,11 +58,11 @@ class SearchFormDate extends StatelessWidget { if (dateRange != null) { return Text( dateFormatStartEnd(dateRange), - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of(context).textTheme.bodyLarge, ); } else { return Text( - 'Add Dates', + AppLocalization.of(context).addDates, style: Theme.of(context).inputDecorationTheme.hintStyle, ); } diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_guests.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_guests.dart index 4980a206b..9667bdda8 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_guests.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_guests.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/themes/colors.dart'; +import '../../core/themes/dimens.dart'; import '../view_models/search_form_viewmodel.dart'; /// Number of guests selection form @@ -18,7 +19,11 @@ class SearchFormGuests extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(top: 24, left: 20, right: 20), + padding: EdgeInsets.only( + top: Dimens.paddingVertical, + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + ), child: Container( height: 64, decoration: BoxDecoration( @@ -26,7 +31,9 @@ class SearchFormGuests extends StatelessWidget { borderRadius: BorderRadius.circular(16.0), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric( + horizontal: Dimens.paddingHorizontal, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart index 85b597ef1..42f4f39c5 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/themes/dimens.dart'; import '../../core/ui/search_bar.dart'; import '../../results/widgets/results_screen.dart'; import '../view_models/search_form_viewmodel.dart'; @@ -27,12 +28,14 @@ class SearchFormScreen extends StatelessWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.symmetric( - horizontal: 20, - vertical: 24, + Padding( + padding: EdgeInsets.only( + top: Dimens.of(context).paddingScreenVertical, + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + bottom: Dimens.paddingVertical, ), - child: AppSearchBar(), + child: const AppSearchBar(), ), SearchFormContinent(viewModel: viewModel), SearchFormDate(viewModel: viewModel), diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart index d257af369..b97a526bc 100644 --- a/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../../../utils/result.dart'; +import '../../core/localization/applocalization.dart'; +import '../../core/themes/dimens.dart'; import '../../results/widgets/results_screen.dart'; import '../view_models/search_form_viewmodel.dart'; @@ -10,7 +11,7 @@ import '../view_models/search_form_viewmodel.dart'; /// The button is disabled when the form is data is incomplete. /// When tapped, it navigates to the [ResultsScreen] /// passing the search options as query parameters. -class SearchFormSubmit extends StatelessWidget { +class SearchFormSubmit extends StatefulWidget { const SearchFormSubmit({ super.key, required this.viewModel, @@ -18,31 +19,52 @@ class SearchFormSubmit extends StatelessWidget { final SearchFormViewModel viewModel; + @override + State<SearchFormSubmit> createState() => _SearchFormSubmitState(); +} + +class _SearchFormSubmitState extends State<SearchFormSubmit> { + @override + void initState() { + super.initState(); + widget.viewModel.updateItineraryConfig.addListener(_onResult); + } + + @override + void didUpdateWidget(covariant SearchFormSubmit oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult); + widget.viewModel.updateItineraryConfig.addListener(_onResult); + } + + @override + void dispose() { + widget.viewModel.updateItineraryConfig.removeListener(_onResult); + super.dispose(); + } + @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + padding: EdgeInsets.only( + top: Dimens.paddingVertical, + left: Dimens.of(context).paddingScreenHorizontal, + right: Dimens.of(context).paddingScreenHorizontal, + bottom: Dimens.of(context).paddingScreenVertical, + ), child: ListenableBuilder( - listenable: viewModel, - child: const SizedBox( + listenable: widget.viewModel, + child: SizedBox( height: 52, child: Center( - child: Text('Search'), + child: Text(AppLocalization.of(context).search), ), ), builder: (context, child) { return FilledButton( key: const ValueKey('submit_button'), - onPressed: viewModel.valid - ? () async { - await viewModel.updateItineraryConfig.execute( - onComplete: (result) { - if (result) { - context.go('/results'); - } - }, - ); - } + onPressed: widget.viewModel.valid + ? widget.viewModel.updateItineraryConfig.execute : null, child: child, ); @@ -50,4 +72,22 @@ class SearchFormSubmit extends StatelessWidget { ), ); } + + void _onResult() { + if (widget.viewModel.updateItineraryConfig.completed) { + widget.viewModel.updateItineraryConfig.clearResult(); + context.go('/results'); + } + + if (widget.viewModel.updateItineraryConfig.error) { + widget.viewModel.updateItineraryConfig.clearResult(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalization.of(context).errorWhileSavingItinerary), + action: SnackBarAction( + label: AppLocalization.of(context).tryAgain, + onPressed: widget.viewModel.updateItineraryConfig.execute, + ), + )); + } + } } diff --git a/compass_app/app/lib/utils/command.dart b/compass_app/app/lib/utils/command.dart index bb133b2bd..40469afb6 100644 --- a/compass_app/app/lib/utils/command.dart +++ b/compass_app/app/lib/utils/command.dart @@ -1,17 +1,25 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; -typedef CommandAction0<T> = Future<T> Function(); -typedef CommandAction1<T, A> = Future<T> Function(A); -typedef OnComplete<T> = Function(T); +import 'result.dart'; + +typedef CommandAction0<T> = Future<Result<T>> Function(); +typedef CommandAction1<T, A> = Future<Result<T>> Function(A); /// Facilitates interaction with a ViewModel. /// /// Encapsulates an action, -/// exposes its running state, +/// exposes its running and error states, /// 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. +/// +/// Actions must return a [Result]. +/// +/// Consume the action result by listening to changes, +/// then call to [clearResult] when the state is consumed. abstract class Command<T> extends ChangeNotifier { Command(); @@ -20,11 +28,25 @@ abstract class Command<T> extends ChangeNotifier { /// True when the action is running. bool get running => _running; + Result<T>? _result; + + /// true if action completed with error + bool get error => _result is Error; + + /// true if action completed successfully + bool get completed => _result is Ok; + + /// Get last action result + Result? get result => _result; + + /// Clear last action result + void clearResult() { + _result = null; + notifyListeners(); + } + /// Internal execute implementation - Future<void> _execute( - CommandAction0<T> action, - OnComplete<T>? onComplete, - ) async { + Future<void> _execute(CommandAction0<T> action) async { // Ensure the action can't launch multiple times. // e.g. avoid multiple taps on button if (_running) return; @@ -32,11 +54,11 @@ abstract class Command<T> extends ChangeNotifier { // Notify listeners. // e.g. button shows loading state _running = true; + _result = null; notifyListeners(); try { - final result = await action(); - onComplete?.call(result); + _result = await action(); } finally { _running = false; notifyListeners(); @@ -52,9 +74,8 @@ class Command0<T> extends Command<T> { 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); + Future<void> execute() async { + await _execute(() => _action()); } } @@ -66,8 +87,7 @@ class Command1<T, A> extends Command<T> { 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); + Future<void> execute(A argument) async { + await _execute(() => _action(argument)); } } diff --git a/compass_app/app/pubspec.yaml b/compass_app/app/pubspec.yaml index 3fdd5e5e1..f922c1e20 100644 --- a/compass_app/app/pubspec.yaml +++ b/compass_app/app/pubspec.yaml @@ -12,9 +12,12 @@ dependencies: path: ../model flutter: sdk: flutter + flutter_localizations: + sdk: flutter go_router: ^14.2.0 google_fonts: ^6.2.1 - intl: ^0.19.0 + intl: any + logging: ^1.2.0 provider: ^6.1.2 dev_dependencies: diff --git a/compass_app/app/test/ui/activities/activities_screen_test.dart b/compass_app/app/test/ui/activities/activities_screen_test.dart new file mode 100644 index 000000000..3d17abc52 --- /dev/null +++ b/compass_app/app/test/ui/activities/activities_screen_test.dart @@ -0,0 +1,68 @@ +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_image_network/mocktail_image_network.dart'; + +import '../../util/fakes/repositories/fake_activities_repository.dart'; +import '../../util/fakes/repositories/fake_itinerary_config_repository.dart'; + +void main() { + group('ResultsScreen widget tests', () { + late ActivitiesViewModel viewModel; + + setUp(() { + viewModel = ActivitiesViewModel( + activityRepository: FakeActivityRepository(), + itineraryConfigRepository: FakeItineraryConfigRepository( + itineraryConfig: ItineraryConfig( + continent: 'Europe', + startDate: DateTime(2024, 01, 01), + endDate: DateTime(2024, 01, 31), + guests: 2, + destination: 'DESTINATION', + ), + ), + ); + }); + + // 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, + ), + ), + ); + } + + testWidgets('should load screen', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + expect(find.byType(ActivitiesScreen), findsOneWidget); + }); + }); + + testWidgets('should list activity', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + expect(find.byType(ActivityEntry), findsOneWidget); + expect(find.text('NAME'), findsOneWidget); + }); + }); + + testWidgets('should select activity', (WidgetTester tester) async { + await mockNetworkImages(() async { + await loadScreen(tester); + await tester.tap(find.byKey(const ValueKey('REF-checkbox'))); + expect(viewModel.selectedActivities, contains('REF')); + }); + }); + }); +} diff --git a/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart b/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart index 7d452ec0c..77a0a208c 100644 --- a/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart +++ b/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart @@ -62,11 +62,8 @@ void main() { viewModel.dateRange = newDateRange; expect(viewModel.valid, true); - await viewModel.updateItineraryConfig.execute( - onComplete: (result) { - expect(result, true); - }, - ); + await viewModel.updateItineraryConfig.execute(); + expect(viewModel.updateItineraryConfig.completed, true); }); }); } diff --git a/compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart b/compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart new file mode 100644 index 000000000..969d789ef --- /dev/null +++ b/compass_app/app/test/util/fakes/repositories/fake_activities_repository.dart @@ -0,0 +1,27 @@ +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]!); + } +} diff --git a/compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart b/compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart index 2920101f8..f01f23bc3 100644 --- a/compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart +++ b/compass_app/app/test/util/fakes/repositories/fake_itinerary_config_repository.dart @@ -2,8 +2,6 @@ import 'package:compass_app/data/repositories/itinerary_config/itinerary_config_ 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}); diff --git a/compass_app/app/test/util/fakes/services/fake_api_client.dart b/compass_app/app/test/util/fakes/services/fake_api_client.dart index c85687e65..1106dd350 100644 --- a/compass_app/app/test/util/fakes/services/fake_api_client.dart +++ b/compass_app/app/test/util/fakes/services/fake_api_client.dart @@ -55,7 +55,7 @@ class FakeApiClient implements ApiClient { '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', + timeOfDay: TimeOfDay.morning, familyFriendly: false, price: 4, destinationRef: 'alaska', diff --git a/compass_app/app/test/utils/command_test.dart b/compass_app/app/test/utils/command_test.dart index dc83cd811..2d0c712e5 100644 --- a/compass_app/app/test/utils/command_test.dart +++ b/compass_app/app/test/utils/command_test.dart @@ -1,40 +1,34 @@ -import 'dart:math'; - import 'package:compass_app/utils/command.dart'; +import 'package:compass_app/utils/result.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; + final command = Command0<void>(() => Future.value(Result.ok(null))); // Run void action - await command.execute(onComplete: (_) { - completed = true; - }); + await command.execute(); // Action completed - expect(completed, true); + expect(command.completed, true); }); test('should complete bool command', () async { // Action that returns bool - final command = Command0<bool>(() => Future.value(true)); - bool completed = false; + final command = Command0<bool>(() => Future.value(Result.ok(true))); // Run action with result - await command.execute(onComplete: (value) { - completed = value; - }); + await command.execute(); - // Action completed with result - expect(completed, true); + // Action completed + expect(command.completed, true); + expect(command.result!.asOk.value, true); }); test('running should be true', () async { - final command = Command0<void>(() => Future.value()); + final command = Command0<void>(() => Future.value(Result.ok(null))); final future = command.execute(); // Action is running @@ -49,7 +43,7 @@ void main() { test('should only run once', () async { int count = 0; - final command = Command0<int>(() => Future.value(count++)); + final command = Command0<int>(() => Future.value(Result.ok(count++))); final future = command.execute(); // Run multiple times @@ -64,6 +58,14 @@ void main() { // Action is called once expect(count, 1); }); + + test('should handle errors', () async { + final command = + Command0<int>(() => Future.value(Result.error(Exception('ERROR!')))); + await command.execute(); + expect(command.error, true); + expect(command.result, isA<Error>()); + }); }); group('Command1 tests', () { @@ -71,37 +73,26 @@ void main() { // Void action with bool argument final command = Command1<void, bool>((a) { expect(a, true); - return Future.value(); + return Future.value(Result.ok(null)); }); - bool completed = false; - // Run void action, ignore void return - await command.execute( - argument: true, - onComplete: (_) { - completed = true; - }, - ); - - expect(completed, true); + await command.execute(true); + + expect(command.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; + final command = + Command1<bool, bool>((a) => Future.value(Result.ok(true))); // Run action with result and argument - await command.execute( - argument: true, - onComplete: (value) { - completed = value; - }, - ); + await command.execute(true); // Argument was passed to onComplete - expect(completed, true); + expect(command.completed, true); + expect(command.result!.asOk.value, true); }); }); } diff --git a/compass_app/model/lib/src/model/activity/activity.dart b/compass_app/model/lib/src/model/activity/activity.dart index 25e72b85a..f5d650564 100644 --- a/compass_app/model/lib/src/model/activity/activity.dart +++ b/compass_app/model/lib/src/model/activity/activity.dart @@ -4,6 +4,14 @@ part 'activity.freezed.dart'; part 'activity.g.dart'; +enum TimeOfDay { + any, + morning, + afternoon, + evening, + night, +} + @freezed class Activity with _$Activity { const factory Activity({ @@ -21,7 +29,7 @@ class Activity with _$Activity { required int duration, /// e.g. 'morning' - required String timeOfDay, + required TimeOfDay timeOfDay, /// e.g. false required bool familyFriendly, diff --git a/compass_app/model/lib/src/model/activity/activity.freezed.dart b/compass_app/model/lib/src/model/activity/activity.freezed.dart index 19a62c71e..6e277d0bc 100644 --- a/compass_app/model/lib/src/model/activity/activity.freezed.dart +++ b/compass_app/model/lib/src/model/activity/activity.freezed.dart @@ -34,7 +34,7 @@ mixin _$Activity { int get duration => throw _privateConstructorUsedError; /// e.g. 'morning' - String get timeOfDay => throw _privateConstructorUsedError; + TimeOfDay get timeOfDay => throw _privateConstructorUsedError; /// e.g. false bool get familyFriendly => throw _privateConstructorUsedError; @@ -71,7 +71,7 @@ abstract class $ActivityCopyWith<$Res> { String description, String locationName, int duration, - String timeOfDay, + TimeOfDay timeOfDay, bool familyFriendly, int price, String destinationRef, @@ -125,7 +125,7 @@ class _$ActivityCopyWithImpl<$Res, $Val extends Activity> timeOfDay: null == timeOfDay ? _value.timeOfDay : timeOfDay // ignore: cast_nullable_to_non_nullable - as String, + as TimeOfDay, familyFriendly: null == familyFriendly ? _value.familyFriendly : familyFriendly // ignore: cast_nullable_to_non_nullable @@ -163,7 +163,7 @@ abstract class _$$ActivityImplCopyWith<$Res> String description, String locationName, int duration, - String timeOfDay, + TimeOfDay timeOfDay, bool familyFriendly, int price, String destinationRef, @@ -215,7 +215,7 @@ class __$$ActivityImplCopyWithImpl<$Res> timeOfDay: null == timeOfDay ? _value.timeOfDay : timeOfDay // ignore: cast_nullable_to_non_nullable - as String, + as TimeOfDay, familyFriendly: null == familyFriendly ? _value.familyFriendly : familyFriendly // ignore: cast_nullable_to_non_nullable @@ -277,7 +277,7 @@ class _$ActivityImpl implements _Activity { /// e.g. 'morning' @override - final String timeOfDay; + final TimeOfDay timeOfDay; /// e.g. false @override @@ -365,7 +365,7 @@ abstract class _Activity implements Activity { required final String description, required final String locationName, required final int duration, - required final String timeOfDay, + required final TimeOfDay timeOfDay, required final bool familyFriendly, required final int price, required final String destinationRef, @@ -394,7 +394,7 @@ abstract class _Activity implements Activity { /// e.g. 'morning' @override - String get timeOfDay; + TimeOfDay get timeOfDay; /// e.g. false @override diff --git a/compass_app/model/lib/src/model/activity/activity.g.dart b/compass_app/model/lib/src/model/activity/activity.g.dart index c7431a13b..8ad67d674 100644 --- a/compass_app/model/lib/src/model/activity/activity.g.dart +++ b/compass_app/model/lib/src/model/activity/activity.g.dart @@ -12,7 +12,7 @@ _$ActivityImpl _$$ActivityImplFromJson(Map<String, dynamic> json) => description: json['description'] as String, locationName: json['locationName'] as String, duration: (json['duration'] as num).toInt(), - timeOfDay: json['timeOfDay'] as String, + timeOfDay: $enumDecode(_$TimeOfDayEnumMap, json['timeOfDay']), familyFriendly: json['familyFriendly'] as bool, price: (json['price'] as num).toInt(), destinationRef: json['destinationRef'] as String, @@ -26,10 +26,18 @@ Map<String, dynamic> _$$ActivityImplToJson(_$ActivityImpl instance) => 'description': instance.description, 'locationName': instance.locationName, 'duration': instance.duration, - 'timeOfDay': instance.timeOfDay, + 'timeOfDay': _$TimeOfDayEnumMap[instance.timeOfDay]!, 'familyFriendly': instance.familyFriendly, 'price': instance.price, 'destinationRef': instance.destinationRef, 'ref': instance.ref, 'imageUrl': instance.imageUrl, }; + +const _$TimeOfDayEnumMap = { + TimeOfDay.any: 'any', + TimeOfDay.morning: 'morning', + TimeOfDay.afternoon: 'afternoon', + TimeOfDay.evening: 'evening', + TimeOfDay.night: 'night', +}; diff --git a/compass_app/server/assets/destinations.json b/compass_app/server/assets/destinations.json deleted file mode 100644 index be1f233c5..000000000 --- a/compass_app/server/assets/destinations.json +++ /dev/null @@ -1,1235 +0,0 @@ -[ - { - "ref": "alaska", - "name": "Alaska", - "country": "United States", - "continent": "North America", - "knownFor": "Alaska is a haven for outdoor enthusiasts and nature lovers. Visitors can experience glaciers, mountains, and wildlife, making it ideal for hiking, kayaking, and wildlife viewing. Alaska also offers a unique cultural experience with its rich Native American heritage and frontier spirit.", - "tags": ["Mountain", "Off-the-beaten-path", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg" - }, - { - "ref": "amalfi-coast", - "name": "Amalfi Coast", - "country": "Italy", - "continent": "Europe", - "knownFor": "Experience the breathtaking beauty of the Amalfi Coast, with its dramatic cliffs, colorful villages, and turquoise waters. Indulge in delicious Italian cuisine, explore charming towns like Positano and Amalfi, and soak up the sun on picturesque beaches.", - "tags": ["Beach", "Romantic", "Foodie"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/amalfi-coast.jpg" - }, - { - "ref": "amazon-rainforest", - "name": "Amazon Rainforest", - "country": "Brazil", - "continent": "South America", - "knownFor": "Immerse yourself in the biodiversity of the world's largest rainforest. Embark on jungle treks, spot exotic wildlife, and discover indigenous cultures. Take a boat trip down the Amazon River, explore the canopy on a zipline, and experience the unique ecosystem.", - "tags": ["Jungle", "Wildlife watching", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/amazon-rainforest.jpg" - }, - { - "ref": "andes-mountains", - "name": "The Andes Mountains", - "country": "South America", - "continent": "South America", - "knownFor": "The Andes Mountains, stretching along the western coast of South America, offer diverse landscapes and experiences. Visitors can trek to Machu Picchu in Peru, explore the salt flats of Salar de Uyuni in Bolivia, or visit the glaciers of Patagonia. The Andes also provide opportunities for skiing, mountaineering, and cultural encounters with indigenous communities.", - "tags": ["Mountain", "Hiking", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/andes-mountains.jpg" - }, - { - "ref": "angkor-wat", - "name": "Angkor Wat", - "country": "Cambodia", - "continent": "Asia", - "knownFor": "Angkor Wat, a vast temple complex in Cambodia, is the largest religious monument in the world and a UNESCO World Heritage site. Visitors can explore the intricate carvings, towering spires, and vast courtyards, marveling at the architectural grandeur and rich history of the Khmer Empire.", - "tags": ["Historic", "Cultural experiences", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/angkor-wat.jpg" - }, - { - "ref": "antelope-canyon", - "name": "Antelope Canyon", - "country": "United States", - "continent": "North America", - "knownFor": "Experience the awe-inspiring beauty of Antelope Canyon, a slot canyon renowned for its flowing sandstone formations and mesmerizing light beams. Embark on guided tours to navigate the narrow passageways and capture stunning photographs. Learn about the Navajo Nation's history and culture, as the canyon is located on their land.", - "tags": ["Off-the-beaten-path", "Hiking", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/antelope-canyon.jpg" - }, - { - "ref": "aruba", - "name": "Aruba", - "country": "Aruba", - "continent": "South America", - "knownFor": "Indulge in the beauty of Aruba, a Caribbean paradise known for its pristine beaches, turquoise waters, and year-round sunshine. Relax on the white sands of Eagle Beach, explore the vibrant capital of Oranjestad, and discover hidden coves and natural wonders. Aruba offers a perfect escape for beach lovers and water sports enthusiasts.", - "tags": ["Beach", "Island", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/aruba.jpg" - }, - { - "ref": "asheville", - "name": "Asheville", - "country": "USA", - "continent": "North America", - "knownFor": "Asheville, nestled in the Blue Ridge Mountains of North Carolina, is a vibrant city known for its arts scene, craft breweries, and outdoor activities. Visitors can explore the historic Biltmore Estate, hike in the surrounding mountains, sample local beers, and enjoy the city's eclectic shops and restaurants.", - "tags": ["City", "Hiking", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/asheville.jpg" - }, - { - "ref": "azores", - "name": "Azores", - "country": "Portugal", - "continent": "Europe", - "knownFor": "This archipelago in the mid-Atlantic boasts stunning volcanic landscapes, lush green hills, and dramatic coastlines. Hike to crater lakes, soak in natural hot springs, or go whale watching in the surrounding waters. With its relaxed atmosphere and unique culture, the Azores is a perfect destination for nature lovers and adventurers seeking an off-the-beaten-path experience.", - "tags": ["Island", "Off-the-beaten-path", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/azores.jpg" - }, - { - "ref": "bali", - "name": "Bali", - "country": "Indonesia", - "continent": "Asia", - "knownFor": "Discover the cultural heart of Indonesia on the island of Bali. Explore ancient temples, experience vibrant Hindu traditions, and relax on beautiful beaches. Practice yoga and meditation, indulge in spa treatments, and enjoy the island's lush natural beauty.", - "tags": ["Island", "Cultural experiences", "Wellness retreats"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/bali.jpg" - }, - { - "ref": "banff-national-park", - "name": "Banff National Park", - "country": "Canada", - "continent": "North America", - "knownFor": "Nestled in the Canadian Rockies, Banff National Park offers stunning mountain scenery, turquoise lakes, and abundant wildlife. Hike to Lake Louise, explore Johnston Canyon, and enjoy outdoor activities year-round.", - "tags": ["Mountain", "Hiking", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/banff-national-park.jpg" - }, - { - "ref": "belize", - "name": "Belize", - "country": "Belize", - "continent": "North America", - "knownFor": "Embark on an unforgettable adventure in Belize, a Central American gem boasting lush rainforests, ancient Maya ruins, and the world's second-largest barrier reef. Explore the mysteries of Caracol and Xunantunich, dive into the Great Blue Hole, and discover diverse marine life. Belize offers a unique blend of cultural exploration and eco-tourism.", - "tags": ["Jungle", "Historic", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/belize.jpg" - }, - { - "ref": "bhutan", - "name": "Bhutan", - "country": "Bhutan", - "continent": "Asia", - "knownFor": "Discover the mystical kingdom of Bhutan, nestled in the Himalayas. Explore ancient monasteries, hike through pristine valleys, and experience the unique culture and traditions. Immerse yourself in the spiritual atmosphere and embrace the concept of Gross National Happiness.", - "tags": ["Mountain", "Cultural experiences", "Off-the-beaten-path"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/bhutan.jpg" - }, - { - "ref": "big-island-hawaii", - "name": "Big Island", - "country": "United States", - "continent": "North America", - "knownFor": "The Big Island of Hawaii offers diverse landscapes, from volcanic craters and black sand beaches to lush rainforests and snow-capped mountains. Visitors can witness the fiery glow of Kilauea volcano, snorkel with manta rays, and stargaze atop Mauna Kea.", - "tags": ["Island", "Hiking", "Snorkeling"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/big-island-hawaii.jpg" - }, - { - "ref": "big-sur", - "name": "Big Sur", - "country": "United States", - "continent": "North America", - "knownFor": "Experience the breathtaking beauty of California's rugged coastline along Big Sur. Drive the iconic Pacific Coast Highway, stopping at dramatic cliffs, hidden coves, and redwood forests. Enjoy hiking, camping, or simply soaking up the awe-inspiring views of this natural wonderland.", - "tags": ["Road trip destination", "Secluded", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/big-sur.jpg" - }, - { - "ref": "bora-bora", - "name": "Bora Bora", - "country": "French Polynesia", - "continent": "Oceania", - "knownFor": "Bora Bora is synonymous with luxury and tranquility. Overwater bungalows perched above turquoise lagoons offer a unique and indulgent experience. Visitors can enjoy snorkeling, diving, and swimming amidst vibrant coral reefs and diverse marine life. The island's lush interior provides opportunities for hiking and exploring, while Polynesian culture adds a touch of exotic charm.", - "tags": ["Island", "Luxury", "Secluded"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/bora-bora.jpg" - }, - { - "ref": "botswana", - "name": "Okavango Delta", - "country": "Botswana", - "continent": "Africa", - "knownFor": "The Okavango Delta, a unique inland delta in Botswana, is a haven for wildlife enthusiasts. Visitors can embark on safari adventures, encountering elephants, lions, hippos, and a diverse array of birds. The delta's waterways offer opportunities for mokoro (canoe) excursions and boat tours, providing a close-up view of the abundant wildlife and stunning landscapes.", - "tags": ["Wildlife watching", "Adventure sports", "Luxury"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/botswana.jpg" - }, - { - "ref": "bruges", - "name": "Bruges", - "country": "Belgium", - "continent": "Europe", - "knownFor": "Step back in time in this charming medieval city with its cobblestone streets, canals, and well-preserved architecture. Explore the historic Markt square, indulge in delicious Belgian chocolate and beer, or take a boat tour through the canals. Bruges offers a romantic and picturesque escape for history buffs and those seeking a quintessential European experience.", - "tags": ["City", "Historic", "Romantic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/bruges.jpg" - }, - { - "ref": "brunei", - "name": "Brunei", - "country": "Brunei", - "continent": "Asia", - "knownFor": "This small sultanate on the island of Borneo offers a fascinating blend of culture and nature. Visitors can explore the opulent Sultan Omar Ali Saifuddin Mosque, delve into the lush rainforests, and discover the unique water village of Kampong Ayer.", - "tags": ["Secluded", "Cultural experiences", "Island"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/brunei.jpg" - }, - { - "ref": "budapest", - "name": "Budapest", - "country": "Hungary", - "continent": "Europe", - "knownFor": "Discover the charm of Budapest, a historic city divided by the Danube River. Explore Buda's Castle District with its medieval streets and Fisherman's Bastion, offering panoramic views. Relax in the thermal baths, a legacy of the Ottoman era, or visit the Hungarian Parliament Building, a stunning example of Gothic Revival architecture.", - "tags": ["City", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/budapest.jpg" - }, - { - "ref": "burgundy", - "name": "Burgundy", - "country": "France", - "continent": "Europe", - "knownFor": "Burgundy, a region in eastern France, is renowned for its world-class wines, charming villages, and rich history. Explore vineyards, indulge in wine tastings, and visit medieval castles and abbeys. Cycle through rolling hills, savor gourmet cuisine, and experience the art de vivre of this picturesque region.", - "tags": ["Rural", "Wine tasting", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/burgundy.jpg" - }, - { - "ref": "cambodia", - "name": "Cambodia", - "country": "Cambodia", - "continent": "Asia", - "knownFor": "Cambodia, a Southeast Asian nation, is a captivating blend of ancient wonders, natural beauty, and vibrant culture. Explore the magnificent temples of Angkor, relax on pristine beaches, and cruise along the Mekong River. Experience the warmth of the Cambodian people, savor delicious cuisine, and discover the rich history of this fascinating country.", - "tags": ["Historic", "Cultural experiences", "Beach"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/cambodia.jpg" - }, - { - "ref": "canadian-rockies", - "name": "Canadian Rockies", - "country": "Canada", - "continent": "North America", - "knownFor": "Embark on an adventure through the majestic Canadian Rockies, where towering mountains, turquoise lakes, and glaciers create a breathtaking landscape. Hike through scenic trails, go skiing or snowboarding in world-class resorts, and encounter diverse wildlife. Experience the thrill of outdoor activities and the beauty of untouched nature.", - "tags": ["Mountain", "Hiking", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/canadian-rockies.jpg" - }, - { - "ref": "canary-islands", - "name": "Canary Islands", - "country": "Spain", - "continent": "Europe", - "knownFor": "Discover the volcanic beauty of the Canary Islands, a Spanish archipelago off the coast of Africa. Explore diverse landscapes, from the dramatic volcanic peaks of Tenerife and Mount Teide to the golden beaches of Gran Canaria and Fuerteventura. Enjoy water sports, hiking, and stargazing in this island paradise.", - "tags": ["Island", "Beach", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/canary-islands.jpg" - }, - { - "ref": "cappadocia", - "name": "Cappadocia", - "country": "Turkey", - "continent": "Asia", - "knownFor": "Embark on a magical journey through a surreal landscape of fairy chimneys, cave dwellings, and underground cities. Soar above the valleys in a hot air balloon, explore ancient rock-cut churches, and experience the unique culture and hospitality of the region.", - "tags": ["Historic", "Off-the-beaten-path", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/cappadocia.jpg" - }, - { - "ref": "chilean-lake-district", - "name": "Chilean Lake District", - "country": "Chile", - "continent": "South America", - "knownFor": "The Chilean Lake District is a paradise for nature lovers, with snow-capped volcanoes, turquoise lakes, and lush forests. Hike in national parks, go kayaking on the lakes, and enjoy the tranquility of the surroundings. The region's German heritage adds a unique cultural element.", - "tags": ["Lake", "Mountain", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/chilean-lake-district.jpg" - }, - { - "ref": "cinque-terre", - "name": "Cinque Terre", - "country": "Italy", - "continent": "Europe", - "knownFor": "Explore the picturesque villages of Cinque Terre, perched on the rugged Italian Riviera coastline. Hike the scenic trails connecting the five colorful towns, each with its unique character. Enjoy fresh seafood, local wines, and breathtaking views of the Mediterranean Sea.", - "tags": ["Hiking", "Coastal", "Foodie"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/cinque-terre.jpg" - }, - { - "ref": "colombia", - "name": "Colombia", - "country": "Colombia", - "continent": "South America", - "knownFor": "Colombia is a vibrant country with a diverse landscape, ranging from the Andes Mountains to the Caribbean coast. Explore the colonial city of Cartagena, hike in the Cocora Valley, or dance the night away in Medellín. Discover the coffee region, learn about the indigenous cultures, and experience the warmth of the Colombian people.", - "tags": ["City", "Mountain", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/colombia.jpg" - }, - { - "ref": "corsica", - "name": "Corsica", - "country": "France", - "continent": "Europe", - "knownFor": "This mountainous Mediterranean island offers a diverse landscape of rugged mountains, pristine beaches, and charming villages. Visitors can enjoy hiking, water sports, exploring historical sites, and experiencing the unique Corsican culture.", - "tags": ["Mountain", "Beach", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/corsica.jpg" - }, - { - "ref": "costa-rica", - "name": "Osa Peninsula", - "country": "Costa Rica", - "continent": "North America", - "knownFor": "A haven for eco-tourism, the Osa Peninsula boasts incredible biodiversity, lush rainforests, and pristine beaches. Adventure seekers can go zip-lining, kayaking, and hiking, while nature enthusiasts can spot monkeys, sloths, and exotic birds. The Corcovado National Park, known as one of the most biodiverse places on Earth, is a must-visit for wildlife watching.", - "tags": ["Jungle", "Secluded", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/costa-rica.jpg" - }, - { - "ref": "dubai", - "name": "Dubai", - "country": "United Arab Emirates", - "continent": "Asia", - "knownFor": "Dubai is a modern metropolis known for its towering skyscrapers, luxurious shopping malls, and extravagant attractions. Visitors can experience the thrill of the Burj Khalifa, the world's tallest building, shop at the Dubai Mall, or enjoy a desert safari. With its vibrant nightlife and world-class dining scene, Dubai offers a truly cosmopolitan experience.", - "tags": ["City", "Luxury", "Shopping"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/dubai.jpg" - }, - { - "ref": "dubrovnik", - "name": "Dubrovnik", - "country": "Croatia", - "continent": "Europe", - "knownFor": "Dubrovnik, the \"Pearl of the Adriatic\", is a historic walled city renowned for its stunning architecture, ancient city walls, and breathtaking coastal views. Visitors can explore historical sites, enjoy boat trips, and experience the vibrant cultural scene.", - "tags": ["Historic", "Sightseeing", "City"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/dubrovnik.jpg" - }, - { - "ref": "ethiopia", - "name": "Ethiopia", - "country": "Ethiopia", - "continent": "Africa", - "knownFor": "Ethiopia, a landlocked country in the Horn of Africa, is a unique destination with ancient history, diverse landscapes, and rich culture. Explore the rock-hewn churches of Lalibela, trek through the Simien Mountains, and witness the vibrant tribal traditions. From bustling cities to remote villages, Ethiopia offers an unforgettable journey.", - "tags": ["Off-the-beaten-path", "Cultural experiences", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/ethiopia.jpg" - }, - { - "ref": "fiesole", - "name": "Fiesole", - "country": "Italy", - "continent": "Europe", - "knownFor": "Step back in time in Fiesole, a charming hilltop town overlooking Florence, Italy. Explore Etruscan and Roman ruins, visit the beautiful Fiesole Cathedral, and wander through quaint streets lined with historic buildings. Enjoy breathtaking views of the Tuscan countryside and escape the hustle and bustle of Florence.", - "tags": ["Historic", "Rural", "Romantic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/fiesole.jpg" - }, - { - "ref": "fiji", - "name": "Fiji", - "country": "Fiji", - "continent": "Oceania", - "knownFor": "Fiji, an archipelago in the South Pacific, is renowned for its pristine beaches, crystal-clear waters, and lush rainforests. Visitors can indulge in water activities like snorkeling, scuba diving, and surfing, or explore the islands' rich cultural heritage and traditions. Fiji is also a popular destination for honeymoons and romantic getaways.", - "tags": ["Island", "Beach", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/fiji.jpg" - }, - { - "ref": "french-alps", - "name": "French Alps", - "country": "France", - "continent": "Europe", - "knownFor": "The French Alps offer stunning mountain scenery, charming villages, and world-class skiing. During the winter months, hit the slopes in renowned resorts like Chamonix and Val d'Isère. In the summer, enjoy hiking, mountain biking, and paragliding.", - "tags": ["Mountain", "Skiing", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/french-alps.jpg" - }, - { - "ref": "french-polynesia", - "name": "French Polynesia", - "country": "France", - "continent": "Oceania", - "knownFor": "Escape to the paradise of French Polynesia, where overwater bungalows, turquoise lagoons, and pristine beaches await. Dive into the vibrant underwater world, explore volcanic landscapes, and experience Polynesian culture. Indulge in luxury resorts, romantic getaways, and unforgettable island hopping adventures.", - "tags": ["Tropical", "Island", "Luxury"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/french-polynesia.jpg" - }, - { - "ref": "french-riviera", - "name": "French Riviera", - "country": "France", - "continent": "Europe", - "knownFor": "Discover the glamour of the French Riviera, where glamorous beaches, luxury yachts, and charming coastal towns line the Mediterranean coast. Explore the vibrant city of Nice, visit the iconic Monte Carlo casino, and soak up the sun on the beaches of Cannes. Experience the region's luxurious lifestyle, stunning scenery, and vibrant nightlife.", - "tags": ["Beach", "Luxury", "Nightlife"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/french-riviera.jpg" - }, - { - "ref": "galapagos-islands", - "name": "Galapagos Islands", - "country": "Ecuador", - "continent": "South America", - "knownFor": "Embark on a once-in-a-lifetime adventure to the Galapagos Islands, a living laboratory of evolution. Observe unique wildlife, including giant tortoises, marine iguanas, and blue-footed boobies. Go snorkeling or diving among vibrant coral reefs and explore volcanic landscapes.", - "tags": ["Island", "Wildlife watching", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/galapagos-islands.jpg" - }, - { - "ref": "galicia", - "name": "Galicia", - "country": "Spain", - "continent": "Europe", - "knownFor": "This region in northwestern Spain boasts rugged coastlines, green landscapes, and a rich Celtic heritage. Visitors can enjoy fresh seafood, explore charming towns, embark on the Camino de Santiago pilgrimage, and discover the unique Galician culture.", - "tags": ["Hiking", "Cultural experiences", "Foodie"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/galicia.jpg" - }, - { - "ref": "grand-canyon", - "name": "Grand Canyon", - "country": "United States", - "continent": "North America", - "knownFor": "Witness the awe-inspiring vastness and natural beauty of the Grand Canyon, a UNESCO World Heritage Site. Hike along the rim for breathtaking panoramic views, descend into the canyon for a challenging adventure, or raft down the Colorado River. The Grand Canyon offers an unforgettable experience for nature enthusiasts and adventure seekers.", - "tags": ["Mountain", "Hiking", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/grand-canyon.jpg" - }, - { - "ref": "great-barrier-reef", - "name": "Great Barrier Reef", - "country": "Australia", - "continent": "Australia", - "knownFor": "The Great Barrier Reef is the world's largest coral reef system, teeming with marine life. Snorkel or scuba dive among colorful corals, spot tropical fish, and experience the underwater wonders of this natural treasure.", - "tags": ["Snorkeling", "Scuba diving", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/great-barrier-reef.jpg" - }, - { - "ref": "great-bear-rainforest", - "name": "Great Bear Rainforest", - "country": "Canada", - "continent": "North America", - "knownFor": "The Great Bear Rainforest is a vast and pristine wilderness area on the Pacific coast of British Columbia. It is home to a diverse array of wildlife, including grizzly bears, black bears, wolves, and whales. Visitors can explore the rainforest by boat, kayak, or on foot, and experience the magic of this untouched ecosystem.", - "tags": ["Wildlife watching", "Hiking", "Eco-conscious"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/great-bear-rainforest.jpg" - }, - { - "ref": "greek-islands", - "name": "Greek Islands", - "country": "Greece", - "continent": "Europe", - "knownFor": "Island hop through the Greek Islands, each with its own unique charm and allure. Explore ancient ruins, relax on pristine beaches, and indulge in delicious Greek cuisine. Experience the vibrant nightlife of Mykonos, the romantic sunsets of Santorini, and the historical treasures of Crete. Discover a world of crystal-clear waters, whitewashed villages, and endless sunshine.", - "tags": ["Island", "Beach", "Historic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/greek-islands.jpg" - }, - { - "ref": "greenland", - "name": "Ilulissat Icefjord", - "country": "Greenland", - "continent": "North America", - "knownFor": "Ilulissat Icefjord is a UNESCO World Heritage site and a breathtaking natural wonder with massive icebergs calving from the Sermeq Kujalleq glacier. Visitors can take boat tours to witness the stunning ice formations, go hiking in the surrounding area, and experience the unique culture of Greenland.", - "tags": ["Off-the-beaten-path", "Adventure sports", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/greenland.jpg" - }, - { - "ref": "guadeloupe", - "name": "Guadeloupe", - "country": "France", - "continent": "North America", - "knownFor": "Guadeloupe, a butterfly-shaped archipelago in the Caribbean, is a French overseas territory boasting stunning natural landscapes. With its lush rainforests, volcanic peaks, and white-sand beaches, it's a paradise for outdoor enthusiasts. Hike to cascading waterfalls, explore vibrant coral reefs, and savor the unique blend of French and Creole culture.", - "tags": ["Island", "Tropical", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/guadeloupe.jpg" - }, - { - "ref": "guatemala", - "name": "Lake Atitlán", - "country": "Guatemala", - "continent": "North America", - "knownFor": "Lake Atitlán, surrounded by volcanoes and traditional Mayan villages, offers a scenic and cultural experience in Guatemala. Visitors can explore the lakeside towns, hike to viewpoints, visit Mayan ruins, and learn about local traditions. The lake also provides opportunities for kayaking, swimming, and boat trips.", - "tags": ["Lake", "Cultural experiences", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/guatemala.jpg" - }, - { - "ref": "ha-long-bay", - "name": "Ha Long Bay", - "country": "Vietnam", - "continent": "Asia", - "knownFor": "Ha Long Bay is a breathtaking UNESCO World Heritage Site, featuring thousands of limestone islands and islets rising from emerald waters. Visitors can cruise through the bay, explore hidden caves, kayak among the karst formations, and experience the unique beauty of this natural wonder.", - "tags": ["Island", "Secluded", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/ha-long-bay.jpg" - }, - { - "ref": "harrisburg", - "name": "Harrisburg", - "country": "USA", - "continent": "North America", - "knownFor": "Discover the charm of Harrisburg, Pennsylvania's historic capital city. Explore the impressive Pennsylvania State Capitol Building, delve into history at the National Civil War Museum, and enjoy family fun at City Island. With its scenic riverfront location, Harrisburg offers a blend of cultural attractions and outdoor activities.", - "tags": ["City", "Historic", "Family-friendly"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/harrisburg.jpg" - }, - { - "ref": "havana", - "name": "Havana", - "country": "Cuba", - "continent": "North America", - "knownFor": "Step back in time in Havana, the vibrant capital of Cuba. Stroll along the Malecón, a seaside promenade, and admire the colorful vintage cars. Explore Old Havana, a UNESCO World Heritage Site, with its colonial architecture and lively squares. Immerse yourself in Cuban culture, music, and dance.", - "tags": ["City", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/havana.jpg" - }, - { - "ref": "ho-chi-minh-city", - "name": "Ho Chi Minh City", - "country": "Vietnam", - "continent": "Asia", - "knownFor": "Ho Chi Minh City is a vibrant metropolis with a rich history and delicious street food. Explore the bustling markets, visit historical landmarks like the Cu Chi Tunnels, and immerse yourself in the city's energetic atmosphere. The blend of French colonial architecture and modern skyscrapers creates a unique cityscape.", - "tags": ["City", "Cultural experiences", "Food tours"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/ho-chi-minh-city.jpg" - }, - { - "ref": "iceland", - "name": "Iceland", - "country": "Iceland", - "continent": "Europe", - "knownFor": "Iceland's dramatic landscapes include glaciers, volcanoes, geysers, and waterfalls. Explore the Golden Circle, relax in geothermal pools, and witness the Northern Lights in winter.", - "tags": ["Secluded", "Adventure sports", "Winter destination"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/iceland.jpg" - }, - { - "ref": "japan-alps", - "name": "Japanese Alps", - "country": "Japan", - "continent": "Asia", - "knownFor": "The Japanese Alps offer stunning mountain scenery, traditional villages, and outdoor adventures. Hike through the Kamikochi Valley, ski in Hakuba, or soak in the onsen hot springs. Visit Matsumoto Castle, a historic landmark, and experience the unique culture of the mountain communities.", - "tags": ["Mountain", "Hiking", "Skiing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/japan-alps.jpg" - }, - { - "ref": "jeju-island", - "name": "Jeju Island", - "country": "South Korea", - "continent": "Asia", - "knownFor": "Escape to the volcanic paradise of Jeju Island, a popular destination off the coast of South Korea. Discover stunning natural landscapes, from volcanic craters and lava tubes to pristine beaches and waterfalls. Explore unique museums, hike Mount Hallasan, and relax in charming coastal towns.", - "tags": ["Island", "Hiking", "Secluded"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/jeju-island.jpg" - }, - { - "ref": "jordan", - "name": "Petra", - "country": "Jordan", - "continent": "Asia", - "knownFor": "Petra, an ancient city carved into rose-colored sandstone cliffs, is a UNESCO World Heritage site and one of the New Seven Wonders of the World. Visitors can marvel at the Treasury, explore the Siq, and discover hidden tombs and temples. Petra offers a glimpse into the fascinating history and culture of the Nabataean civilization.", - "tags": ["Historic", "Cultural experiences", "Off-the-beaten-path"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/jordan.jpg" - }, - { - "ref": "kauai", - "name": "Kauai", - "country": "United States", - "continent": "North America", - "knownFor": "Escape to the Garden Isle of Kauai, a paradise of lush rainforests, dramatic cliffs, and pristine beaches. Hike the challenging Kalalau Trail along the Na Pali Coast, kayak the Wailua River, or relax on Poipu Beach. Discover hidden waterfalls, explore Waimea Canyon, and experience the island's laid-back atmosphere.", - "tags": ["Island", "Hiking", "Beach"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/kauai.jpg" - }, - { - "ref": "kenya", - "name": "Kenya", - "country": "Kenya", - "continent": "Africa", - "knownFor": "Embark on an unforgettable safari adventure in Kenya, home to diverse wildlife and stunning landscapes. Witness the Great Migration, encounter lions, elephants, and rhinos, and experience the rich culture of the Maasai people.", - "tags": ["Wildlife watching", "Safari", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/kenya.jpg" - }, - { - "ref": "kyoto", - "name": "Kyoto", - "country": "Japan", - "continent": "Asia", - "knownFor": "Kyoto, Japan's former capital, is a cultural treasure trove with numerous temples, shrines, and gardens. Experience traditional tea ceremonies, stroll through the Arashiyama Bamboo Grove, and immerse yourself in Japanese history and spirituality.", - "tags": ["Historic", "Cultural experiences", "City"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/kyoto.jpg" - }, - { - "ref": "lake-bled", - "name": "Lake Bled", - "country": "Slovenia", - "continent": "Europe", - "knownFor": "Nestled in the Julian Alps, Lake Bled is a fairytale-like destination with a stunning glacial lake, a charming island church, and a medieval castle perched on a cliff. Visitors can enjoy swimming, boating, hiking, and exploring the surrounding mountains. Bled is also known for its delicious cream cake and thermal springs.", - "tags": ["Lake", "Mountain", "Romantic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lake-bled.jpg" - }, - { - "ref": "lake-como", - "name": "Lake Como", - "country": "Italy", - "continent": "Europe", - "knownFor": "Escape to the picturesque shores of Lake Como, surrounded by charming villages, luxurious villas, and stunning mountain scenery. Take a boat tour on the lake, explore historic gardens, and indulge in fine dining and Italian wines. Enjoy hiking, biking, and water sports in the surrounding area.", - "tags": ["Lake", "Romantic", "Luxury"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lake-como.jpg" - }, - { - "ref": "lake-district", - "name": "Lake District National Park", - "country": "England", - "continent": "Europe", - "knownFor": "Nestled in the heart of Cumbria, the Lake District offers breathtaking landscapes with rolling hills, shimmering lakes, and charming villages. Visitors can enjoy hiking, boating, and exploring the literary legacy of Beatrix Potter and William Wordsworth. The region is also known for its delicious local produce and cozy pubs.", - "tags": ["Lake", "Hiking", "Rural"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lake-district.jpg" - }, - { - "ref": "lake-garda", - "name": "Lake Garda", - "country": "Italy", - "continent": "Europe", - "knownFor": "Lake Garda is the largest lake in Italy, known for its stunning scenery, charming towns, and historic sites. Visitors can enjoy swimming, sailing, windsurfing, and hiking in the surrounding mountains. The area is also famous for its lemon groves and olive oil production, offering delicious local cuisine and wine tasting experiences.", - "tags": ["Lake", "Mountain", "Food tours"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lake-garda.jpg" - }, - { - "ref": "lake-tahoe", - "name": "Lake Tahoe", - "country": "USA", - "continent": "North America", - "knownFor": "Lake Tahoe offers a blend of outdoor adventure and stunning natural beauty. Visitors enjoy skiing in the winter and water sports like kayaking and paddleboarding in the summer. The crystal-clear lake, surrounded by mountains, provides breathtaking scenery and a relaxing atmosphere.", - "tags": ["Lake", "Mountain", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lake-tahoe.jpg" - }, - { - "ref": "laos", - "name": "Laos", - "country": "Laos", - "continent": "Asia", - "knownFor": "Laos is a landlocked country in Southeast Asia, known for its laid-back atmosphere, stunning natural beauty, and ancient temples. Explore the UNESCO World Heritage city of Luang Prabang, kayak down the Mekong River, discover the Kuang Si Falls, and visit the Pak Ou Caves filled with thousands of Buddha statues.", - "tags": ["Off-the-beaten-path", "Cultural experiences", "River"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/laos.jpg" - }, - { - "ref": "lofoten-islands", - "name": "Lofoten Islands", - "country": "Norway", - "continent": "Europe", - "knownFor": "Experience the breathtaking beauty of the Arctic Circle with dramatic landscapes, towering mountains, and charming fishing villages. Hike scenic trails, kayak along pristine fjords, and marvel at the Northern Lights. Explore Viking history and indulge in fresh seafood delicacies.", - "tags": ["Island", "Secluded", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lofoten-islands.jpg" - }, - { - "ref": "lombok", - "name": "Lombok", - "country": "Indonesia", - "continent": "Asia", - "knownFor": "Lombok, Bali's less-crowded neighbor, offers pristine beaches, lush rainforests, and the majestic Mount Rinjani volcano. Visitors can enjoy surfing, diving, hiking, and exploring the island's cultural attractions.", - "tags": ["Beach", "Mountain", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/lombok.jpg" - }, - { - "ref": "luang-prabang", - "name": "Luang Prabang", - "country": "Laos", - "continent": "Asia", - "knownFor": "Immerse yourself in the tranquility of Luang Prabang, a UNESCO World Heritage town in Laos. Visit ornate temples like Wat Xieng Thong, witness the alms-giving ceremony at dawn, and explore the night market. Cruise down the Mekong River, discover Kuang Si Falls, and experience the town's spiritual atmosphere.", - "tags": ["Off-the-beaten-path", "Cultural experiences", "River"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/luang-prabang.jpg" - }, - { - "ref": "madagascar", - "name": "Madagascar", - "country": "Madagascar", - "continent": "Africa", - "knownFor": "Madagascar, an island nation off the southeast coast of Africa, is a biodiversity hotspot with unique flora and fauna. Explore rainforests, baobab-lined avenues, and pristine beaches. Encounter lemurs, chameleons, and other endemic species, and discover the rich cultural heritage of the Malagasy people. Madagascar offers an unforgettable adventure for nature lovers.", - "tags": ["Island", "Wildlife watching", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/madagascar.jpg" - }, - { - "ref": "maldives", - "name": "Maldives", - "country": "Maldives", - "continent": "Asia", - "knownFor": "The Maldives, a tropical island nation, offers luxurious overwater bungalows, pristine beaches, and world-class diving. Relax on the white sand, swim in the crystal-clear waters, and explore the vibrant coral reefs. The Maldives is the perfect destination for a romantic getaway or a relaxing beach vacation.", - "tags": ["Island", "Beach", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/maldives.jpg" - }, - { - "ref": "mallorca", - "name": "Mallorca", - "country": "Spain", - "continent": "Europe", - "knownFor": "Mallorca offers a diverse experience, from stunning beaches and turquoise waters to charming villages and rugged mountains. Visitors can explore historic Palma, hike the Serra de Tramuntana, or simply relax on the beach and enjoy the Mediterranean sunshine.", - "tags": ["Beach", "Island", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/mallorca.jpg" - }, - { - "ref": "malta", - "name": "Malta", - "country": "Malta", - "continent": "Europe", - "knownFor": "Malta, an archipelago in the central Mediterranean, is a captivating blend of history, culture, and stunning natural beauty. Its ancient temples, fortified cities, and hidden coves attract history buffs and adventurers alike. From exploring the UNESCO-listed capital Valletta to diving in crystal-clear waters, Malta offers a diverse experience for every traveler.", - "tags": ["Island", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/malta.jpg" - }, - { - "ref": "marrakech", - "name": "Marrakech", - "country": "Morocco", - "continent": "Africa", - "knownFor": "Step into a world of vibrant colours and bustling souks in Marrakech. Explore the historic Medina, with its maze-like alleys and hidden treasures, or marvel at the intricate architecture of mosques and palaces. Indulge in the rich flavours of Moroccan cuisine and experience the unique culture of this magical city.", - "tags": ["Cultural experiences", "City", "Shopping"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/marrakech.jpg" - }, - { - "ref": "maui", - "name": "Maui", - "country": "United States", - "continent": "North America", - "knownFor": "Experience the diverse landscapes of Maui, from volcanic craters to lush rainforests and stunning beaches. Drive the scenic Road to Hana, watch the sunrise from Haleakala Crater, or snorkel in Molokini Crater. Enjoy water sports, whale watching, and the island's relaxed vibes.", - "tags": ["Island", "Road trip", "Beach"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/maui.jpg" - }, - { - "ref": "milford-sound", - "name": "Milford Sound", - "country": "New Zealand", - "continent": "Oceania", - "knownFor": "Milford Sound, nestled in Fiordland National Park, offers breathtaking landscapes with towering cliffs, cascading waterfalls, and pristine waters. Visitors can cruise the fiord, kayak among the peaks, or hike the Milford Track for a multi-day adventure. The area is also known for its rich biodiversity, including dolphins, seals, and penguins.", - "tags": ["Secluded", "Hiking", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/milford-sound.jpg" - }, - { - "ref": "moab", - "name": "Moab", - "country": "United States", - "continent": "North America", - "knownFor": "Moab is an adventurer's paradise, renowned for its stunning red rock formations and world-class outdoor activities. Hiking, mountain biking, and off-roading are popular pursuits in Arches and Canyonlands National Parks. The Colorado River offers white-water rafting and kayaking, while the surrounding desert landscapes provide endless opportunities for exploration and discovery.", - "tags": ["Desert", "Adventure sports", "Off-the-beaten-path"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/moab.jpg" - }, - { - "ref": "montenegro", - "name": "Montenegro", - "country": "Montenegro", - "continent": "Europe", - "knownFor": "Montenegro is a small Balkan country with a dramatic coastline, soaring mountains, and charming towns. Explore the Bay of Kotor, a UNESCO World Heritage Site, hike in Durmitor National Park, or relax on the beaches of Budva. Discover the historic cities of Kotor and Cetinje and enjoy the local seafood specialties.", - "tags": ["Beach", "Mountain", "Historic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/montenegro.jpg" - }, - { - "ref": "mosel-valley", - "name": "Mosel Valley", - "country": "Germany", - "continent": "Europe", - "knownFor": "Embark on a journey through picturesque vineyards and charming villages along the Mosel River. Explore medieval castles, indulge in world-renowned Riesling wines, and savor authentic German cuisine. Hike or bike along scenic trails, or take a leisurely river cruise to soak in the breathtaking landscapes.", - "tags": ["River", "Wine tasting", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/mosel-valley.jpg" - }, - { - "ref": "myanmar", - "name": "Myanmar", - "country": "Myanmar", - "continent": "Asia", - "knownFor": "Myanmar (formerly Burma) is a country rich in culture and history, with ancient temples, stunning landscapes, and friendly people. Explore the iconic Shwedagon Pagoda in Yangon, visit the temple complex of Bagan, and cruise along the Irrawaddy River.", - "tags": ["Secluded", "Cultural experiences", "Off-the-beaten-path"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/myanmar.jpg" - }, - { - "ref": "mykonos", - "name": "Mykonos", - "country": "Greece", - "continent": "Europe", - "knownFor": "Mykonos is a glamorous Greek island famous for its whitewashed houses, iconic windmills, and vibrant nightlife. Visitors can relax on beautiful beaches, explore charming towns, and indulge in delicious Greek cuisine. Mykonos is also known for its luxury hotels, designer boutiques, and lively beach clubs.", - "tags": ["Island", "Beach", "Nightlife"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/mykonos.jpg" - }, - { - "ref": "nairobi", - "name": "Nairobi", - "country": "Kenya", - "continent": "Africa", - "knownFor": "Nairobi, the bustling capital of Kenya, serves as a gateway to the country's renowned safari destinations. Visit the David Sheldrick Elephant Orphanage, explore the Karen Blixen Museum, and experience the vibrant nightlife and cultural scene.", - "tags": ["City", "Wildlife watching", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/nairobi.jpg" - }, - { - "ref": "namibia", - "name": "Sossusvlei", - "country": "Namibia", - "continent": "Africa", - "knownFor": "Sossusvlei is a mesmerizing salt and clay pan surrounded by towering red sand dunes in the Namib Desert. Visitors can embark on scenic drives, climb the dunes for panoramic views, and capture breathtaking photos of the unique landscape. Sossusvlei is a photographer's paradise and a must-visit for desert enthusiasts.", - "tags": ["Desert", "Off-the-beaten-path", "Photography"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/namibia.jpg" - }, - { - "ref": "napa-valley", - "name": "Napa Valley", - "country": "United States", - "continent": "North America", - "knownFor": "Indulge in the world-renowned wine region of Napa Valley, California. Visit picturesque vineyards, sample exquisite wines, and savor gourmet cuisine at Michelin-starred restaurants. Explore charming towns like St. Helena and Yountville, or relax in luxurious spa resorts. Napa Valley offers a sophisticated and relaxing getaway for wine lovers and foodies.", - "tags": ["Wine tasting", "Food tours", "Luxury"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/napa-valley.jpg" - }, - { - "ref": "new-orleans", - "name": "New Orleans", - "country": "United States", - "continent": "North America", - "knownFor": "New Orleans is a vibrant city with a unique culture, known for its jazz music, Mardi Gras celebrations, and Creole cuisine. Explore the French Quarter, listen to live music on Frenchmen Street, or visit the historic cemeteries. Experience the nightlife, enjoy the local food, and immerse yourself in the spirit of New Orleans.", - "tags": ["City", "Cultural experiences", "Foodie"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/new-orleans.jpg" - }, - { - "ref": "new-zealand", - "name": "New Zealand", - "country": "New Zealand", - "continent": "Oceania", - "knownFor": "Embark on an adventure in New Zealand, a land of diverse landscapes, from snow-capped mountains and glaciers to geothermal wonders and lush rainforests. Hike through Fiordland National Park, explore the Waitomo Caves, and experience the thrill of bungee jumping and white-water rafting. Discover the Maori culture, indulge in delicious local cuisine, and marvel at the country's natural beauty.", - "tags": ["Adventure sports", "Hiking", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/new-zealand.jpg" - }, - { - "ref": "newfoundland", - "name": "Newfoundland", - "country": "Canada", - "continent": "North America", - "knownFor": "Newfoundland boasts rugged coastlines, charming fishing villages, and abundant wildlife. Hike along scenic trails, go whale watching, and experience the unique local culture. The island's friendly people and lively music scene add to its appeal.", - "tags": ["Island", "Wildlife watching", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/newfoundland.jpg" - }, - { - "ref": "nicaragua", - "name": "Nicaragua", - "country": "Nicaragua", - "continent": "North America", - "knownFor": "Nicaragua offers a diverse landscape of volcanoes, lakes, beaches, and rainforests. Visitors can enjoy adventure activities like surfing, volcano boarding, and zip-lining, as well as exploring colonial cities and experiencing the rich Nicaraguan culture.", - "tags": ["Adventure sports", "Beach", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/nicaragua.jpg" - }, - { - "ref": "northern-territory", - "name": "Kakadu National Park", - "country": "Australia", - "continent": "Oceania", - "knownFor": "Kakadu National Park is a UNESCO World Heritage site with diverse landscapes, including wetlands, sandstone escarpments, and ancient rock art sites. Visitors can explore Aboriginal culture, go bird watching, take boat tours through wetlands, and admire cascading waterfalls. Kakadu is a haven for wildlife, with crocodiles, wallabies, and numerous bird species.", - "tags": ["National Park", "Wildlife watching", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/northern-territory.jpg" - }, - { - "ref": "oaxaca", - "name": "Oaxaca", - "country": "Mexico", - "continent": "North America", - "knownFor": "Immerse yourself in the vibrant culture and rich history of Oaxaca, Mexico. Explore ancient Zapotec ruins, visit colorful markets filled with handicrafts, and experience traditional festivals. Sample the region's renowned cuisine, including mole sauces and mezcal. Oaxaca offers a unique and authentic travel experience for culture enthusiasts and foodies.", - "tags": ["Cultural experiences", "Food tours", "Historic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/oaxaca.jpg" - }, - { - "ref": "okavango-delta", - "name": "Okavango Delta", - "country": "Botswana", - "continent": "Africa", - "knownFor": "The Okavango Delta, a vast inland delta in Botswana, is a haven for wildlife. Explore the waterways by mokoro (traditional canoe) and witness elephants, lions, hippos, and an array of bird species in their natural habitat.", - "tags": ["River", "Wildlife watching", "Adventure sports"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/okavango-delta.jpg" - }, - { - "ref": "oman", - "name": "Oman", - "country": "Oman", - "continent": "Asia", - "knownFor": "Oman, a country on the Arabian Peninsula, is a hidden gem with dramatic landscapes, ancient forts, and warm hospitality. Explore the vast deserts, swim in turquoise wadis, and hike through rugged mountains. Visit traditional souks, experience Bedouin culture, and discover the unique blend of modernity and tradition in this captivating destination.", - "tags": ["Desert", "Secluded", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/oman.jpg" - }, - { - "ref": "pagos-islands", - "name": "Galápagos Islands", - "country": "Ecuador", - "continent": "South America", - "knownFor": "A unique archipelago off the coast of Ecuador, the Galápagos Islands is a haven for wildlife enthusiasts. Encounter giant tortoises, marine iguanas, blue-footed boobies, and sea lions in their natural habitat. Snorkeling and diving opportunities reveal a vibrant underwater world.", - "tags": ["Island", "Wildlife watching", "Snorkeling"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/pagos-islands.jpg" - }, - { - "ref": "patagonia", - "name": "Patagonia", - "country": "Argentina and Chile", - "continent": "South America", - "knownFor": "Patagonia is a vast region at the southern tip of South America, known for its glaciers, mountains, and diverse wildlife. Visitors can embark on trekking adventures, witness the Perito Moreno Glacier, go kayaking, and spot penguins, whales, and other animals.", - "tags": ["Hiking", "Adventure sports", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/patagonia.jpg" - }, - { - "ref": "peru", - "name": "Arequipa", - "country": "Peru", - "continent": "South America", - "knownFor": "Arequipa, known as the \"White City\" due to its buildings made of white volcanic stone, is a beautiful colonial city in southern Peru. Visitors can explore the historic center, visit the Santa Catalina Monastery, and enjoy stunning views of the surrounding volcanoes. Arequipa is also a gateway to the Colca Canyon, one of the deepest canyons in the world.", - "tags": ["City", "Historic", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/peru.jpg" - }, - { - "ref": "peruvian-amazon", - "name": "Peruvian Amazon", - "country": "Peru", - "continent": "South America", - "knownFor": "Venture into the heart of the Amazon rainforest, a biodiversity hotspot teeming with exotic wildlife and indigenous cultures. Embark on jungle treks, navigate the Amazon River, and spot unique flora and fauna. Experience the thrill of adventure travel and connect with nature in its purest form.", - "tags": ["Jungle", "Adventure sports", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/peruvian-amazon.jpg" - }, - { - "ref": "phnom-penh", - "name": "Phnom Penh", - "country": "Cambodia", - "continent": "Asia", - "knownFor": "Immerse yourself in the vibrant culture and rich history of Phnom Penh, Cambodia's bustling capital. Visit the Royal Palace, explore ancient temples like Wat Phnom, and delve into the poignant history at the Tuol Sleng Genocide Museum. Experience the city's energetic nightlife and savor delicious Khmer cuisine.", - "tags": ["City", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/phnom-penh.jpg" - }, - { - "ref": "phuket", - "name": "Phuket", - "country": "Thailand", - "continent": "Asia", - "knownFor": "Relax on the stunning beaches of Phuket, Thailand's largest island. Explore the turquoise waters of the Andaman Sea, go snorkeling or scuba diving among coral reefs, or visit nearby islands like Phi Phi. Enjoy the vibrant nightlife, indulge in Thai massages, and experience the local culture. Phuket offers a perfect blend of relaxation and adventure for beach lovers and those seeking a tropical escape.", - "tags": ["Beach", "Island", "Snorkeling"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/phuket.jpg" - }, - { - "ref": "positano", - "name": "Positano", - "country": "Italy", - "continent": "Europe", - "knownFor": "Nestled along the Amalfi Coast, Positano enchants with its colorful cliffside houses, pebble beaches, and luxury boutiques. Explore the narrow streets, indulge in delicious Italian cuisine, and soak up the romantic atmosphere.", - "tags": ["Beach", "Romantic", "Luxury"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/positano.jpg" - }, - { - "ref": "prague", - "name": "Prague", - "country": "Czech Republic", - "continent": "Europe", - "knownFor": "Prague, with its stunning architecture and rich history, is a fairytale city. Explore the Prague Castle, wander through the Old Town Square, and enjoy a traditional Czech meal. The city's charming atmosphere and affordable prices make it a popular destination.", - "tags": ["City", "Historic", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/prague.jpg" - }, - { - "ref": "puerto-rico", - "name": "Puerto Rico", - "country": "United States", - "continent": "North America", - "knownFor": "Puerto Rico is a Caribbean island with a rich history, vibrant culture, and stunning natural beauty. Explore the historic forts of Old San Juan, relax on the beaches of Vieques, or hike in El Yunque National Forest. Experience the bioluminescent bays, go salsa dancing, and enjoy the local cuisine.", - "tags": ["Island", "Beach", "Historic"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/puerto-rico.jpg" - }, - { - "ref": "punta-cana", - "name": "Punta Cana", - "country": "Dominican Republic", - "continent": "North America", - "knownFor": "Punta Cana is a renowned beach destination with pristine white sand, crystal-clear waters, and luxurious resorts. Enjoy water sports, golf, or simply unwind by the ocean and experience the vibrant Dominican culture.", - "tags": ["Beach", "All-inclusive", "Family-friendly"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/punta-cana.jpg" - }, - { - "ref": "quebec-city", - "name": "Quebec City", - "country": "Canada", - "continent": "North America", - "knownFor": "Experience the European charm of Quebec City, a UNESCO World Heritage Site. Wander through the historic Old Town with its cobblestone streets and fortified walls, visit the iconic Chateau Frontenac, and admire the stunning views of the St. Lawrence River. Quebec City offers a unique blend of history, culture, and French Canadian charm for a memorable getaway.", - "tags": ["City", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/quebec-city.jpg" - }, - { - "ref": "queenstown", - "name": "Queenstown", - "country": "New Zealand", - "continent": "Oceania", - "knownFor": "Experience the adventure capital of the world in Queenstown, New Zealand. Surrounded by stunning mountains and Lake Wakatipu, this town offers everything from bungy jumping and skydiving to skiing and hiking.", - "tags": ["Adventure sports", "Lake", "Mountain"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/queenstown.jpg" - }, - { - "ref": "rajasthan", - "name": "Rajasthan", - "country": "India", - "continent": "Asia", - "knownFor": "Rajasthan, a state in northwestern India, is known for its opulent palaces, vibrant culture, and desert landscapes. Visitors can explore the cities of Jaipur, Jodhpur, and Udaipur, with their magnificent forts and palaces. The region also offers camel safaris, desert camping, and opportunities to experience traditional Rajasthani music and dance.", - "tags": ["Historic", "Desert", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/rajasthan.jpg" - }, - { - "ref": "reunion-island", - "name": "Réunion Island", - "country": "France", - "continent": "Africa", - "knownFor": "This volcanic island boasts dramatic landscapes, including Piton de la Fournaise, one of the world's most active volcanoes. Hike through lush rainforests, relax on black sand beaches, and experience the unique Creole culture.", - "tags": ["Secluded", "Hiking", "Tropical"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/reunion-island.jpg" - }, - { - "ref": "rio-de-janeiro", - "name": "Rio de Janeiro", - "country": "Brazil", - "continent": "South America", - "knownFor": "Rio de Janeiro is a vibrant city known for its stunning beaches, iconic landmarks like Christ the Redeemer and Sugarloaf Mountain, and lively Carnival celebrations. Visitors can enjoy sunbathing, surfing, and exploring the city's diverse neighborhoods, experiencing the infectious energy and cultural richness of Brazil.", - "tags": ["City", "Beach", "Party"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/rio-de-janeiro.jpg" - }, - { - "ref": "salar-de-uyuni", - "name": "Salar de Uyuni", - "country": "Bolivia", - "continent": "South America", - "knownFor": "Witness the surreal beauty of the world's largest salt flats, a mesmerizing landscape that transforms into a giant mirror during the rainy season. Capture incredible photos, explore unique rock formations, and visit nearby lagoons teeming with flamingos.", - "tags": ["Desert", "Off-the-beaten-path", "Photography"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/salar-de-uyuni.jpg" - }, - { - "ref": "san-diego", - "name": "San Diego", - "country": "United States", - "continent": "North America", - "knownFor": "San Diego, a sunny coastal city in Southern California, boasts beautiful beaches, a world-famous zoo, and a vibrant cultural scene. Explore Balboa Park, visit the historic Gaslamp Quarter, and enjoy water sports along the Pacific coast.", - "tags": ["City", "Beach", "Family-friendly"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/san-diego.jpg" - }, - { - "ref": "san-miguel-de-allende", - "name": "San Miguel de Allende", - "country": "Mexico", - "continent": "North America", - "knownFor": "This colonial city in central Mexico is a UNESCO World Heritage site with stunning Spanish architecture, vibrant cultural events, and a thriving arts scene. Visitors can explore historic churches, wander through cobbled streets lined with colorful houses, and enjoy delicious Mexican cuisine. San Miguel de Allende is also a popular destination for art classes and workshops.", - "tags": ["City", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/san-miguel-de-allende.jpg" - }, - { - "ref": "san-sebastian", - "name": "San Sebastian", - "country": "Spain", - "continent": "Europe", - "knownFor": "Indulge in the culinary delights of this coastal paradise, renowned for its Michelin-starred restaurants and pintxos bars. Relax on the beautiful beaches, explore the charming Old Town, and hike or bike in the surrounding hills. Experience the vibrant culture and lively festivals of the Basque region.", - "tags": ["Beach", "City", "Foodie"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/san-sebastian.jpg" - }, - { - "ref": "santorini", - "name": "Santorini", - "country": "Greece", - "continent": "Europe", - "knownFor": "Santorini's iconic whitewashed villages perched on volcanic cliffs offer breathtaking views of the Aegean Sea. Explore charming Oia, visit ancient Akrotiri, and enjoy romantic sunsets with caldera views.", - "tags": ["Island", "Romantic", "Luxury"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/santorini.jpg" - }, - { - "ref": "sardinia", - "name": "Sardinia", - "country": "Italy", - "continent": "Europe", - "knownFor": "Experience the allure of Sardinia, a Mediterranean island boasting stunning coastlines, turquoise waters, and rugged mountains. Explore charming villages, ancient ruins, and secluded coves. Indulge in delicious Sardinian cuisine, hike scenic trails, and discover the island's rich history and culture.", - "tags": ["Island", "Beach", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/sardinia.jpg" - }, - { - "ref": "scotland", - "name": "Scotland", - "country": "United Kingdom", - "continent": "Europe", - "knownFor": "Scotland is known for its dramatic landscapes, historic castles, and vibrant cities. Explore the Scottish Highlands, visit Edinburgh Castle, and sample Scotch whisky. The country's rich history and culture, along with its friendly people, make it a captivating destination.", - "tags": ["Mountain", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/scotland.jpg" - }, - { - "ref": "scottish-highlands", - "name": "Scottish Highlands", - "country": "Scotland", - "continent": "Europe", - "knownFor": "The Scottish Highlands, a mountainous region in northern Scotland, is renowned for its rugged beauty, ancient castles, and rich history. Visitors can explore the dramatic landscapes through hiking, climbing, and scenic drives, or visit historic sites like Loch Ness and Eilean Donan Castle. The region is also famous for its whisky distilleries and traditional Highland culture.", - "tags": ["Mountain", "Historic", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/scottish-highlands.jpg" - }, - { - "ref": "seoul", - "name": "Seoul", - "country": "South Korea", - "continent": "Asia", - "knownFor": "Seoul is a vibrant metropolis blending modern skyscrapers with ancient palaces and temples. Visitors can explore historical landmarks, experience K-pop culture, indulge in delicious Korean cuisine, and enjoy the city's bustling nightlife.", - "tags": ["City", "Cultural experiences", "Nightlife"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/seoul.jpg" - }, - { - "ref": "serengeti-national-park", - "name": "Serengeti National Park", - "country": "Tanzania", - "continent": "Africa", - "knownFor": "Serengeti National Park is renowned for its incredible wildlife and the annual Great Migration, where millions of wildebeest, zebras, and other animals traverse the plains in search of fresh grazing. Visitors can embark on thrilling safari adventures, witness predator-prey interactions, and marvel at the diversity of the African savanna.", - "tags": ["Wildlife watching", "Safari", "Adventure"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/serengeti-national-park.jpg" - }, - { - "ref": "seville", - "name": "Seville", - "country": "Spain", - "continent": "Europe", - "knownFor": "Seville, the vibrant capital of Andalusia, is renowned for its flamenco dancing, Moorish architecture, and lively tapas bars. Explore the stunning Alcázar palace, witness a passionate flamenco performance, and wander through the charming Santa Cruz district.", - "tags": ["City", "Cultural experiences", "Food tours"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/seville.jpg" - }, - { - "ref": "singapore", - "name": "Singapore", - "country": "Singapore", - "continent": "Asia", - "knownFor": "Experience a vibrant mix of cultures, cutting-edge architecture, and lush green spaces in this dynamic city-state. Discover futuristic Gardens by the Bay, indulge in diverse culinary delights, and explore world-class shopping and entertainment options.", - "tags": ["City", "Cultural experiences", "Foodie"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/singapore.jpg" - }, - { - "ref": "slovenia", - "name": "Slovenia", - "country": "Slovenia", - "continent": "Europe", - "knownFor": "Slovenia is a small country in Central Europe with stunning alpine scenery, charming towns, and a rich history. Visit Lake Bled, a picturesque lake with a church on an island, explore the Postojna Cave, or hike in Triglav National Park. Discover the capital city of Ljubljana, enjoy the local wines, and experience the Slovenian hospitality.", - "tags": ["Lake", "Mountain", "City"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/slovenia.jpg" - }, - { - "ref": "sri-lanka", - "name": "Sri Lanka", - "country": "Sri Lanka", - "continent": "Asia", - "knownFor": "Sri Lanka is an island nation off the southern coast of India, known for its ancient ruins, beautiful beaches, and diverse wildlife. Visit the Sigiriya rock fortress, relax on the beaches of Bentota, or go on a safari in Yala National Park. Explore the tea plantations, experience the local culture, and enjoy the delicious Sri Lankan cuisine.", - "tags": ["Island", "Beach", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/sri-lanka.jpg" - }, - { - "ref": "svalbard", - "name": "Svalbard", - "country": "Norway", - "continent": "Europe", - "knownFor": "Svalbard, an Arctic archipelago under Norwegian sovereignty, is a remote and captivating destination for adventurers and nature enthusiasts. Witness glaciers, fjords, and ice-covered landscapes. Spot polar bears, walruses, and reindeer, and experience the midnight sun or the northern lights. Svalbard offers a unique opportunity to explore the Arctic wilderness.", - "tags": ["Off-the-beaten-path", "Wildlife watching", "Winter destination"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/svalbard.jpg" - }, - { - "ref": "swiss-alps", - "name": "Swiss Alps", - "country": "Switzerland", - "continent": "Europe", - "knownFor": "Discover the breathtaking beauty of the Swiss Alps, a paradise for outdoor enthusiasts. Hike through scenic mountain trails, go skiing or snowboarding in world-class resorts, or take a scenic train ride through the mountains. Enjoy the fresh air, charming villages, and stunning scenery. The Swiss Alps offer an unforgettable experience for nature lovers and adventure seekers.", - "tags": ["Mountain", "Hiking", "Skiing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/swiss-alps.jpg" - }, - { - "ref": "tasmania", - "name": "Tasmania", - "country": "Australia", - "continent": "Oceania", - "knownFor": "Discover the wild beauty of Tasmania, an island state off the coast of Australia. Explore Cradle Mountain-Lake St Clair National Park, with its rugged mountains and pristine lakes. Visit Port Arthur Historic Site, a former penal colony, or encounter unique wildlife like Tasmanian devils and quolls.", - "tags": ["Island", "Hiking", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/tasmania.jpg" - }, - { - "ref": "tel-aviv", - "name": "Tel Aviv", - "country": "Israel", - "continent": "Asia", - "knownFor": "Experience the vibrant and cosmopolitan city of Tel Aviv, known for its beaches, Bauhaus architecture, and thriving nightlife. Relax on the sandy shores of the Mediterranean Sea, explore the trendy neighborhoods of Neve Tzedek and Florentin, and enjoy the city's diverse culinary scene. Tel Aviv offers a perfect blend of beach relaxation, cultural experiences, and exciting nightlife.", - "tags": ["City", "Beach", "Nightlife"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/tel-aviv.jpg" - }, - { - "ref": "trans-siberian-railway", - "name": "Trans-Siberian Railway", - "country": "Russia", - "continent": "Asia", - "knownFor": "The Trans-Siberian Railway is the longest railway line in the world, stretching over 9,000 kilometers from Moscow to Vladivostok. This epic journey offers travelers a unique opportunity to experience the vastness and diversity of Russia, passing through bustling cities, remote villages, and stunning natural landscapes.", - "tags": ["Adventure sports", "Cultural experiences", "Long-haul vacation"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/trans-siberian-railway.jpg" - }, - { - "ref": "transylvania", - "name": "Transylvania", - "country": "Romania", - "continent": "Europe", - "knownFor": "Transylvania, a region in Romania, is famous for its medieval towns, fortified churches, and stunning Carpathian Mountain scenery. Visitors can explore Bran Castle, associated with the Dracula legend, visit historic cities like Brasov and Sibiu, and hike or ski in the mountains. The region also offers opportunities to experience traditional Romanian culture and cuisine.", - "tags": ["Historic", "Mountain", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/transylvania.jpg" - }, - { - "ref": "tulum", - "name": "Tulum", - "country": "Mexico", - "continent": "North America", - "knownFor": "Tulum seamlessly blends ancient Mayan history with modern bohemian vibes. Visitors can explore the Tulum Archaeological Site, perched on cliffs overlooking the Caribbean Sea, and discover well-preserved ruins. Pristine beaches offer relaxation and water activities, while the town's eco-chic atmosphere provides yoga retreats, wellness centers, and sustainable dining options.", - "tags": ["Beach", "Cultural experiences", "Wellness retreats"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/tulum.jpg" - }, - { - "ref": "turkish-riviera", - "name": "Turkish Riviera", - "country": "Turkey", - "continent": "Asia", - "knownFor": "The Turkish Riviera offers a mix of ancient ruins, stunning beaches, and turquoise waters. Explore the historical sites of Ephesus and Antalya, relax on the sandy shores, and enjoy water sports like sailing and snorkeling. The region's delicious cuisine and affordable prices add to its appeal.", - "tags": ["Beach", "Historic", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/turkish-riviera.jpg" - }, - { - "ref": "tuscany", - "name": "Tuscany", - "country": "Italy", - "continent": "Europe", - "knownFor": "Explore the rolling hills and vineyards of Tuscany, indulging in wine tastings and farm-to-table cuisine. Discover charming medieval towns, Renaissance art, and historic cities like Florence and Siena. Immerse yourself in the region's rich culture and art scene, or simply relax and soak up the idyllic scenery.", - "tags": ["Cultural experiences", "Food tours", "Wine tasting"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/tuscany.jpg" - }, - { - "ref": "us-virgin-islands", - "name": "US Virgin Islands", - "country": "United States", - "continent": "North America", - "knownFor": "Escape to the Caribbean paradise of the US Virgin Islands, where you can relax on pristine beaches, explore coral reefs, and experience the laid-back island lifestyle. Visit the historic towns of Charlotte Amalie and Christiansted, go sailing or snorkeling in crystal-clear waters, or simply soak up the sun. The US Virgin Islands offer a perfect tropical getaway for beach lovers and those seeking a relaxing escape.", - "tags": ["Island", "Beach", "Relaxing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/us-virgin-islands.jpg" - }, - { - "ref": "vancouver-island", - "name": "Vancouver Island", - "country": "Canada", - "continent": "North America", - "knownFor": "Vancouver Island, located off Canada's Pacific coast, is a haven for nature lovers and adventure seekers. Explore the rugged coastline, ancient rainforests, and snow-capped mountains. Go whale watching, kayaking, or surfing, and discover charming towns and vibrant cities like Victoria. Vancouver Island offers a perfect blend of wilderness and urban experiences.", - "tags": ["Island", "Adventure sports", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/vancouver-island.jpg" - }, - { - "ref": "vienna", - "name": "Vienna", - "country": "Austria", - "continent": "Europe", - "knownFor": "Step into the imperial city of Vienna, where grand palaces, historical landmarks, and elegant cafes exude charm and sophistication. Explore museums, art galleries, and renowned opera houses, or visit Schönbrunn Palace and delve into Habsburg history. Enjoy classical music concerts, indulge in Viennese pastries, and experience the city's rich cultural heritage.", - "tags": ["City", "Historic", "Cultural experiences"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/vienna.jpg" - }, - { - "ref": "vietnam", - "name": "Vietnam", - "country": "Vietnam", - "continent": "Asia", - "knownFor": "Vietnam offers a rich tapestry of culture, history, and natural beauty. Explore the bustling streets of Hanoi, cruise through the scenic Ha Long Bay, and discover the ancient town of Hoi An. From delicious street food to stunning landscapes, Vietnam is a destination that will captivate your senses.", - "tags": ["Cultural experiences", "Food tours", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/vietnam.jpg" - }, - { - "ref": "western-australia", - "name": "Western Australia", - "country": "Australia", - "continent": "Australia", - "knownFor": "Western Australia, the largest state in Australia, is a land of vast landscapes, stunning coastlines, and unique wildlife. Explore the vibrant city of Perth, swim with whale sharks at Ningaloo Reef, and discover the ancient rock formations of the Kimberley region. From wineries to deserts, Western Australia offers a diverse and unforgettable experience.", - "tags": ["Beach", "Road trip destination", "Wildlife watching"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/western-australia.jpg" - }, - { - "ref": "yellowstone-national-park", - "name": "Yellowstone National Park", - "country": "United States", - "continent": "North America", - "knownFor": "Witness the geothermal wonders of Yellowstone, with its geysers, hot springs, and mudpots. Observe abundant wildlife, including bison, elk, and wolves. Explore the Grand Canyon of the Yellowstone, go hiking or camping, and enjoy winter sports.", - "tags": ["National Park", "Wildlife watching", "Hiking"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/yellowstone-national-park.jpg" - }, - { - "ref": "yosemite-national-park", - "name": "Yosemite National Park", - "country": "United States", - "continent": "North America", - "knownFor": "Yosemite National Park, located in California's Sierra Nevada mountains, is renowned for its towering granite cliffs, giant sequoia trees, and stunning waterfalls. Visitors can enjoy hiking, camping, rock climbing, and exploring the park's natural wonders, including Yosemite Valley, Half Dome, and Glacier Point.", - "tags": ["Mountain", "Hiking", "Sightseeing"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/yosemite-national-park.jpg" - }, - { - "ref": "yucatan-peninsula", - "name": "Yucatan Peninsula", - "country": "Mexico", - "continent": "North America", - "knownFor": "Discover ancient Mayan ruins, explore vibrant coral reefs, and relax on pristine beaches in the Yucatan Peninsula. Dive into cenotes, swim with whale sharks, and experience the rich culture and history of this captivating region.", - "tags": ["Beach", "Historic", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/yucatan-peninsula.jpg" - }, - { - "ref": "zanzibar", - "name": "Zanzibar", - "country": "Tanzania", - "continent": "Africa", - "knownFor": "Zanzibar is a Tanzanian archipelago off the coast of East Africa, known for its stunning beaches, turquoise waters, and historical Stone Town. Visitors can relax on the beach, explore the UNESCO-listed Stone Town, go diving or snorkeling, and experience the island's unique blend of African, Arab, and European influences.", - "tags": ["Beach", "Cultural experiences", "Scuba diving"], - "imageUrl": "https://storage.googleapis.com/tripedia-images/destinations/zanzibar.jpg" - } -] diff --git a/compass_app/server/bin/compass_server.dart b/compass_app/server/bin/compass_server.dart index 925abe8b0..209b7dbe2 100644 --- a/compass_app/server/bin/compass_server.dart +++ b/compass_app/server/bin/compass_server.dart @@ -9,7 +9,7 @@ import 'package:shelf_router/shelf_router.dart'; // Configure routes. final _router = Router() ..get('/continent', continentHandler) - ..get('/destination', destinationHandler); + ..mount('/destination', DestinationApi().router.call); void main(List<String> args) async { // Use any available host or container IP (usually `0.0.0.0`). diff --git a/compass_app/server/lib/config/assets.dart b/compass_app/server/lib/config/assets.dart new file mode 100644 index 000000000..134977fb7 --- /dev/null +++ b/compass_app/server/lib/config/assets.dart @@ -0,0 +1,4 @@ +class Assets { + static const activities = '../app/assets/activities.json'; + static const destinations = '../app/assets/destinations.json'; +} diff --git a/compass_app/server/lib/routes/destination.dart b/compass_app/server/lib/routes/destination.dart index 7c9fe4474..f6cf2f049 100644 --- a/compass_app/server/lib/routes/destination.dart +++ b/compass_app/server/lib/routes/destination.dart @@ -1,9 +1,42 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:compass_model/model.dart'; import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; -Future<Response> destinationHandler(Request req) async { - final file = File('assets/destinations.json'); - final jsonString = await file.readAsString(); - return Response.ok(jsonString); +import '../config/assets.dart'; + +class DestinationApi { + final List<Destination> destinations = + (json.decode(File(Assets.destinations).readAsStringSync()) as List) + .map((element) => Destination.fromJson(element)) + .toList(); + final List<Activity> activities = + (json.decode(File(Assets.activities).readAsStringSync()) as List) + .map((element) => Activity.fromJson(element)) + .toList(); + + Router get router { + final router = Router(); + + router.get('/', (Request request) { + return Response.ok( + json.encode(destinations), + headers: {'Content-Type': 'application/json'}, + ); + }); + + router.get('/<id>/activity', (Request request, String id) { + final list = activities + .where((activity) => activity.destinationRef == id) + .toList(); + return Response.ok( + json.encode(list), + headers: {'Content-Type': 'application/json'}, + ); + }); + + return router; + } } diff --git a/compass_app/server/test/server_test.dart b/compass_app/server/test/server_test.dart index d6cc659b8..5ae5eb5ce 100644 --- a/compass_app/server/test/server_test.dart +++ b/compass_app/server/test/server_test.dart @@ -46,6 +46,18 @@ void main() { expect(destination.first.name, 'Alaska'); }); + test('Get Activities end-point', () async { + // Query /destination/alaska/activity end-point + final response = await get(Uri.parse('$host/destination/alaska/activity')); + expect(response.statusCode, 200); + // Parse json response list + final list = jsonDecode(response.body) as List<dynamic>; + // Parse items + final activity = list.map((element) => Activity.fromJson(element)); + expect(activity.length, 20); + expect(activity.first.name, 'Glacier Trekking and Ice Climbing'); + }); + test('404', () async { final response = await get(Uri.parse('$host/foobar')); expect(response.statusCode, 404);