1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 23:08:59 +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,142 @@
// 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/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/activity/activity_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/activity/activity.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
class ActivitiesViewModel extends ChangeNotifier {
ActivitiesViewModel({
required ActivityRepository activityRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _activityRepository = activityRepository,
_itineraryConfigRepository = itineraryConfigRepository {
loadActivities = Command0(_loadActivities)..execute();
saveActivities = Command0(_saveActivities);
}
final _log = Logger('ActivitiesViewModel');
final ActivityRepository _activityRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
List<Activity> _daytimeActivities = <Activity>[];
List<Activity> _eveningActivities = <Activity>[];
final Set<String> _selectedActivities = <String>{};
/// List of daytime [Activity] per destination.
List<Activity> get daytimeActivities => _daytimeActivities;
/// List of evening [Activity] per destination.
List<Activity> get eveningActivities => _eveningActivities;
/// Selected [Activity] by ref.
Set<String> get selectedActivities => _selectedActivities;
/// Load list of [Activity] for a [Destination] by ref.
late final Command0 loadActivities;
/// Save list [selectedActivities] into itinerary configuration.
late final Command0 saveActivities;
Future<Result<void>> _loadActivities() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
if (result is Error) {
_log.warning(
'Failed to load stored ItineraryConfig',
result.asError.error,
);
return result;
}
final destinationRef = result.asOk.value.destination;
if (destinationRef == null) {
_log.severe('Destination missing in ItineraryConfig');
return Result.error(Exception('Destination not found'));
}
_selectedActivities.addAll(result.asOk.value.activities);
final resultActivities =
await _activityRepository.getByDestination(destinationRef);
switch (resultActivities) {
case Ok():
{
_daytimeActivities = resultActivities.value
.where((activity) => [
TimeOfDay.any,
TimeOfDay.morning,
TimeOfDay.afternoon,
].contains(activity.timeOfDay))
.toList();
_eveningActivities = resultActivities.value
.where((activity) => [
TimeOfDay.evening,
TimeOfDay.night,
].contains(activity.timeOfDay))
.toList();
_log.fine('Activities (daytime: ${_daytimeActivities.length}, '
'evening: ${_eveningActivities.length}) loaded');
}
case Error():
{
_log.warning('Failed to load activities', resultActivities.error);
}
}
notifyListeners();
return resultActivities;
}
/// Add [Activity] to selected list.
void addActivity(String activityRef) {
assert(
(_daytimeActivities + _eveningActivities)
.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.add(activityRef);
_log.finest('Activity $activityRef added');
notifyListeners();
}
/// Remove [Activity] from selected list.
void removeActivity(String activityRef) {
assert(
(_daytimeActivities + _eveningActivities)
.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.remove(activityRef);
_log.finest('Activity $activityRef removed');
notifyListeners();
}
Future<Result<void>> _saveActivities() async {
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
_log.warning(
'Failed to load stored ItineraryConfig',
resultConfig.asError.error,
);
return resultConfig;
}
final itineraryConfig = resultConfig.asOk.value;
final result = await _itineraryConfigRepository.setItineraryConfig(
itineraryConfig.copyWith(activities: _selectedActivities.toList()));
if (result is Error) {
_log.warning(
'Failed to store ItineraryConfig',
result.asError.error,
);
}
return result;
}
}

View File

@@ -0,0 +1,48 @@
// 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 '../../core/ui/back_button.dart';
import '../../core/ui/home_button.dart';
class ActivitiesHeader extends StatelessWidget {
const ActivitiesHeader({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
top: true,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
top: Dimens.of(context).paddingScreenVertical,
bottom: Dimens.paddingVertical,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomBackButton(
onTap: () {
// Navigate to ResultsScreen and edit search
context.go(Routes.results);
},
),
Text(
AppLocalization.of(context).activities,
style: Theme.of(context).textTheme.titleLarge,
),
const HomeButton(),
],
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
// 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/dimens.dart';
import '../view_models/activities_viewmodel.dart';
import 'activity_entry.dart';
import 'activity_time_of_day.dart';
class ActivitiesList extends StatelessWidget {
const ActivitiesList({
super.key,
required this.viewModel,
required this.activityTimeOfDay,
});
final ActivitiesViewModel viewModel;
final ActivityTimeOfDay activityTimeOfDay;
@override
Widget build(BuildContext context) {
final list = switch (activityTimeOfDay) {
ActivityTimeOfDay.daytime => viewModel.daytimeActivities,
ActivityTimeOfDay.evening => viewModel.eveningActivities,
};
return SliverPadding(
padding: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.paddingVertical,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final activity = list[index];
return Padding(
padding:
EdgeInsets.only(bottom: index < list.length - 1 ? 20 : 0),
child: ActivityEntry(
key: ValueKey(activity.ref),
activity: activity,
selected: viewModel.selectedActivities.contains(activity.ref),
onChanged: (value) {
if (value!) {
viewModel.addActivity(activity.ref);
} else {
viewModel.removeActivity(activity.ref);
}
},
),
);
},
childCount: list.length,
),
),
);
}
}

View File

@@ -0,0 +1,188 @@
// 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 '../../core/ui/error_indicator.dart';
import '../view_models/activities_viewmodel.dart';
import 'activities_header.dart';
import 'activities_list.dart';
import 'activities_title.dart';
import 'activity_time_of_day.dart';
const String confirmButtonKey = 'confirm-button';
class ActivitiesScreen extends StatefulWidget {
const ActivitiesScreen({
super.key,
required this.viewModel,
});
final ActivitiesViewModel viewModel;
@override
State<ActivitiesScreen> createState() => _ActivitiesScreenState();
}
class _ActivitiesScreenState extends State<ActivitiesScreen> {
@override
void initState() {
super.initState();
widget.viewModel.saveActivities.addListener(_onResult);
}
@override
void didUpdateWidget(covariant ActivitiesScreen oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.saveActivities.removeListener(_onResult);
widget.viewModel.saveActivities.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.saveActivities.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go(Routes.results);
},
child: Scaffold(
body: ListenableBuilder(
listenable: widget.viewModel.loadActivities,
builder: (context, child) {
if (widget.viewModel.loadActivities.completed) {
return child!;
}
return Column(
children: [
const ActivitiesHeader(),
if (widget.viewModel.loadActivities.running)
const Expanded(
child: Center(child: CircularProgressIndicator())),
if (widget.viewModel.loadActivities.error)
Expanded(
child: Center(
child: ErrorIndicator(
title: AppLocalization.of(context)
.errorWhileLoadingActivities,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.loadActivities.execute,
),
),
),
],
);
},
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) {
return Column(
children: [
Expanded(
child: CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: ActivitiesHeader(),
),
ActivitiesTitle(
viewModel: widget.viewModel,
activityTimeOfDay: ActivityTimeOfDay.daytime,
),
ActivitiesList(
viewModel: widget.viewModel,
activityTimeOfDay: ActivityTimeOfDay.daytime,
),
ActivitiesTitle(
viewModel: widget.viewModel,
activityTimeOfDay: ActivityTimeOfDay.evening,
),
ActivitiesList(
viewModel: widget.viewModel,
activityTimeOfDay: ActivityTimeOfDay.evening,
),
],
),
),
_BottomArea(viewModel: widget.viewModel),
],
);
},
),
),
),
);
}
void _onResult() {
if (widget.viewModel.saveActivities.completed) {
widget.viewModel.saveActivities.clearResult();
context.go(Routes.booking);
}
if (widget.viewModel.saveActivities.error) {
widget.viewModel.saveActivities.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileSavingActivities),
action: SnackBarAction(
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.saveActivities.execute,
),
),
);
}
}
}
class _BottomArea extends StatelessWidget {
const _BottomArea({
required this.viewModel,
});
final ActivitiesViewModel viewModel;
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: true,
child: Material(
elevation: 8,
child: Padding(
padding: EdgeInsets.only(
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenVertical,
top: Dimens.paddingVertical,
bottom: Dimens.of(context).paddingScreenVertical,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalization.of(context)
.selected(viewModel.selectedActivities.length),
style: Theme.of(context).textTheme.labelLarge,
),
FilledButton(
key: const Key(confirmButtonKey),
onPressed: viewModel.selectedActivities.isNotEmpty
? viewModel.saveActivities.execute
: null,
child: Text(AppLocalization.of(context).confirm),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
// 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 '../view_models/activities_viewmodel.dart';
import 'activity_time_of_day.dart';
class ActivitiesTitle extends StatelessWidget {
const ActivitiesTitle({
super.key,
required this.activityTimeOfDay,
required this.viewModel,
});
final ActivitiesViewModel viewModel;
final ActivityTimeOfDay activityTimeOfDay;
@override
Widget build(BuildContext context) {
final list = switch (activityTimeOfDay) {
ActivityTimeOfDay.daytime => viewModel.daytimeActivities,
ActivityTimeOfDay.evening => viewModel.eveningActivities,
};
if (list.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox());
}
return SliverToBoxAdapter(
child: Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: Text(_label(context)),
),
);
}
String _label(BuildContext context) => switch (activityTimeOfDay) {
ActivityTimeOfDay.daytime => AppLocalization.of(context).daytime,
ActivityTimeOfDay.evening => AppLocalization.of(context).evening,
};
}

View File

@@ -0,0 +1,68 @@
// 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 '../../../domain/models/activity/activity.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/ui/custom_checkbox.dart';
class ActivityEntry extends StatelessWidget {
const ActivityEntry({
super.key,
required this.activity,
required this.selected,
required this.onChanged,
});
final Activity activity;
final bool selected;
final ValueChanged<bool?> onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 80,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: activity.imageUrl,
height: 80,
width: 80,
errorListener: imageErrorListener,
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
activity.timeOfDay.name.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall,
),
Text(
activity.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 20),
CustomCheckbox(
key: ValueKey('${activity.ref}-checkbox'),
value: selected,
onChanged: onChanged,
)
],
),
);
}
}

