1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +00:00

Compass app (#2446)

This commit is contained in:
Eric Windmill
2024-09-27 18:49:27 -04:00
committed by GitHub
parent fcf2552cda
commit 46b5a26b26
326 changed files with 53272 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/continent/continent_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
/// 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,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _continentRepository = continentRepository,
_itineraryConfigRepository = itineraryConfigRepository {
updateItineraryConfig = Command0(_updateItineraryConfig);
load = Command0(_load)..execute();
}
final _log = Logger('SearchFormViewModel');
final ContinentRepository _continentRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
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;
/// List of continents.
/// Loaded in [load] command.
List<Continent> get continents => _continents;
/// 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;
_log.finest('Selected continent: $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;
_log.finest('Selected date range: $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;
}
_log.finest('Set guests number: $_guests');
notifyListeners();
}
/// Load the list of continents and current itinerary config.
late final Command0 load;
/// Store ViewModel data into [ItineraryConfigRepository] before navigating.
late final Command0 updateItineraryConfig;
Future<Result<void>> _load() async {
final result = await _loadContinents();
if (result is Error) {
return result;
}
return await _loadItineraryConfig();
}
Future<Result<void>> _loadContinents() async {
final result = await _continentRepository.getContinents();
switch (result) {
case Ok():
{
_continents = result.value;
_log.fine('Continents (${_continents.length}) loaded');
}
case Error():
{
_log.warning('Failed to load continents', result.asError.error);
}
}
notifyListeners();
return result;
}
Future<Result<void>> _loadItineraryConfig() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
switch (result) {
case Ok<ItineraryConfig>():
{
final itineraryConfig = result.value;
_selectedContinent = itineraryConfig.continent;
if (itineraryConfig.startDate != null &&
itineraryConfig.endDate != null) {
_dateRange = DateTimeRange(
start: itineraryConfig.startDate!,
end: itineraryConfig.endDate!,
);
}
_guests = itineraryConfig.guests ?? 0;
_log.fine('ItineraryConfig loaded');
notifyListeners();
}
case Error<ItineraryConfig>():
{
_log.warning(
'Failed to load stored ItineraryConfig',
result.asError.error,
);
}
}
return result;
}
Future<Result<void>> _updateItineraryConfig() async {
assert(valid, "called when valid was false");
final result = await _itineraryConfigRepository.setItineraryConfig(
ItineraryConfig(
continent: _selectedContinent,
startDate: _dateRange!.start,
endDate: _dateRange!.end,
guests: _guests,
),
);
switch (result) {
case Ok<void>():
_log.fine('ItineraryConfig saved');
case Error<void>():
_log.warning('Failed to store ItineraryConfig', result.error);
}
return result;
}
}

View File

@@ -0,0 +1,168 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../domain/models/continent/continent.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/colors.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/error_indicator.dart';
import '../view_models/search_form_viewmodel.dart';
/// Continent selection carousel
///
/// 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.load,
builder: (context, child) {
if (viewModel.load.running) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (viewModel.load.error) {
return Center(
child: ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingContinents,
label: AppLocalization.of(context).tryAgain,
onPressed: viewModel.load.execute,
),
);
}
return child!;
},
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: viewModel.continents.length,
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
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,
errorListener: imageErrorListener,
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;
}
},
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,82 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/date_format_start_end.dart';
import '../../core/themes/colors.dart';
import '../view_models/search_form_viewmodel.dart';
/// 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: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
),
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: Dimens.paddingHorizontal,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalization.of(context).when,
style: Theme.of(context).textTheme.titleMedium,
),
ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
final dateRange = viewModel.dateRange;
if (dateRange != null) {
return Text(
dateFormatStartEnd(dateRange),
style: Theme.of(context).textTheme.bodyLarge,
);
} else {
return Text(
AppLocalization.of(context).addDates,
style: Theme.of(context).inputDecorationTheme.hintStyle,
);
}
},
)
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import '../../core/themes/colors.dart';
import '../../core/themes/dimens.dart';
import '../view_models/search_form_viewmodel.dart';
const String removeGuestsKey = 'remove-guests';
const String addGuestsKey = 'add-guests';
/// 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: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
),
child: Container(
height: 64,
decoration: BoxDecoration(
border: Border.all(color: AppColors.grey1),
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingHorizontal,
),
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(removeGuestsKey),
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(addGuestsKey),
onTap: () {
viewModel.guests++;
},
child: const Icon(
Icons.add_circle_outline,
color: AppColors.grey3,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,65 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/search_bar.dart';
import '../../results/widgets/results_screen.dart';
import '../view_models/search_form_viewmodel.dart';
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 PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go(Routes.home);
},
child: Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
top: true,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: Dimens.of(context).paddingScreenVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.paddingVertical,
),
child: const AppSearchBar(),
),
),
SearchFormContinent(viewModel: viewModel),
SearchFormDate(viewModel: viewModel),
SearchFormGuests(viewModel: viewModel),
const Spacer(),
SearchFormSubmit(viewModel: viewModel),
],
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../results/widgets/results_screen.dart';
import '../view_models/search_form_viewmodel.dart';
const String searchFormSubmitButtonKey = 'submit-button';
/// 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 StatefulWidget {
const SearchFormSubmit({
super.key,
required this.viewModel,
});
final SearchFormViewModel viewModel;
@override
State<SearchFormSubmit> createState() => _SearchFormSubmitState();
}
class _SearchFormSubmitState extends State<SearchFormSubmit> {
@override
void initState() {
super.initState();
widget.viewModel.updateItineraryConfig.addListener(_onResult);
}
@override
void didUpdateWidget(covariant SearchFormSubmit oldWidget) {
oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult);
widget.viewModel.updateItineraryConfig.addListener(_onResult);
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
widget.viewModel.updateItineraryConfig.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.of(context).paddingScreenVertical,
),
child: ListenableBuilder(
listenable: widget.viewModel,
child: SizedBox(
height: 52,
child: Center(
child: Text(AppLocalization.of(context).search),
),
),
builder: (context, child) {
return FilledButton(
key: const ValueKey(searchFormSubmitButtonKey),
onPressed: widget.viewModel.valid
? widget.viewModel.updateItineraryConfig.execute
: null,
child: child,
);
},
),
);
}
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go(Routes.results);
}
if (widget.viewModel.updateItineraryConfig.error) {
widget.viewModel.updateItineraryConfig.clearResult();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalization.of(context).errorWhileSavingItinerary),
action: SnackBarAction(
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.updateItineraryConfig.execute,
),
));
}
}
}