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].

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

@ -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<SingleChildWidget> get providers {
// List of Providers
@ -10,5 +13,8 @@ List<SingleChildWidget> get providers {
Provider.value(
value: DestinationRepositoryLocal() as DestinationRepository,
),
Provider.value(
value: ContinentRepositoryLocal() as ContinentRepository,
),
];
}

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

@ -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<Result<List<Continent>>> getContinents();
}

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

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

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

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

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

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

@ -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<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
// Allow to drag with mouse on Regions carousel
PointerDeviceKind.mouse,
};
}

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

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

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

@ -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<Continent> _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<Continent> get continents => _continents;
/// Load the list of continents.
Future<void> load() async {
final result = await _continentRepository.getContinents();
switch (result) {
case Ok():
{
_continents = result.value;
}
case Error():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
}
}
notifyListeners();
}
/// Selected continent.
/// 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();
}
}

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

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

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

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

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

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

@ -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<void> loadScreen(WidgetTester tester) async {
final viewModel = ResultsViewModel(
destinationRepository: FakeDestinationRepository(),
);
// Load some data
viewModel.search();
await tester.pumpWidget(
MaterialApp(
home: ResultsScreen(

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

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

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

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

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

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

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

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

@ -0,0 +1,4 @@
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
class MockGoRouter extends Mock implements GoRouter {}
Loading…
Cancel
Save