View File

@@ -0,0 +1,5 @@
// 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.
enum ActivityTimeOfDay { daytime, evening }

View File

@@ -0,0 +1,34 @@
// 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:logging/logging.dart';
import '../../../../data/repositories/auth/auth_repository.dart';
import '../../../../utils/command.dart';
import '../../../../utils/result.dart';
class LoginViewModel {
LoginViewModel({
required AuthRepository authRepository,
}) : _authRepository = authRepository {
login = Command1<void, (String email, String password)>(_login);
}
final AuthRepository _authRepository;
final _log = Logger('LoginViewModel');
late Command1 login;
Future<Result<void>> _login((String, String) credentials) async {
final (email, password) = credentials;
final result = await _authRepository.login(
email: email,
password: password,
);
if (result is Error<void>) {
_log.warning('Login failed! ${result.error}');
}
return result;
}
}

View File

@@ -0,0 +1,113 @@
// 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 '../view_models/login_viewmodel.dart';
import 'tilted_cards.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({
super.key,
required this.viewModel,
});
final LoginViewModel viewModel;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _email =
TextEditingController(text: 'email@example.com');
final TextEditingController _password =
TextEditingController(text: 'password');
@override
void initState() {
super.initState();
widget.viewModel.login.addListener(_onResult);
}
@override
void didUpdateWidget(covariant LoginScreen oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.login.removeListener(_onResult);
widget.viewModel.login.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.login.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const TiltedCards(),
Padding(
padding: Dimens.of(context).edgeInsetsScreenSymmetric,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _email,
),
const SizedBox(height: Dimens.paddingVertical),
TextField(
controller: _password,
obscureText: true,
),
const SizedBox(height: Dimens.paddingVertical),
ListenableBuilder(
listenable: widget.viewModel.login,
builder: (context, _) {
return FilledButton(
onPressed: () {
widget.viewModel.login
.execute((_email.value.text, _password.value.text));
},
child: Text(AppLocalization.of(context).login),
);
},
),
],
),
),
],
),
);
}
void _onResult() {
if (widget.viewModel.login.completed) {
widget.viewModel.login.clearResult();
context.go(Routes.home);
}
if (widget.viewModel.login.error) {
widget.viewModel.login.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileLogin),
action: SnackBarAction(
label: AppLocalization.of(context).tryAgain,
onPressed: () => widget.viewModel.login
.execute((_email.value.text, _password.value.text)),
),
),
);
}
}
}

View File

