From 496b46748536c69b3bf2c681d381a9e2975d113d Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Mon, 15 Jul 2024 15:39:58 +0200 Subject: [PATCH] Compass App: Search form screen (#2353) Part of the implementation of the Compass App for the Architecture sample. **Merge to `compass-app`** This PR introduces the Search Form Screen, in which users can select a region, a date range and the number of guests. The feature is split in 5 different widgets, each one depending on the `SearchFormViewModel`. The architecture follows the same patterns implemented in the previous PR https://github.com/flutter/samples/pull/2342 TODO later on: - Error handling is yet not implemented, we need to introduce a "logger" and a way to handle error responses. - All repositories return local data, next steps include creating the dart server app. - The search query at the moment only takes the "continent" and not the dates and number of guests, that would be implemented later on as well. ## Demo [Screencast from 2024-07-12 14-30-48.webm](https://github.com/user-attachments/assets/afbcdd4e-617a-49cc-894c-8e082748e572) ## 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]. [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/dependencies.dart | 10 +- .../app/lib/data/models/continent.dart | 12 ++ .../continent/continent_repository.dart | 8 + .../continent/continent_repository_local.dart | 44 ++++++ compass_app/app/lib/main.dart | 3 + compass_app/app/lib/routing/router.dart | 28 +++- .../app/lib/ui/core/themes/colors.dart | 3 +- compass_app/app/lib/ui/core/themes/theme.dart | 26 ++++ .../app/lib/ui/core/ui/home_button.dart | 36 +++++ .../app/lib/ui/core/ui/scroll_behavior.dart | 13 ++ .../app/lib/ui/core/ui/search_bar.dart | 85 +++++++++++ .../view_models/results_viewmodel.dart | 5 +- .../ui/results/widgets/results_screen.dart | 52 ++----- .../view_models/search_form_viewmodel.dart | 107 ++++++++++++++ .../widgets/search_form_continent.dart | 139 ++++++++++++++++++ .../search_form/widgets/search_form_date.dart | 88 +++++++++++ .../widgets/search_form_guests.dart | 91 ++++++++++++ .../widgets/search_form_screen.dart | 46 ++++++ .../widgets/search_form_submit.dart | 44 ++++++ compass_app/app/pubspec.yaml | 2 + .../test/ui/results/results_screen_test.dart | 4 +- .../ui/results/results_viewmodel_test.dart | 4 +- .../search_form_viewmodel_test.dart | 64 ++++++++ .../widgets/search_form_continent_test.dart | 43 ++++++ .../widgets/search_form_date_test.dart | 58 ++++++++ .../widgets/search_form_guests_test.dart | 69 +++++++++ .../widgets/search_form_screen_test.dart | 64 ++++++++ .../widgets/search_form_submit_test.dart | 65 ++++++++ .../fake_continent_repository.dart | 14 ++ .../fake_destination_repository.dart | 0 compass_app/app/test/util/mocks.dart | 4 + 31 files changed, 1171 insertions(+), 60 deletions(-) create mode 100644 compass_app/app/lib/data/models/continent.dart create mode 100644 compass_app/app/lib/data/repositories/continent/continent_repository.dart create mode 100644 compass_app/app/lib/data/repositories/continent/continent_repository_local.dart create mode 100644 compass_app/app/lib/ui/core/ui/home_button.dart create mode 100644 compass_app/app/lib/ui/core/ui/scroll_behavior.dart create mode 100644 compass_app/app/lib/ui/core/ui/search_bar.dart create mode 100644 compass_app/app/lib/ui/search_form/view_models/search_form_viewmodel.dart create mode 100644 compass_app/app/lib/ui/search_form/widgets/search_form_continent.dart create mode 100644 compass_app/app/lib/ui/search_form/widgets/search_form_date.dart create mode 100644 compass_app/app/lib/ui/search_form/widgets/search_form_guests.dart create mode 100644 compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart create mode 100644 compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart create mode 100644 compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart create mode 100644 compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart create mode 100644 compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart create mode 100644 compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart create mode 100644 compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart create mode 100644 compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart create mode 100644 compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart rename compass_app/app/test/{ui/results => util/fakes/repositories}/fake_destination_repository.dart (100%) create mode 100644 compass_app/app/test/util/mocks.dart diff --git a/compass_app/app/lib/config/dependencies.dart b/compass_app/app/lib/config/dependencies.dart index 0d9f29b54..fb6743d9f 100644 --- a/compass_app/app/lib/config/dependencies.dart +++ b/compass_app/app/lib/config/dependencies.dart @@ -1,8 +1,11 @@ -import '../data/repositories/destination/destination_repository.dart'; -import '../data/repositories/destination/destination_repository_local.dart'; import 'package:provider/single_child_widget.dart'; import 'package:provider/provider.dart'; +import '../data/repositories/continent/continent_repository.dart'; +import '../data/repositories/continent/continent_repository_local.dart'; +import '../data/repositories/destination/destination_repository.dart'; +import '../data/repositories/destination/destination_repository_local.dart'; + /// Configure dependencies as a list of Providers List get providers { // List of Providers @@ -10,5 +13,8 @@ List get providers { Provider.value( value: DestinationRepositoryLocal() as DestinationRepository, ), + Provider.value( + value: ContinentRepositoryLocal() as ContinentRepository, + ), ]; } diff --git a/compass_app/app/lib/data/models/continent.dart b/compass_app/app/lib/data/models/continent.dart new file mode 100644 index 000000000..b9114ce22 --- /dev/null +++ b/compass_app/app/lib/data/models/continent.dart @@ -0,0 +1,12 @@ +class Continent { + /// e.g. 'Europe' + final String name; + + /// e.g. 'https://rstr.in/google/tripedia/TmR12QdlVTT' + final String imageUrl; + + Continent({ + required this.name, + required this.imageUrl, + }); +} diff --git a/compass_app/app/lib/data/repositories/continent/continent_repository.dart b/compass_app/app/lib/data/repositories/continent/continent_repository.dart new file mode 100644 index 000000000..b9155166b --- /dev/null +++ b/compass_app/app/lib/data/repositories/continent/continent_repository.dart @@ -0,0 +1,8 @@ +import '../../../utils/result.dart'; +import '../../models/continent.dart'; + +/// Data source with all possible continents. +abstract class ContinentRepository { + /// Get complete list of continents. + Future>> getContinents(); +} 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 new file mode 100644 index 000000000..8215b53d2 --- /dev/null +++ b/compass_app/app/lib/data/repositories/continent/continent_repository_local.dart @@ -0,0 +1,44 @@ +import '../../../utils/result.dart'; +import '../../models/continent.dart'; +import 'continent_repository.dart'; + +/// Local data source with all possible regions. +class ContinentRepositoryLocal implements ContinentRepository { + @override + Future>> getContinents() { + return Future.value( + Result.ok( + [ + Continent( + name: 'Europe', + imageUrl: 'https://rstr.in/google/tripedia/TmR12QdlVTT', + ), + Continent( + name: 'Asia', + imageUrl: 'https://rstr.in/google/tripedia/VJ8BXlQg8O1', + ), + Continent( + name: 'South America', + imageUrl: 'https://rstr.in/google/tripedia/flm_-o1aI8e', + ), + Continent( + name: 'Africa', + imageUrl: 'https://rstr.in/google/tripedia/-nzi8yFOBpF', + ), + Continent( + name: 'North America', + imageUrl: 'https://rstr.in/google/tripedia/jlbgFDrSUVE', + ), + Continent( + name: 'Oceania', + imageUrl: 'https://rstr.in/google/tripedia/vxyrDE-fZVL', + ), + Continent( + name: 'Australia', + imageUrl: 'https://rstr.in/google/tripedia/z6vy6HeRyvZ', + ), + ], + ), + ); + } +} diff --git a/compass_app/app/lib/main.dart b/compass_app/app/lib/main.dart index 4950a914c..a9660d195 100644 --- a/compass_app/app/lib/main.dart +++ b/compass_app/app/lib/main.dart @@ -4,6 +4,8 @@ import 'routing/router.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'ui/core/ui/scroll_behavior.dart'; + void main() { runApp( MultiProvider( @@ -21,6 +23,7 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( + scrollBehavior: AppCustomScrollBehavior(), theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.system, diff --git a/compass_app/app/lib/routing/router.dart b/compass_app/app/lib/routing/router.dart index ddc1d0935..306cae2d4 100644 --- a/compass_app/app/lib/routing/router.dart +++ b/compass_app/app/lib/routing/router.dart @@ -1,21 +1,35 @@ -import '../ui/results/widgets/results_screen.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../ui/results/view_models/results_viewmodel.dart'; +import '../ui/results/widgets/results_screen.dart'; +import '../ui/search_form/view_models/search_form_viewmodel.dart'; +import '../ui/search_form/widgets/search_form_screen.dart'; /// Top go_router entry point final router = GoRouter( - initialLocation: '/results', + initialLocation: '/', routes: [ GoRoute( - path: '/results', + path: '/', builder: (context, state) { - final viewModel = ResultsViewModel( - destinationRepository: context.read(), - ); - return ResultsScreen(viewModel: viewModel); + final viewModel = SearchFormViewModel(continentRepository: context.read()); + return SearchFormScreen(viewModel: viewModel); }, + routes: [ + GoRoute( + path: 'results', + builder: (context, state) { + final viewModel = ResultsViewModel( + destinationRepository: context.read(), + ); + final parameters = state.uri.queryParameters; + // TODO: Pass the rest of query parameters to the ViewModel + viewModel.search(continent: parameters['continent']); + return ResultsScreen(viewModel: viewModel); + }, + ), + ], ), ], ); diff --git a/compass_app/app/lib/ui/core/themes/colors.dart b/compass_app/app/lib/ui/core/themes/colors.dart index 48440a362..046fe8377 100644 --- a/compass_app/app/lib/ui/core/themes/colors.dart +++ b/compass_app/app/lib/ui/core/themes/colors.dart @@ -5,10 +5,11 @@ class AppColors { static const white1 = Color(0xFFFFF7FA); static const grey1 = Color(0xFFF2F2F2); static const grey2 = Color(0xFF4D4D4D); + static const grey3 = Color(0xFFA4A4A4); static const whiteTransparent = Color(0x4DFFFFFF); // Figma rgba(255, 255, 255, 0.3) static const blackTransparent = - Color(0x4D000000); // Figma rgba(255, 255, 255, 0.3) + Color(0x4D000000); static const lightColorScheme = ColorScheme( brightness: Brightness.light, diff --git a/compass_app/app/lib/ui/core/themes/theme.dart b/compass_app/app/lib/ui/core/themes/theme.dart index e9954bc76..e8581d490 100644 --- a/compass_app/app/lib/ui/core/themes/theme.dart +++ b/compass_app/app/lib/ui/core/themes/theme.dart @@ -3,9 +3,32 @@ import '../ui/tag_chip.dart'; import 'package:flutter/material.dart'; class AppTheme { + static const _textTheme = TextTheme( + titleMedium: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + bodyMedium: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w400, + ), + ); + + static const _inputDecorationTheme = InputDecorationTheme( + hintStyle: TextStyle( + // grey3 works for both light and dark themes + color: AppColors.grey3, + fontSize: 18.0, + fontWeight: FontWeight.w400, + ), + ); + static ThemeData lightTheme = ThemeData( useMaterial3: true, + brightness: Brightness.light, colorScheme: AppColors.lightColorScheme, + textTheme: _textTheme, + inputDecorationTheme: _inputDecorationTheme, extensions: [ TagChipTheme( chipColor: AppColors.whiteTransparent, @@ -16,7 +39,10 @@ class AppTheme { static ThemeData darkTheme = ThemeData( useMaterial3: true, + brightness: Brightness.dark, colorScheme: AppColors.darkColorScheme, + textTheme: _textTheme, + inputDecorationTheme: _inputDecorationTheme, extensions: [ TagChipTheme( chipColor: AppColors.blackTransparent, diff --git a/compass_app/app/lib/ui/core/ui/home_button.dart b/compass_app/app/lib/ui/core/ui/home_button.dart new file mode 100644 index 000000000..912b0c4ce --- /dev/null +++ b/compass_app/app/lib/ui/core/ui/home_button.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../themes/colors.dart'; + +/// Home button to navigate back to the '/' path. +class HomeButton extends StatelessWidget { + const HomeButton({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40.0, + width: 40.0, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(8.0), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8.0), + onTap: () { + context.go('/'); + }, + child: Center( + child: Icon( + size: 24.0, + Icons.home_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), + ); + } +} diff --git a/compass_app/app/lib/ui/core/ui/scroll_behavior.dart b/compass_app/app/lib/ui/core/ui/scroll_behavior.dart new file mode 100644 index 000000000..73b2be6df --- /dev/null +++ b/compass_app/app/lib/ui/core/ui/scroll_behavior.dart @@ -0,0 +1,13 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Custom scroll behavior to allow dragging with mouse. +/// Necessary to allow dragging with mouse on Continents carousel. +class AppCustomScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => { + PointerDeviceKind.touch, + // Allow to drag with mouse on Regions carousel + PointerDeviceKind.mouse, + }; +} diff --git a/compass_app/app/lib/ui/core/ui/search_bar.dart b/compass_app/app/lib/ui/core/ui/search_bar.dart new file mode 100644 index 000000000..6ae15a4ba --- /dev/null +++ b/compass_app/app/lib/ui/core/ui/search_bar.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../themes/colors.dart'; +import 'home_button.dart'; + +/// Application top search bar. +/// +/// Displays a search bar with the current query. +/// Includes [HomeButton] to navigate back to the '/' path. +class AppSearchBar extends StatelessWidget { + const AppSearchBar({ + super.key, + this.query, + }); + + final String? query; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Container( + height: 64, + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(16.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: query != null + ? _QueryText(query: query!) + : const _EmptySearch(), + ), + ), + ), + ), + const SizedBox(width: 10), + const HomeButton(), + ], + ); + } +} + +class _QueryText extends StatelessWidget { + const _QueryText({ + required this.query, + }); + + final String query; + + @override + Widget build(BuildContext context) { + return Text( + query, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ); + } +} + +class _EmptySearch extends StatelessWidget { + const _EmptySearch(); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.search), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Search destination', + 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 7057afbe8..58643a031 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 @@ -8,10 +8,7 @@ import 'package:flutter/cupertino.dart'; class ResultsViewModel extends ChangeNotifier { ResultsViewModel({ required DestinationRepository destinationRepository, - }) : _destinationRepository = destinationRepository { - // Preload a search result - search(continent: 'Europe'); - } + }) : _destinationRepository = destinationRepository; final DestinationRepository _destinationRepository; 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 127f7756a..4b4b2d4b9 100644 --- a/compass_app/app/lib/ui/results/widgets/results_screen.dart +++ b/compass_app/app/lib/ui/results/widgets/results_screen.dart @@ -1,7 +1,8 @@ -import '../../core/themes/colors.dart'; +import 'package:flutter/material.dart'; + +import '../../core/ui/search_bar.dart'; import '../view_models/results_viewmodel.dart'; import 'result_card.dart'; -import 'package:flutter/material.dart'; class ResultsScreen extends StatelessWidget { const ResultsScreen({ @@ -24,7 +25,12 @@ class ResultsScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20.0), child: CustomScrollView( slivers: [ - _Search(viewModel: viewModel), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24, bottom: 24), + child: AppSearchBar(query: viewModel.filters), + ), + ), _Grid(viewModel: viewModel), ], ), @@ -63,43 +69,3 @@ class _Grid extends StatelessWidget { ); } } - -class _Search extends StatelessWidget { - const _Search({ - required this.viewModel, - }); - - final ResultsViewModel viewModel; - - @override - Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 60, bottom: 24), - child: Container( - height: 64, - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey1), - borderRadius: BorderRadius.circular(16.0), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - viewModel.filters, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w400, - height: 0, - leadingDistribution: TextLeadingDistribution.even, - ), - ), - ), - ), - ), - ), - ); - } -} 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 new file mode 100644 index 000000000..44f875532 --- /dev/null +++ b/compass_app/app/lib/ui/search_form/view_models/search_form_viewmodel.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../data/models/continent.dart'; +import '../../../data/repositories/continent/continent_repository.dart'; +import '../../../utils/result.dart'; + +final _dateFormat = DateFormat('yyyy-MM-dd'); + +/// View model for the search form. +/// +/// Contains the form selected options +/// and the logic to load the list of regions. +class SearchFormViewModel extends ChangeNotifier { + SearchFormViewModel({ + required ContinentRepository continentRepository, + }) : _continentRepository = continentRepository { + load(); + } + + final ContinentRepository _continentRepository; + List _continents = []; + String? _selectedContinent; + DateTimeRange? _dateRange; + int _guests = 0; + + /// True if the form is valid and can be submitted + bool get valid => + _guests > 0 && _selectedContinent != null && _dateRange != null; + + /// Returns the search query string to call the Results screen + /// e.g. 'destination=Europe&checkIn=2024-05-09&checkOut=2024-05-24&guests=1', + /// Must be called only if [valid] is true + get searchQuery { + assert(valid, "Called searchQuery when the form is not valid"); + assert(_selectedContinent != null, "Called searchQuery without a continent"); + assert(_dateRange != null, "Called searchQuery without a date range"); + assert(_guests > 0, "Called searchQuery without guests"); + final startDate = _dateRange!.start; + final endDate = _dateRange!.end; + final uri = Uri(queryParameters: { + 'continent': _selectedContinent!, + 'checkIn': _dateFormat.format(startDate), + 'checkOut': _dateFormat.format(endDate), + 'guests': _guests.toString(), + }); + return uri.query; + } + + /// List of continents. + /// Loaded in [load] method. + List get continents => _continents; + + /// Load the list of continents. + Future load() async { + final result = await _continentRepository.getContinents(); + switch (result) { + case Ok(): + { + _continents = result.value; + } + case Error(): + { + // TODO: Handle error + // ignore: avoid_print + print(result.error); + } + } + notifyListeners(); + } + + /// Selected continent. + /// Null means no continent is selected. + String? get selectedContinent => _selectedContinent; + + /// Set selected continent. + /// Set to null to clear the selection. + set selectedContinent(String? continent) { + _selectedContinent = continent; + notifyListeners(); + } + + /// Date range. + /// Null means no range is selected. + DateTimeRange? get dateRange => _dateRange; + + /// Set date range. + /// Can be set to null to clear selection. + set dateRange(DateTimeRange? dateRange) { + _dateRange = dateRange; + notifyListeners(); + } + + /// Number of guests + int get guests => _guests; + + /// Set number of guests + /// If the quantity is negative, it will be set to 0 + set guests(int quantity) { + if (quantity < 0) { + _guests = 0; + } else { + _guests = quantity; + } + notifyListeners(); + } +} 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 new file mode 100644 index 000000000..4960145e4 --- /dev/null +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_continent.dart @@ -0,0 +1,139 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../data/models/continent.dart'; +import '../../core/themes/colors.dart'; +import '../view_models/search_form_viewmodel.dart'; + +/// Continent selection carousel +/// +/// Loads a list of continents in a horizontal carousel. +/// Users can tap one item to select it. +/// Tapping again the same item will deselect it. +class SearchFormContinent extends StatelessWidget { + const SearchFormContinent({ + super.key, + required this.viewModel, + }); + + final SearchFormViewModel viewModel; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 140, + child: ListenableBuilder( + listenable: viewModel, + builder: (context, child) { + return ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: viewModel.continents.length, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemBuilder: (BuildContext context, int index) { + final Continent(:imageUrl, :name) = viewModel.continents[index]; + return _CarouselItem( + key: ValueKey(name), + imageUrl: imageUrl, + name: name, + viewModel: viewModel, + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const SizedBox(width: 8); + }, + ); + }, + ), + ); + } +} + +class _CarouselItem extends StatelessWidget { + const _CarouselItem({ + super.key, + required this.imageUrl, + required this.name, + required this.viewModel, + }); + + final String imageUrl; + final String name; + final SearchFormViewModel viewModel; + + bool _selected() => + viewModel.selectedContinent == null || + viewModel.selectedContinent == name; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 140, + height: 140, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + errorWidget: (context, url, error) { + // NOTE: Getting "invalid image data" error for some of the images + // e.g. https://rstr.in/google/tripedia/jlbgFDrSUVE + return const DecoratedBox( + decoration: BoxDecoration( + color: AppColors.grey3, + ), + child: SizedBox(width: 140, height: 140), + ); + }, + ), + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + name, + textAlign: TextAlign.center, + style: GoogleFonts.openSans( + fontSize: 18, + fontWeight: FontWeight.w500, + color: AppColors.white1, + ), + ), + ), + ), + // Overlay when other continent is selected + Positioned.fill( + child: AnimatedOpacity( + opacity: _selected() ? 0 : 0.7, + duration: kThemeChangeDuration, + child: DecoratedBox( + decoration: BoxDecoration( + // Support dark-mode + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + // Handle taps + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + if (viewModel.selectedContinent == name) { + viewModel.selectedContinent = null; + } else { + viewModel.selectedContinent = name; + } + }, + ), + ), + ), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 000000000..aab7b5424 --- /dev/null +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_date.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../core/themes/colors.dart'; +import '../view_models/search_form_viewmodel.dart'; + +final _dateFormatDay = DateFormat('d'); +final _dateFormatDayMonth = DateFormat('d MMM'); + +/// Date selection form field. +/// +/// Opens a date range picker dialog when tapped. +class SearchFormDate extends StatelessWidget { + const SearchFormDate({ + super.key, + required this.viewModel, + }); + + final SearchFormViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 24, left: 20, right: 20), + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: () { + showDateRangePicker( + context: context, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ).then((dateRange) => viewModel.dateRange = dateRange); + }, + child: Container( + height: 64, + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(16.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'When', + style: Theme.of(context).textTheme.titleMedium, + ), + ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + final dateRange = viewModel.dateRange; + if (dateRange != null) { + return Text( + _dateFormat(dateRange), + style: Theme.of(context).textTheme.bodyMedium, + ); + } else { + return Text( + 'Add Dates', + style: Theme.of(context).inputDecorationTheme.hintStyle, + ); + } + }, + ) + ], + ), + ), + ), + ), + ); + } + + String _dateFormat(DateTimeRange dateTimeRange) { + final start = dateTimeRange.start; + final end = dateTimeRange.end; + + final dayMonthEnd = _dateFormatDayMonth.format(end); + + if (start.month == end.month) { + final dayStart = _dateFormatDay.format(start); + return '$dayStart - $dayMonthEnd'; + } + + final dayMonthStart = _dateFormatDayMonth.format(start); + return '$dayMonthStart - $dayMonthEnd'; + } +} 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 new file mode 100644 index 000000000..4980a206b --- /dev/null +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_guests.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import '../../core/themes/colors.dart'; +import '../view_models/search_form_viewmodel.dart'; + +/// Number of guests selection form +/// +/// Users can tap the Plus and Minus icons to increase or decrease +/// the number of guests. +class SearchFormGuests extends StatelessWidget { + const SearchFormGuests({ + super.key, + required this.viewModel, + }); + + final SearchFormViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 24, left: 20, right: 20), + child: Container( + height: 64, + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey1), + borderRadius: BorderRadius.circular(16.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Who', + style: Theme.of(context).textTheme.titleMedium, + ), + _QuantitySelector(viewModel), + ], + ), + ), + ), + ); + } +} + +class _QuantitySelector extends StatelessWidget { + const _QuantitySelector(this.viewModel); + + final SearchFormViewModel viewModel; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 90, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + key: const ValueKey('remove_guests'), + onTap: () { + viewModel.guests--; + }, + child: const Icon( + Icons.remove_circle_outline, + color: AppColors.grey3, + ), + ), + ListenableBuilder( + listenable: viewModel, + builder: (context, _) => Text( + viewModel.guests.toString(), + style: viewModel.guests == 0 + ? Theme.of(context).inputDecorationTheme.hintStyle + : Theme.of(context).textTheme.bodyMedium, + ), + ), + InkWell( + key: const ValueKey('add_guests'), + onTap: () { + viewModel.guests++; + }, + child: const Icon( + Icons.add_circle_outline, + color: AppColors.grey3, + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 000000000..85b597ef1 --- /dev/null +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_screen.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../../core/ui/search_bar.dart'; +import '../../results/widgets/results_screen.dart'; +import '../view_models/search_form_viewmodel.dart'; +import 'search_form_date.dart'; +import 'search_form_guests.dart'; +import 'search_form_continent.dart'; +import 'search_form_submit.dart'; + +/// Search form screen +/// +/// Displays a search form with continent, date and guests selection. +/// Tapping on the submit button opens the [ResultsScreen] screen +/// passing the search options as query parameters. +class SearchFormScreen extends StatelessWidget { + const SearchFormScreen({ + super.key, + required this.viewModel, + }); + + final SearchFormViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: AppSearchBar(), + ), + SearchFormContinent(viewModel: viewModel), + SearchFormDate(viewModel: viewModel), + SearchFormGuests(viewModel: viewModel), + const Spacer(), + SearchFormSubmit(viewModel: viewModel), + ], + ), + ); + } +} diff --git a/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart b/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart new file mode 100644 index 000000000..e8e1047dc --- /dev/null +++ b/compass_app/app/lib/ui/search_form/widgets/search_form_submit.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../results/widgets/results_screen.dart'; +import '../view_models/search_form_viewmodel.dart'; + +/// Search form submit button +/// +/// 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 { + const SearchFormSubmit({ + super.key, + required this.viewModel, + }); + + final SearchFormViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ListenableBuilder( + listenable: viewModel, + child: const SizedBox( + height: 52, + child: Center( + child: Text('Search'), + ), + ), + builder: (context, child) { + return FilledButton( + key: const ValueKey('submit_button'), + onPressed: viewModel.valid + ? () => context.go('/results?${viewModel.searchQuery}') + : null, + child: child, + ); + }, + ), + ); + } +} diff --git a/compass_app/app/pubspec.yaml b/compass_app/app/pubspec.yaml index f8ea76fbd..bf95a64b1 100644 --- a/compass_app/app/pubspec.yaml +++ b/compass_app/app/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: sdk: flutter go_router: ^14.2.0 google_fonts: ^6.2.1 + intl: ^0.19.0 provider: ^6.1.2 dev_dependencies: @@ -19,6 +20,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^4.0.0 mocktail_image_network: ^1.2.0 + mocktail: ^1.0.4 flutter: uses-material-design: true diff --git a/compass_app/app/test/ui/results/results_screen_test.dart b/compass_app/app/test/ui/results/results_screen_test.dart index 391e23524..390d8388e 100644 --- a/compass_app/app/test/ui/results/results_screen_test.dart +++ b/compass_app/app/test/ui/results/results_screen_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:mocktail_image_network/mocktail_image_network.dart'; -import 'fake_destination_repository.dart'; +import '../../util/fakes/repositories/fake_destination_repository.dart'; void main() { // TODO: Add more cases @@ -36,6 +36,8 @@ Future loadScreen(WidgetTester tester) async { final viewModel = ResultsViewModel( destinationRepository: FakeDestinationRepository(), ); + // Load some data + viewModel.search(); await tester.pumpWidget( MaterialApp( home: ResultsScreen( diff --git a/compass_app/app/test/ui/results/results_viewmodel_test.dart b/compass_app/app/test/ui/results/results_viewmodel_test.dart index 53128bfbe..56189ede1 100644 --- a/compass_app/app/test/ui/results/results_viewmodel_test.dart +++ b/compass_app/app/test/ui/results/results_viewmodel_test.dart @@ -1,13 +1,13 @@ import 'package:compass_app/ui/results/view_models/results_viewmodel.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'fake_destination_repository.dart'; +import '../../util/fakes/repositories/fake_destination_repository.dart'; void main() { group('ResultsViewModel tests', () { final viewModel = ResultsViewModel( destinationRepository: FakeDestinationRepository(), - ); + )..search(); // perform a simple test // verifies that the list of items is properly loaded 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 new file mode 100644 index 000000000..e4338e5ab --- /dev/null +++ b/compass_app/app/test/ui/search_form/view_models/search_form_viewmodel_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; + +import '../../../util/fakes/repositories/fake_continent_repository.dart'; + +void main() { + group('SearchFormViewModel Tests', () { + late SearchFormViewModel viewModel; + + setUp(() { + viewModel = SearchFormViewModel(continentRepository: FakeContinentRepository()); + }); + + test('Initial values are correct', () { + expect(viewModel.valid, false); + expect(viewModel.selectedContinent, null); + expect(viewModel.dateRange, null); + expect(viewModel.guests, 0); + }); + + test('Setting dateRange updates correctly', () { + final DateTimeRange newDateRange = DateTimeRange( + start: DateTime(2024, 1, 1), + end: DateTime(2024, 1, 31), + ); + viewModel.dateRange = newDateRange; + expect(viewModel.dateRange, newDateRange); + }); + + test('Setting selectedContinent updates correctly', () { + viewModel.selectedContinent = 'CONTINENT'; + expect(viewModel.selectedContinent, 'CONTINENT'); + + // Setting null should work + viewModel.selectedContinent = null; + expect(viewModel.selectedContinent, null); + }); + + test('Setting guests updates correctly', () { + viewModel.guests = 2; + expect(viewModel.guests, 2); + + // Guests number should not be negative + viewModel.guests = -1; + expect(viewModel.guests, 0); + }); + + test('Set all values and obtain query', () { + expect(viewModel.valid, false); + + viewModel.guests = 2; + viewModel.selectedContinent = 'CONTINENT'; + final DateTimeRange newDateRange = DateTimeRange( + start: DateTime(2024, 1, 1), + end: DateTime(2024, 1, 31), + ); + viewModel.dateRange = newDateRange; + + expect(viewModel.valid, true); + expect(viewModel.searchQuery, 'continent=CONTINENT&checkIn=2024-01-01&checkOut=2024-01-31&guests=2'); + }); + }); +} diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart new file mode 100644 index 000000000..c6e0f36a2 --- /dev/null +++ b/compass_app/app/test/ui/search_form/widgets/search_form_continent_test.dart @@ -0,0 +1,43 @@ +import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; +import 'package:compass_app/ui/search_form/widgets/search_form_continent.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; + +import '../../../util/fakes/repositories/fake_continent_repository.dart'; + +void main() { + group('SearchFormContinent widget tests', () { + late SearchFormViewModel viewModel; + + setUp(() { + viewModel = SearchFormViewModel( + continentRepository: FakeContinentRepository(), + ); + }); + + loadWidget(WidgetTester tester) async { + await mockNetworkImages(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchFormContinent( + viewModel: viewModel, + ), + ), + ), + ); + }); + } + + testWidgets('Should load and select continent', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormContinent), findsOneWidget); + + // Select continent + await tester.tap(find.text('CONTINENT'), warnIfMissed: false); + + expect(viewModel.selectedContinent, 'CONTINENT'); + }); + }); +} diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart new file mode 100644 index 000000000..3a0c12b77 --- /dev/null +++ b/compass_app/app/test/ui/search_form/widgets/search_form_date_test.dart @@ -0,0 +1,58 @@ +import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; +import 'package:compass_app/ui/search_form/widgets/search_form_date.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; + +import '../../../util/fakes/repositories/fake_continent_repository.dart'; + +void main() { + group('SearchFormDate widget tests', () { + late SearchFormViewModel viewModel; + + setUp(() { + viewModel = SearchFormViewModel( + continentRepository: FakeContinentRepository(), + ); + }); + + loadWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchFormDate( + viewModel: viewModel, + ), + ), + ), + ); + } + + testWidgets('should display date in different month', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormDate), findsOneWidget); + + // Initial state + expect(find.text('Add Dates'), findsOneWidget); + + // Simulate date picker input: + viewModel.dateRange = DateTimeRange(start: DateTime(2024, 6, 12), end: DateTime(2024, 7, 23)); + await tester.pumpAndSettle(); + + expect(find.text('12 Jun - 23 Jul'), findsOneWidget); + }); + + testWidgets('should display date in same month', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormDate), findsOneWidget); + + // Initial state + expect(find.text('Add Dates'), findsOneWidget); + + // Simulate date picker input: + viewModel.dateRange = DateTimeRange(start: DateTime(2024, 6, 12), end: DateTime(2024, 6, 23)); + await tester.pumpAndSettle(); + + expect(find.text('12 - 23 Jun'), findsOneWidget); + }); + }); +} diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart new file mode 100644 index 000000000..30c94334a --- /dev/null +++ b/compass_app/app/test/ui/search_form/widgets/search_form_guests_test.dart @@ -0,0 +1,69 @@ +import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; +import 'package:compass_app/ui/search_form/widgets/search_form_guests.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; + +import '../../../util/fakes/repositories/fake_continent_repository.dart'; + +void main() { + group('SearchFormGuests widget tests', () { + late SearchFormViewModel viewModel; + + setUp(() { + viewModel = SearchFormViewModel( + continentRepository: FakeContinentRepository(), + ); + }); + + loadWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchFormGuests( + viewModel: viewModel, + ), + ), + ), + ); + } + + testWidgets('Increase number of guests', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormGuests), findsOneWidget); + + // Initial state + expect(find.text('0'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('add_guests'))); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('Decrease number of guests', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormGuests), findsOneWidget); + + // Initial state + expect(find.text('0'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('remove_guests'))); + await tester.pumpAndSettle(); + + // Should remain at 0 + expect(find.text('0'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('add_guests'))); + await tester.pumpAndSettle(); + + // Increase to 1 + expect(find.text('1'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('remove_guests'))); + await tester.pumpAndSettle(); + + // Back to 0 + expect(find.text('0'), findsOneWidget); + }); + }); +} diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart new file mode 100644 index 000000000..6634963df --- /dev/null +++ b/compass_app/app/test/ui/search_form/widgets/search_form_screen_test.dart @@ -0,0 +1,64 @@ +import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; +import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; + +import '../../../util/mocks.dart'; +import '../../../util/fakes/repositories/fake_continent_repository.dart'; + +void main() { + group('SearchFormScreen widget tests', () { + late SearchFormViewModel viewModel; + late MockGoRouter goRouter; + + setUp(() { + viewModel = SearchFormViewModel( + continentRepository: FakeContinentRepository(), + ); + goRouter = MockGoRouter(); + }); + + loadWidget(WidgetTester tester) async { + await mockNetworkImages(() async { + await tester.pumpWidget( + MaterialApp( + home: InheritedGoRouter( + goRouter: goRouter, + child: Scaffold( + body: SearchFormScreen( + viewModel: viewModel, + ), + ), + ), + ), + ); + }); + } + + testWidgets('Should fill form and perform search', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormScreen), findsOneWidget); + + // Select continent + await tester.tap(find.text('CONTINENT'), warnIfMissed: false); + + // Select date + viewModel.dateRange = DateTimeRange(start: DateTime(2024, 6, 12), end: DateTime(2024, 7, 23)); + + // Select guests + await tester.tap(find.byKey(const ValueKey('add_guests'))); + + // Refresh screen state + await tester.pumpAndSettle(); + + // Perform search + await tester.tap(find.byKey(const ValueKey('submit_button'))); + + // Should navigate to results screen + verify(() => goRouter.go('/results?continent=CONTINENT&checkIn=2024-06-12&checkOut=2024-07-23&guests=1')).called(1); + }); + }); +} diff --git a/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart b/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart new file mode 100644 index 000000000..9caac82e6 --- /dev/null +++ b/compass_app/app/test/ui/search_form/widgets/search_form_submit_test.dart @@ -0,0 +1,65 @@ +import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart'; +import 'package:compass_app/ui/search_form/widgets/search_form_submit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../util/fakes/repositories/fake_continent_repository.dart'; +import '../../../util/mocks.dart'; + +void main() { + group('SearchFormSubmit widget tests', () { + late SearchFormViewModel viewModel; + late MockGoRouter goRouter; + + setUp(() { + viewModel = SearchFormViewModel( + continentRepository: FakeContinentRepository(), + ); + goRouter = MockGoRouter(); + }); + + loadWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: InheritedGoRouter( + goRouter: goRouter, + child: Scaffold( + body: SearchFormSubmit( + viewModel: viewModel, + ), + ), + ), + ), + ); + } + + testWidgets('Should be enabled and allow tap', (WidgetTester tester) async { + await loadWidget(tester); + expect(find.byType(SearchFormSubmit), findsOneWidget); + + // Tap should not navigate + await tester.tap(find.byKey(const ValueKey('submit_button'))); + verifyNever(() => goRouter.go(any())); + + // Fill in data + viewModel.guests = 2; + viewModel.selectedContinent = 'CONTINENT'; + final DateTimeRange newDateRange = DateTimeRange( + start: DateTime(2024, 1, 1), + end: DateTime(2024, 1, 31), + ); + viewModel.dateRange = newDateRange; + await tester.pumpAndSettle(); + + // Perform search + await tester.tap(find.byKey(const ValueKey('submit_button'))); + + // Should navigate to results screen + verify(() => goRouter.go( + '/results?continent=CONTINENT&checkIn=2024-01-01&checkOut=2024-01-31&guests=2')) + .called(1); + }); + }); +} diff --git a/compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart b/compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart new file mode 100644 index 000000000..f08f84b7a --- /dev/null +++ b/compass_app/app/test/util/fakes/repositories/fake_continent_repository.dart @@ -0,0 +1,14 @@ +import 'package:compass_app/data/models/continent.dart'; +import 'package:compass_app/data/repositories/continent/continent_repository.dart'; +import 'package:compass_app/utils/result.dart'; + +class FakeContinentRepository implements ContinentRepository { + @override + Future>> getContinents() async { + return Result.ok([ + Continent(name: 'CONTINENT', imageUrl: 'URL'), + Continent(name: 'CONTINENT2', imageUrl: 'URL'), + Continent(name: 'CONTINENT3', imageUrl: 'URL'), + ]); + } +} diff --git a/compass_app/app/test/ui/results/fake_destination_repository.dart b/compass_app/app/test/util/fakes/repositories/fake_destination_repository.dart similarity index 100% rename from compass_app/app/test/ui/results/fake_destination_repository.dart rename to compass_app/app/test/util/fakes/repositories/fake_destination_repository.dart diff --git a/compass_app/app/test/util/mocks.dart b/compass_app/app/test/util/mocks.dart new file mode 100644 index 000000000..af44499df --- /dev/null +++ b/compass_app/app/test/util/mocks.dart @@ -0,0 +1,4 @@ +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockGoRouter extends Mock implements GoRouter {}