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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
compass_app/app/lib/ui/activities/widgets/activities_screen.dart
Normal file
188
compass_app/app/lib/ui/activities/widgets/activities_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
113
compass_app/app/lib/ui/auth/login/widgets/login_screen.dart
Normal file
113
compass_app/app/lib/ui/auth/login/widgets/login_screen.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
97
compass_app/app/lib/ui/auth/login/widgets/tilted_cards.dart
Normal file
97
compass_app/app/lib/ui/auth/login/widgets/tilted_cards.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
104
compass_app/app/lib/ui/booking/widgets/booking_body.dart
Normal file
104
compass_app/app/lib/ui/booking/widgets/booking_body.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
191
compass_app/app/lib/ui/booking/widgets/booking_header.dart
Normal file
191
compass_app/app/lib/ui/booking/widgets/booking_header.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
compass_app/app/lib/ui/booking/widgets/booking_screen.dart
Normal file
115
compass_app/app/lib/ui/booking/widgets/booking_screen.dart
Normal 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,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
121
compass_app/app/lib/ui/core/localization/applocalization.dart
Normal file
121
compass_app/app/lib/ui/core/localization/applocalization.dart
Normal 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;
|
||||
}
|
||||
41
compass_app/app/lib/ui/core/themes/colors.dart
Normal file
41
compass_app/app/lib/ui/core/themes/colors.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
65
compass_app/app/lib/ui/core/themes/dimens.dart
Normal file
65
compass_app/app/lib/ui/core/themes/dimens.dart
Normal 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;
|
||||
}
|
||||
84
compass_app/app/lib/ui/core/themes/theme.dart
Normal file
84
compass_app/app/lib/ui/core/themes/theme.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
63
compass_app/app/lib/ui/core/ui/back_button.dart
Normal file
63
compass_app/app/lib/ui/core/ui/back_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
7
compass_app/app/lib/ui/core/ui/blur_filter.dart
Normal file
7
compass_app/app/lib/ui/core/ui/blur_filter.dart
Normal 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);
|
||||
50
compass_app/app/lib/ui/core/ui/custom_checkbox.dart
Normal file
50
compass_app/app/lib/ui/core/ui/custom_checkbox.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
compass_app/app/lib/ui/core/ui/date_format_start_end.dart
Normal file
24
compass_app/app/lib/ui/core/ui/date_format_start_end.dart
Normal 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';
|
||||
}
|
||||
63
compass_app/app/lib/ui/core/ui/error_indicator.dart
Normal file
63
compass_app/app/lib/ui/core/ui/error_indicator.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
60
compass_app/app/lib/ui/core/ui/home_button.dart
Normal file
60
compass_app/app/lib/ui/core/ui/home_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
compass_app/app/lib/ui/core/ui/scroll_behavior.dart
Normal file
17
compass_app/app/lib/ui/core/ui/scroll_behavior.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
111
compass_app/app/lib/ui/core/ui/search_bar.dart
Normal file
111
compass_app/app/lib/ui/core/ui/search_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
144
compass_app/app/lib/ui/core/ui/tag_chip.dart
Normal file
144
compass_app/app/lib/ui/core/ui/tag_chip.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
95
compass_app/app/lib/ui/home/view_models/home_viewmodel.dart
Normal file
95
compass_app/app/lib/ui/home/view_models/home_viewmodel.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
198
compass_app/app/lib/ui/home/widgets/home_screen.dart
Normal file
198
compass_app/app/lib/ui/home/widgets/home_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
compass_app/app/lib/ui/home/widgets/home_title.dart
Normal file
89
compass_app/app/lib/ui/home/widgets/home_title.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
89
compass_app/app/lib/ui/results/widgets/result_card.dart
Normal file
89
compass_app/app/lib/ui/results/widgets/result_card.dart
Normal 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,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
180
compass_app/app/lib/ui/results/widgets/results_screen.dart
Normal file
180
compass_app/app/lib/ui/results/widgets/results_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user