@@ -0,0 +1,97 @@
// 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:flutter_svg/svg.dart';
import '../../../../utils/image_error_listener.dart';
class TiltedCards extends StatelessWidget {
const TiltedCards({super.key});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: const AspectRatio(
aspectRatio: 1,
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 0,
child: _Card(
imageUrl: 'https://rstr.in/google/tripedia/g2i0BsYPKW-',
width: 200,
height: 273,
tilt: -3.83 / 360,
),
),
Positioned(
right: 0,
child: _Card(
imageUrl: 'https://rstr.in/google/tripedia/980sqNgaDRK',
width: 180,
height: 230,
tilt: 3.46 / 360,
),
),
_Card(
imageUrl: 'https://rstr.in/google/tripedia/pHfPmf3o5NU',
width: 225,
height: 322,
tilt: 0,
showTitle: true,
),
],
),
),
);
}
}
class _Card extends StatelessWidget {
const _Card({
required this.imageUrl,
required this.width,
required this.height,
required this.tilt,
this.showTitle = false,
});
final double tilt;
final double width;
final double height;
final String imageUrl;
final bool showTitle;
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: AlwaysStoppedAnimation(tilt),
child: SizedBox(
width: width,
height: height,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
color: showTitle ? Colors.black.withOpacity(0.5) : null,
colorBlendMode: showTitle ? BlendMode.darken : null,
errorListener: imageErrorListener,
),
if (showTitle) Center(child: SvgPicture.asset('assets/logo.svg')),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,34 @@
// 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 '../../../../data/repositories/auth/auth_repository.dart';
import '../../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../../utils/command.dart';
import '../../../../utils/result.dart';
class LogoutViewModel {
LogoutViewModel({
required AuthRepository authRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _authLogoutRepository = authRepository,
_itineraryConfigRepository = itineraryConfigRepository {
logout = Command0(_logout);
}
final AuthRepository _authLogoutRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
late Command0 logout;
Future<Result> _logout() async {
var result = await _authLogoutRepository.logout();
switch (result) {
case Ok<void>():
// clear stored itinerary config
return _itineraryConfigRepository
.setItineraryConfig(const ItineraryConfig());
case Error<void>():
return result;
}
}
}

View File

@@ -0,0 +1,88 @@
// 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/colors.dart';
import '../view_models/logout_viewmodel.dart';
class LogoutButton extends StatefulWidget {
const LogoutButton({
super.key,
required this.viewModel,
});
final LogoutViewModel viewModel;
@override
State<LogoutButton> createState() => _LogoutButtonState();
}
class _LogoutButtonState extends State<LogoutButton> {
@override
void initState() {
super.initState();
widget.viewModel.logout.addListener(_onResult);
}
@override
void didUpdateWidget(covariant LogoutButton oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.logout.removeListener(_onResult);
widget.viewModel.logout.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.logout.removeListener(_onResult);
super.dispose();
}
@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),
color: Colors.transparent,
),
child: InkResponse(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
widget.viewModel.logout.execute();
},
child: Center(
child: Icon(
size: 24.0,
Icons.logout,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
);
}
void _onResult() {
// We do not need to navigate to `/login` on logout,
// it is done automatically by GoRouter.
if (widget.viewModel.logout.error) {
widget.viewModel.logout.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileLogout),
action: SnackBarAction(
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.logout.execute,
),
),
);
}
}
}

View File

@@ -0,0 +1,89 @@
// 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/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/booking/booking.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
import '../../../domain/use_cases/booking/booking_create_use_case.dart';
import '../../../domain/use_cases/booking/booking_share_use_case.dart';
class BookingViewModel extends ChangeNotifier {
BookingViewModel({
required BookingCreateUseCase createBookingUseCase,
required BookingShareUseCase shareBookingUseCase,
required ItineraryConfigRepository itineraryConfigRepository,
required BookingRepository bookingRepository,
}) : _createUseCase = createBookingUseCase,
_shareUseCase = shareBookingUseCase,
_itineraryConfigRepository = itineraryConfigRepository,
_bookingRepository = bookingRepository {
createBooking = Command0(_createBooking);
shareBooking = Command0(() => _shareUseCase.shareBooking(_booking!));
loadBooking = Command1(_load);
}
final BookingCreateUseCase _createUseCase;
final BookingShareUseCase _shareUseCase;
final ItineraryConfigRepository _itineraryConfigRepository;
final BookingRepository _bookingRepository;
final _log = Logger('BookingViewModel');
Booking? _booking;
Booking? get booking => _booking;
/// Creates a booking from the ItineraryConfig
/// and saves it to the user bookins
late final Command0 createBooking;
/// Loads booking by id
late final Command1<void, int> loadBooking;
/// Share the current booking using the OS share dialog.
late final Command0 shareBooking;
Future<Result<void>> _createBooking() async {
_log.fine('Loading booking');
final itineraryConfig =
await _itineraryConfigRepository.getItineraryConfig();
switch (itineraryConfig) {
case Ok<ItineraryConfig>():
_log.fine('Loaded stored ItineraryConfig');
final result = await _createUseCase.createFrom(itineraryConfig.value);
switch (result) {
case Ok<Booking>():
_log.fine('Created Booking');
_booking = result.value;
notifyListeners();
return Result.ok(null);
case Error<Booking>():
_log.warning('Booking error: ${result.error}');
notifyListeners();
return Result.error(result.asError.error);
}
case Error<ItineraryConfig>():
_log.warning('ItineraryConfig error: ${itineraryConfig.error}');
notifyListeners();
return Result.error(itineraryConfig.error);
}
}
Future<Result<void>> _load(int id) async {
final result = await _bookingRepository.getBooking(id);
switch (result) {
case Ok<Booking>():
_log.fine('Loaded booking $id');
_booking = result.value;
notifyListeners();
case Error<Booking>():
_log.warning('Failed to load booking $id');
}
return result;
}
}

View File

