mirror of https://github.com/flutter/samples.git
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.mdpull/2389/head
parent
cfedff5a5c
commit
496b467485
@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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…
Reference in new issue