@@ -0,0 +1,104 @@
// 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 '../../../domain/models/activity/activity.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/themes/dimens.dart';
import '../view_models/booking_viewmodel.dart';
import 'booking_header.dart';
class BookingBody extends StatelessWidget {
const BookingBody({
super.key,
required this.viewModel,
});
final BookingViewModel viewModel;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
final booking = viewModel.booking;
if (booking == null) return const SizedBox();
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: BookingHeader(booking: booking)),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final activity = booking.activity[index];
return _Activity(activity: activity);
},
childCount: booking.activity.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 200)),
],
);
},
);
}
}
class _Activity extends StatelessWidget {
const _Activity({
required this.activity,
});
final Activity activity;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: activity.imageUrl,
height: 80,
width: 80,
errorListener: imageErrorListener,
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
activity.timeOfDay.name.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall,
),
Text(
activity.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
activity.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,191 @@
// 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 '../../../domain/models/booking/booking.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/date_format_start_end.dart';
import '../../core/ui/home_button.dart';
import '../../core/ui/tag_chip.dart';
class BookingHeader extends StatelessWidget {
const BookingHeader({
super.key,
required this.booking,
});
final Booking booking;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Top(booking: booking),
Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: Text(
booking.destination.knownFor,
style: Theme.of(context).textTheme.bodyLarge,
),
),
const SizedBox(height: Dimens.paddingVertical),
_Tags(booking: booking),
const SizedBox(height: Dimens.paddingVertical),
Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: Text(
AppLocalization.of(context).yourChosenActivities,
style: Theme.of(context).textTheme.headlineSmall,
),
),
],
);
}
}
class _Top extends StatelessWidget {
const _Top({
required this.booking,
});
final Booking booking;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 260,
child: Stack(
fit: StackFit.expand,
children: [
_HeaderImage(booking: booking),
const _Gradient(),
_Headline(booking: booking),
Positioned(
right: Dimens.of(context).paddingScreenHorizontal,
top: Dimens.of(context).paddingScreenVertical,
child: const SafeArea(
top: true,
child: HomeButton(blur: true),
),
),
],
),
);
}
}
class _Tags extends StatelessWidget {
const _Tags({
required this.booking,
});
final Booking booking;
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final chipColor = switch (brightness) {
Brightness.dark => AppColors.whiteTransparent,
Brightness.light => AppColors.blackTransparent,
};
return Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: Wrap(
spacing: 6,
runSpacing: 6,
children: booking.destination.tags
.map(
(tag) => TagChip(
tag: tag,
fontSize: 16,
height: 32,
chipColor: chipColor,
onChipColor: Theme.of(context).colorScheme.onSurface,
),
)
.toList(),
),
);
}
}
class _Headline extends StatelessWidget {
const _Headline({
required this.booking,
});
final Booking booking;
@override
Widget build(BuildContext context) {
return Align(
alignment: AlignmentDirectional.bottomStart,
child: Padding(
padding: Dimens.of(context).edgeInsetsScreenSymmetric,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
booking.destination.name,
style: Theme.of(context).textTheme.headlineLarge,
),
Text(
dateFormatStartEnd(
DateTimeRange(
start: booking.startDate,
end: booking.endDate,
),
),
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
);
}
}
class _HeaderImage extends StatelessWidget {
const _HeaderImage({
required this.booking,
});
final Booking booking;
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
fit: BoxFit.fitWidth,
imageUrl: booking.destination.imageUrl,
errorListener: imageErrorListener,
);
}
}
class _Gradient extends StatelessWidget {
const _Gradient();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Theme.of(context).colorScheme.surface,
],
),
),
);
}
}

View File

@@ -0,0 +1,115 @@
// 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/ui/error_indicator.dart';
import '../view_models/booking_viewmodel.dart';
import 'booking_body.dart';
class BookingScreen extends StatefulWidget {
const BookingScreen({
super.key,
required this.viewModel,
});
final BookingViewModel viewModel;
@override
State<BookingScreen> createState() => _BookingScreenState();
}
class _BookingScreenState extends State<BookingScreen> {
@override
void initState() {
super.initState();
widget.viewModel.shareBooking.addListener(_listener);
}
@override
void dispose() {
widget.viewModel.shareBooking.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
// Back navigation always goes to home
if (!didPop) context.go(Routes.home);
},
child: Scaffold(
floatingActionButton: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) => FloatingActionButton.extended(
// Workaround for https://github.com/flutter/flutter/issues/115358#issuecomment-2117157419
heroTag: null,
key: const ValueKey('share-button'),
onPressed: widget.viewModel.booking != null
? widget.viewModel.shareBooking.execute
: null,
label: Text(AppLocalization.of(context).shareTrip),
icon: const Icon(Icons.share_outlined),
),
),
body: ListenableBuilder(
// Listen to changes in both commands
listenable: Listenable.merge([
widget.viewModel.createBooking,
widget.viewModel.loadBooking,
]),
builder: (context, child) {
// If either command is running, show progress indicator
if (widget.viewModel.createBooking.running ||
widget.viewModel.loadBooking.running) {
return const Center(
child: CircularProgressIndicator(),
);
}
// If fails to create booking, tap to try again
if (widget.viewModel.createBooking.error) {
return Center(
child: ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingBooking,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.createBooking.execute,
),
);
}
// If existing booking fails to load, tap to go /home
if (widget.viewModel.loadBooking.error) {
return Center(
child: ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingBooking,
label: AppLocalization.of(context).close,
onPressed: () => context.go(Routes.home),
),
);
}
return child!;
},
child: BookingBody(viewModel: widget.viewModel),
),
),
);
}
void _listener() {
if (widget.viewModel.shareBooking.error) {
widget.viewModel.shareBooking.clearResult();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalization.of(context).errorWhileSharing),
action: SnackBarAction(
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.shareBooking.execute,
),
));
}
}
}

View File

@@ -0,0 +1,121 @@
// 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/foundation.dart';
import 'package:flutter/material.dart';
/// Simple Localizations similar to
/// https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization#an-alternative-class-for-the-apps-localized-resources
class AppLocalization {
static AppLocalization of(BuildContext context) {
return Localizations.of(context, AppLocalization);
}
static const _strings = <String, String>{
'activities': 'Activities',
'addDates': 'Add Dates',
'bookingDeleted': 'Booking deleted',
'bookNewTrip': 'Book New Trip',
'close': 'Close',
'confirm': 'Confirm',
'daytime': 'Daytime',
'errorWhileDeletingBooking': 'Error while deleting booking',
'errorWhileLoadingActivities': 'Error while loading activities',
'errorWhileLoadingBooking': 'Error while loading booking',
'errorWhileLoadingContinents': 'Error while loading continents',
'errorWhileLoadingDestinations': 'Error while loading destinations',
'errorWhileLoadingHome': 'Error while loading home',
'errorWhileLogin': 'Error while trying to login',
'errorWhileLogout': 'Error while trying to logout',
'errorWhileSavingActivities': 'Error while saving activities',
'errorWhileSavingItinerary': 'Error while saving itinerary',
'errorWhileSharing': 'Error while sharing booking',
'evening': 'Evening',
'login': 'Login',
'nameTrips': '{name}\'s Trips',
'search': 'Search',
'searchDestination': 'Search destination',
'selected': '{1} selected',
'shareTrip': 'Share Trip',
'tryAgain': 'Try again',
'yourChosenActivities': 'Your chosen activities',
'when': 'When',
};
// If string for "label" does not exist, will show "[LABEL]"
static String _get(String label) =>
_strings[label] ?? '[${label.toUpperCase()}]';
String get activities => _get('activities');
String get addDates => _get('addDates');
String get confirm => _get('confirm');
String get daytime => _get('daytime');
String get errorWhileLoadingActivities => _get('errorWhileLoadingActivities');
String get errorWhileLoadingBooking => _get('errorWhileLoadingBooking');
String get errorWhileLoadingContinents => _get('errorWhileLoadingContinents');
String get errorWhileLoadingDestinations =>
_get('errorWhileLoadingDestinations');
String get errorWhileSavingActivities => _get('errorWhileSavingActivities');
String get errorWhileSavingItinerary => _get('errorWhileSavingItinerary');
String get evening => _get('evening');
String get search => _get('search');
String get searchDestination => _get('searchDestination');
String get shareTrip => _get('shareTrip');
String get tryAgain => _get('tryAgain');
String get yourChosenActivities => _get('yourChosenActivities');
String get when => _get('when');
String get errorWhileLogin => _get('errorWhileLogin');
String get login => _get('login');
String get errorWhileLogout => _get('errorWhileLogout');
String get close => _get('close');
String get errorWhileSharing => _get('errorWhileSharing');
String get bookNewTrip => _get('bookNewTrip');
String get errorWhileLoadingHome => _get('errorWhileLoadingHome');
String get bookingDeleted => _get('bookingDeleted');
String get errorWhileDeletingBooking => _get('errorWhileDeletingBooking');
String nameTrips(String name) => _get('nameTrips').replaceAll('{name}', name);
String selected(int value) =>
_get('selected').replaceAll('{1}', value.toString());
}
class AppLocalizationDelegate extends LocalizationsDelegate<AppLocalization> {
@override
bool isSupported(Locale locale) => locale.languageCode == 'en';
@override
Future<AppLocalization> load(Locale locale) {
return SynchronousFuture(AppLocalization());
}
@override
bool shouldReload(covariant LocalizationsDelegate<AppLocalization> old) =>
false;
}

View File

@@ -0,0 +1,41 @@
// 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';
class AppColors {
static const black1 = Color(0xFF101010);
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);
static const red1 = Color(0xFFE74C3C);
static const lightColorScheme = ColorScheme(
brightness: Brightness.light,
primary: AppColors.black1,
onPrimary: AppColors.white1,
secondary: AppColors.black1,
onSecondary: AppColors.white1,
surface: Colors.white,
onSurface: AppColors.black1,
error: Colors.white,
onError: Colors.red,
);
static const darkColorScheme = ColorScheme(
brightness: Brightness.dark,
primary: AppColors.white1,
onPrimary: AppColors.black1,
secondary: AppColors.white1,
onSecondary: AppColors.black1,
surface: AppColors.black1,
onSurface: Colors.white,
error: Colors.black,
onError: AppColors.red1,
);
}

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';
sealed class Dimens {
const Dimens();
/// General horizontal padding used to separate UI items
static const paddingHorizontal = 20.0;
/// General vertical padding used to separate UI items
static const paddingVertical = 24.0;
/// Horizontal padding for screen edges
abstract final double paddingScreenHorizontal;
/// Vertical padding for screen edges
abstract final double paddingScreenVertical;
/// Horizontal symmetric padding for screen edges
EdgeInsets get edgeInsetsScreenHorizontal =>
EdgeInsets.symmetric(horizontal: paddingScreenHorizontal);
/// Symmetric padding for screen edges
EdgeInsets get edgeInsetsScreenSymmetric => EdgeInsets.symmetric(
horizontal: paddingScreenHorizontal, vertical: paddingScreenVertical);
static final dimensDesktop = DimensDesktop();
static final dimensMobile = DimensMobile();
/// Get dimensions definition based on screen size
factory Dimens.of(BuildContext context) =>
switch (MediaQuery.sizeOf(context).width) {
> 600 => dimensDesktop,
_ => dimensMobile,
};
abstract final double profilePictureSize;
}
/// Mobile dimensions
class DimensMobile extends Dimens {
@override
double paddingScreenHorizontal = Dimens.paddingHorizontal;
@override
double paddingScreenVertical = Dimens.paddingVertical;
@override
double get profilePictureSize => 64.0;
}
/// Desktop/Web dimensions
class DimensDesktop extends Dimens {
@override
double paddingScreenHorizontal = 100.0;
@override
double paddingScreenVertical = 64.0;
@override
double get profilePictureSize => 128.0;
}

View File

@@ -0,0 +1,84 @@
// 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 'colors.dart';
import '../ui/tag_chip.dart';
import 'package:flutter/material.dart';
class AppTheme {
static const _textTheme = TextTheme(
headlineLarge: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w500,
),
headlineSmall: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
),
titleMedium: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
bodyLarge: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
),
bodyMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
),
bodySmall: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.grey3,
),
labelSmall: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.grey3,
),
labelLarge: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
color: AppColors.grey3,
),
);
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,
onChipColor: Colors.white,
),
],
);
static ThemeData darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: AppColors.darkColorScheme,
textTheme: _textTheme,
inputDecorationTheme: _inputDecorationTheme,
extensions: [
TagChipTheme(
chipColor: AppColors.blackTransparent,
onChipColor: Colors.white,
),
],
);
}

View File

@@ -0,0 +1,63 @@
// 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 '../themes/colors.dart';
import 'blur_filter.dart';
/// Custom back button to pop navigation.
class CustomBackButton extends StatelessWidget {
const CustomBackButton({
super.key,
this.onTap,
this.blur = false,
});
final bool blur;
final GestureTapCallback? onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40.0,
width: 40.0,
child: Stack(
children: [
if (blur)
ClipRect(
child: BackdropFilter(
filter: kBlurFilter,
child: const SizedBox(height: 40.0, width: 40.0),
),
),
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: AppColors.grey1),
borderRadius: BorderRadius.circular(8.0),
),
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
if (onTap != null) {
onTap!();
} else {
context.pop();
}
},
child: Center(
child: Icon(
size: 24.0,
Icons.arrow_back,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,7 @@
// 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 'dart:ui';
final kBlurFilter = ImageFilter.blur(sigmaX: 2, sigmaY: 2);

View File

@@ -0,0 +1,50 @@
// 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 '../themes/colors.dart';
class CustomCheckbox extends StatelessWidget {
const CustomCheckbox({
super.key,
required this.value,
required this.onChanged,
});
final bool value;
final ValueChanged<bool?> onChanged;
@override
Widget build(BuildContext context) {
return InkResponse(
radius: 24,
onTap: () => onChanged(!value),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.grey3),
),
child: Material(
borderRadius: BorderRadius.circular(24),
color: value
? Theme.of(context).colorScheme.primary
: Colors.transparent,
child: SizedBox(
width: 24,
height: 24,
child: Visibility(
visible: value,
child: Icon(
Icons.check,
size: 14,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,24 @@
// 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:intl/intl.dart';
final _dateFormatDay = DateFormat('d');
final _dateFormatDayMonth = DateFormat('d MMM');
String dateFormatStartEnd(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';
}

View File

@@ -0,0 +1,63 @@
// 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 '../themes/colors.dart';
class ErrorIndicator extends StatelessWidget {
const ErrorIndicator({
super.key,
required this.title,
required this.label,
required this.onPressed,
});
final String title;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IntrinsicWidth(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.onError,
),
const SizedBox(width: 10),
Text(
title,
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
),
),
],
),
),
),
),
const SizedBox(
height: 10,
),
FilledButton(
onPressed: onPressed,
style: const ButtonStyle(
backgroundColor: WidgetStatePropertyAll(AppColors.red1),
foregroundColor: WidgetStatePropertyAll(Colors.white),
),
child: Text(label),
),
],
);
}
}

View File

@@ -0,0 +1,60 @@
// 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 '../themes/colors.dart';
import 'blur_filter.dart';
/// Home button to navigate back to the '/' path.
class HomeButton extends StatelessWidget {
const HomeButton({
super.key,
this.blur = false,
});
final bool blur;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40.0,
width: 40.0,
child: Stack(
fit: StackFit.expand,
children: [
if (blur)
ClipRect(
child: BackdropFilter(
filter: kBlurFilter,
child: const SizedBox(height: 40.0, width: 40.0),
),
),
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: AppColors.grey1),
borderRadius: BorderRadius.circular(8.0),
color: Colors.transparent,
),
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
context.go(Routes.home);
},
child: Center(
child: Icon(
size: 24.0,
Icons.home_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,17 @@
// 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/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,
};
}

View File

@@ -0,0 +1,111 @@
// 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 '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../localization/applocalization.dart';
import '../themes/colors.dart';
import '../themes/dimens.dart';
import 'date_format_start_end.dart';
import 'home_button.dart';
/// Application top search bar.
///
/// Displays a search bar with the current configuration.
/// Includes [HomeButton] to navigate back to the '/' path.
class AppSearchBar extends StatelessWidget {
const AppSearchBar({
super.key,
this.config,
this.onTap,
});
final ItineraryConfig? config;
final GestureTapCallback? onTap;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(16.0),
onTap: onTap,
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: Align(
alignment: AlignmentDirectional.centerStart,
child: _QueryText(config: config),
),
),
),
),
),
const SizedBox(width: 10),
const HomeButton(),
],
);
}
}
class _QueryText extends StatelessWidget {
const _QueryText({
required this.config,
});
final ItineraryConfig? config;
@override
Widget build(BuildContext context) {
if (config == null) {
return const _EmptySearch();
}
final ItineraryConfig(:continent, :startDate, :endDate, :guests) = config!;
if (startDate == null ||
endDate == null ||
guests == null ||
continent == null) {
return const _EmptySearch();
}
return Text(
'$continent - ${dateFormatStartEnd(DateTimeRange(start: startDate, end: endDate))} - Guests: $guests',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
);
}
}
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(
AppLocalization.of(context).searchDestination,
textAlign: TextAlign.start,
style: Theme.of(context).inputDecorationTheme.hintStyle,
),
),
],
);
}
}

View File

@@ -0,0 +1,144 @@
// 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 'dart:ui';
import '../themes/colors.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class TagChip extends StatelessWidget {
const TagChip({
super.key,
required this.tag,
this.fontSize = 10,
this.height = 20,
this.chipColor,
this.onChipColor,
});
final String tag;
final double fontSize;
final double height;
final Color? chipColor;
final Color? onChipColor;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(height / 2),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: DecoratedBox(
decoration: BoxDecoration(
color: chipColor ??
Theme.of(context).extension<TagChipTheme>()?.chipColor ??
AppColors.whiteTransparent,
),
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_iconFrom(tag),
color: onChipColor ??
Theme.of(context)
.extension<TagChipTheme>()
?.onChipColor ??
Colors.white,
size: fontSize,
),
const SizedBox(width: 4),
Text(
tag,
textAlign: TextAlign.center,
style: _textStyle(context),
),
],
),
),
),
),
),
);
}
IconData? _iconFrom(String tag) {
return switch (tag) {
'Adventure sports' => Icons.kayaking_outlined,
'Beach' => Icons.beach_access_outlined,
'City' => Icons.location_city_outlined,
'Cultural experiences' => Icons.museum_outlined,
'Foodie' || 'Food tours' => Icons.restaurant,
'Hiking' => Icons.hiking,
'Historic' => Icons.menu_book_outlined,
'Island' || 'Coastal' || 'Lake' || 'River' => Icons.water,
'Luxury' => Icons.attach_money_outlined,
'Mountain' || 'Wildlife watching' => Icons.landscape_outlined,
'Nightlife' => Icons.local_bar_outlined,
'Off-the-beaten-path' => Icons.do_not_step_outlined,
'Romantic' => Icons.favorite_border_outlined,
'Rural' => Icons.agriculture_outlined,
'Secluded' => Icons.church_outlined,
'Sightseeing' => Icons.attractions_outlined,
'Skiing' => Icons.downhill_skiing_outlined,
'Wine tasting' => Icons.wine_bar_outlined,
'Winter destination' => Icons.ac_unit,
_ => Icons.label_outlined,
};
}
// Note: original Figma file uses Google Sans
// which is not available on GoogleFonts
_textStyle(BuildContext context) => GoogleFonts.openSans(
textStyle: TextStyle(
fontWeight: FontWeight.w500,
fontSize: fontSize,
color: onChipColor ??
Theme.of(context).extension<TagChipTheme>()?.onChipColor ??
Colors.white,
textBaseline: TextBaseline.alphabetic,
),
);
}
class TagChipTheme extends ThemeExtension<TagChipTheme> {
final Color chipColor;
final Color onChipColor;
TagChipTheme({
required this.chipColor,
required this.onChipColor,
});
@override
ThemeExtension<TagChipTheme> copyWith({
Color? chipColor,
Color? onChipColor,
}) {
return TagChipTheme(
chipColor: chipColor ?? this.chipColor,
onChipColor: onChipColor ?? this.onChipColor,
);
}
@override
ThemeExtension<TagChipTheme> lerp(
covariant ThemeExtension<TagChipTheme> other,
double t,
) {
if (other is! TagChipTheme) {
return this;
}
return TagChipTheme(
chipColor: Color.lerp(chipColor, other.chipColor, t) ?? chipColor,
onChipColor: Color.lerp(onChipColor, other.onChipColor, t) ?? onChipColor,
);
}
}

View File

@@ -0,0 +1,95 @@
// 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 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/user/user_repository.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../domain/models/user/user.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository {
load = Command0(_load)..execute();
deleteBooking = Command1(_deleteBooking);
}
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
final _log = Logger('HomeViewModel');
List<BookingSummary> _bookings = [];
User? _user;
late Command0 load;
late Command1<void, int> deleteBooking;
List<BookingSummary> get bookings => _bookings;
User? get user => _user;
Future<Result> _load() async {
try {
final result = await _bookingRepository.getBookingsList();
switch (result) {
case Ok<List<BookingSummary>>():
_bookings = result.value;
_log.fine('Loaded bookings');
case Error<List<BookingSummary>>():
_log.warning('Failed to load bookings', result.error);
return result;
}
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
_log.fine('Loaded user');
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
return userResult;
} finally {
notifyListeners();
}
}
Future<Result<void>> _deleteBooking(int id) async {
try {
final resultDelete = await _bookingRepository.delete(id);
switch (resultDelete) {
case Ok<void>():
_log.fine('Deleted booking $id');
case Error<void>():
_log.warning('Failed to delete booking $id', resultDelete.error);
return resultDelete;
}
// After deleting the booking, we need to reload the bookings list.
// BookingRepository is the source of truth for bookings.
final resultLoadBookings = await _bookingRepository.getBookingsList();
switch (resultLoadBookings) {
case Ok<List<BookingSummary>>():
_bookings = resultLoadBookings.value;
_log.fine('Loaded bookings');
case Error<List<BookingSummary>>():
_log.warning('Failed to load bookings', resultLoadBookings.error);
return resultLoadBookings;
}
return resultLoadBookings;
} finally {
notifyListeners();
}
}
}

View File

@@ -0,0 +1,198 @@
// 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 '../../../domain/models/booking/booking_summary.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/date_format_start_end.dart';
import '../../core/ui/error_indicator.dart';
import '../view_models/home_viewmodel.dart';
import 'home_title.dart';
const String bookingButtonKey = 'booking-button';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.viewModel,
});
final HomeViewModel viewModel;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
widget.viewModel.deleteBooking.addListener(_onResult);
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.deleteBooking.removeListener(_onResult);
widget.viewModel.deleteBooking.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.deleteBooking.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
// Workaround for https://github.com/flutter/flutter/issues/115358#issuecomment-2117157419
heroTag: null,
key: const ValueKey(bookingButtonKey),
onPressed: () => context.go(Routes.search),
label: Text(AppLocalization.of(context).bookNewTrip),
icon: const Icon(Icons.add_location_outlined),
),
body: SafeArea(
top: true,
bottom: true,
child: ListenableBuilder(
listenable: widget.viewModel.load,
builder: (context, child) {
if (widget.viewModel.load.running) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (widget.viewModel.load.error) {
return ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingHome,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.load.execute,
);
}
return child!;
},
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: Dimens.of(context).paddingScreenVertical,
horizontal: Dimens.of(context).paddingScreenHorizontal,
),
child: HomeHeader(viewModel: widget.viewModel),
),
),
SliverList.builder(
itemCount: widget.viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(widget.viewModel.bookings[index].id),
booking: widget.viewModel.bookings[index],
onTap: () => context.push(Routes.bookingWithId(
widget.viewModel.bookings[index].id)),
confirmDismiss: (_) async {
// wait for command to complete
await widget.viewModel.deleteBooking.execute(
widget.viewModel.bookings[index].id,
);
// if command completed successfully, return true
if (widget.viewModel.deleteBooking.completed) {
// removes the dismissable from the list
return true;
} else {
// the dismissable stays in the list
return false;
}
},
),
)
],
);
},
),
),
),
);
}
void _onResult() {
if (widget.viewModel.deleteBooking.completed) {
widget.viewModel.deleteBooking.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).bookingDeleted),
),
);
}
if (widget.viewModel.deleteBooking.error) {
widget.viewModel.deleteBooking.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileDeletingBooking),
),
);
}
}
}
class _Booking extends StatelessWidget {
const _Booking({
super.key,
required this.booking,
required this.onTap,
required this.confirmDismiss,
});
final BookingSummary booking;
final GestureTapCallback onTap;
final ConfirmDismissCallback confirmDismiss;
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey(booking.id),
direction: DismissDirection.endToStart,
confirmDismiss: confirmDismiss,
child: InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: Dimens.of(context).paddingScreenHorizontal,
vertical: Dimens.paddingVertical,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
booking.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
dateFormatStartEnd(
DateTimeRange(
start: booking.startDate,
end: booking.endDate,
),
),
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,89 @@
// 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:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import '../../auth/logout/view_models/logout_viewmodel.dart';
import '../../auth/logout/widgets/logout_button.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../view_models/home_viewmodel.dart';
class HomeHeader extends StatelessWidget {
const HomeHeader({
super.key,
required this.viewModel,
});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
final user = viewModel.user;
if (user == null) {
return const SizedBox();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipOval(
child: Image.asset(
user.picture,
width: Dimens.of(context).profilePictureSize,
height: Dimens.of(context).profilePictureSize,
),
),
LogoutButton(
viewModel: LogoutViewModel(
authRepository: context.read(),
itineraryConfigRepository: context.read(),
),
),
],
),
const SizedBox(height: Dimens.paddingVertical),
_Title(
text: AppLocalization.of(context).nameTrips(user.name),
),
],
);
}
}
class _Title extends StatelessWidget {
const _Title({
required this.text,
});
final String text;
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => RadialGradient(
center: Alignment.bottomLeft,
radius: 2,
colors: [
Colors.purple.shade700,
Colors.purple.shade400,
],
).createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height),
),
child: Text(
text,
style: GoogleFonts.rubik(
textStyle: Theme.of(context).textTheme.headlineLarge,
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
// 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:logging/logging.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
import 'package:flutter/cupertino.dart';
/// Results screen view model
/// Based on https://docs.flutter.dev/get-started/fwe/state-management#using-mvvm-for-your-applications-architecture
class ResultsViewModel extends ChangeNotifier {
ResultsViewModel({
required DestinationRepository destinationRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _destinationRepository = destinationRepository,
_itineraryConfigRepository = itineraryConfigRepository {
updateItineraryConfig = Command1<void, String>(_updateItineraryConfig);
search = Command0(_search)..execute();
}
final _log = Logger('ResultsViewModel');
final DestinationRepository _destinationRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
// Setters are private
List<Destination> _destinations = [];
/// List of destinations, may be empty but never null
List<Destination> get destinations => _destinations;
ItineraryConfig? _itineraryConfig;
/// Filter options to display on search bar
ItineraryConfig get config => _itineraryConfig ?? const ItineraryConfig();
/// Perform search
late final Command0 search;
/// Store ViewModel data into [ItineraryConfigRepository] before navigating.
late final Command1<void, String> updateItineraryConfig;
Future<Result<void>> _search() async {
// Load current itinerary config
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
_log.warning(
'Failed to load stored ItineraryConfig',
resultConfig.asError.error,
);
return resultConfig;
}
_itineraryConfig = resultConfig.asOk.value;
notifyListeners();
final result = await _destinationRepository.getDestinations();
switch (result) {
case Ok():
{
// If the result is Ok, update the list of destinations
_destinations = result.value
.where((destination) =>
destination.continent == _itineraryConfig!.continent)
.toList();
_log.fine('Destinations (${_destinations.length}) loaded');
}
case Error():
{
_log.warning('Failed to load destinations', result.error);
}
}
// After finish loading results, notify the view
notifyListeners();
return result;
}
Future<Result<void>> _updateItineraryConfig(String destinationRef) async {
assert(destinationRef.isNotEmpty, "destinationRef should not be empty");
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
_log.warning(
'Failed to load stored ItineraryConfig',
resultConfig.asError.error,
);
return resultConfig;
}
final itineraryConfig = resultConfig.asOk.value;
final result = await _itineraryConfigRepository
.setItineraryConfig(itineraryConfig.copyWith(
destination: destinationRef,
activities: [],
));
if (result is Error) {
_log.warning(
'Failed to store ItineraryConfig',
result.asError.error,
);
}
return result;
}
}

View File

@@ -0,0 +1,89 @@
// 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/destination/destination.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/ui/tag_chip.dart';
class ResultCard extends StatelessWidget {
const ResultCard({
super.key,
required this.destination,
required this.onTap,
});
final Destination destination;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: destination.imageUrl,
fit: BoxFit.fitHeight,
errorWidget: (context, url, error) => const Icon(Icons.error),
errorListener: imageErrorListener,
),
Positioned(
bottom: 12.0,
left: 12.0,
right: 12.0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
destination.name.toUpperCase(),
style: _cardTitleStyle,
),
const SizedBox(
height: 6,
),
Wrap(
spacing: 4.0,
runSpacing: 4.0,
direction: Axis.horizontal,
children:
destination.tags.map((e) => TagChip(tag: e)).toList(),
),
],
),
),
// Handle taps
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
),
),
),
],
),
);
}
}
final _cardTitleStyle = GoogleFonts.rubik(
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15.0,
color: Colors.white,
letterSpacing: 1,
shadows: [
// Helps to read the text a bit better
Shadow(
blurRadius: 3.0,
color: Colors.black,
)
],
),
);

View File

@@ -0,0 +1,180 @@
// 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 '../../core/ui/error_indicator.dart';
import '../../core/ui/search_bar.dart';
import '../view_models/results_viewmodel.dart';
import 'result_card.dart';
class ResultsScreen extends StatefulWidget {
const ResultsScreen({
super.key,
required this.viewModel,
});
final ResultsViewModel viewModel;
@override
State<ResultsScreen> createState() => _ResultsScreenState();
}
class _ResultsScreenState extends State<ResultsScreen> {
@override
void initState() {
super.initState();
widget.viewModel.updateItineraryConfig.addListener(_onResult);
}
@override
void didUpdateWidget(covariant ResultsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult);
widget.viewModel.updateItineraryConfig.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.updateItineraryConfig.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go(Routes.search);
},
child: Scaffold(
body: ListenableBuilder(
listenable: widget.viewModel.search,
builder: (context, child) {
if (widget.viewModel.search.completed) {
return child!;
}
return Column(
children: [
_AppSearchBar(widget: widget),
if (widget.viewModel.search.running)
const Expanded(
child: Center(child: CircularProgressIndicator())),
if (widget.viewModel.search.error)
Expanded(
child: Center(
child: ErrorIndicator(
title: AppLocalization.of(context)
.errorWhileLoadingDestinations,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.search.execute,
),
),
),
],
);
},
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) {
return Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _AppSearchBar(widget: widget),
),
_Grid(viewModel: widget.viewModel),
],
),
);
},
),
),
),
);
}
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go(Routes.activities);
}
if (widget.viewModel.updateItineraryConfig.error) {
widget.viewModel.updateItineraryConfig.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileSavingItinerary),
),
);
}
}
}
class _AppSearchBar extends StatelessWidget {
const _AppSearchBar({
required this.widget,
});
final ResultsScreen widget;
@override
Widget build(BuildContext context) {
return SafeArea(
top: true,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: Dimens.of(context).paddingScreenVertical,
bottom: Dimens.dimensMobile.paddingScreenVertical,
),
child: AppSearchBar(
config: widget.viewModel.config,
onTap: () {
// Navigate to SearchFormScreen and edit search
context.pop();
},
),
),
);
}
}
class _Grid extends StatelessWidget {
const _Grid({
required this.viewModel,
});
final ResultsViewModel viewModel;
@override
Widget build(BuildContext context) {
return SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
childAspectRatio: 182 / 222,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final destination = viewModel.destinations[index];
return ResultCard(
key: ValueKey(destination.ref),
destination: destination,
onTap: () {
viewModel.updateItineraryConfig.execute(destination.ref);
},
);
},
childCount: viewModel.destinations.length,
),
);
}
}

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