Compass App: Activities screen, error handling and logs ()

This PR introduces the Activities screen, handling of errors in view
models and commands, and logs using the dart `logging` package.

**Activities**

- The screen loads a list of activities, split in daytime and evening
activities, and the user can select them.
- Server adds the endpoint `/destination/<id>/activitity` which was
missing before.

Screencast provided:

[Screencast from 2024-07-29
16-29-02.webm](https://github.com/user-attachments/assets/a56024d8-0a9c-49e7-8fd0-c895da15badc)

**Error handling**

_UI Error handling:_

In the screencast you can see a `SnackBar` appearing, since the
"Confirm" button is not yet implemented.
The `saveActivities` Command returns an error `Result.error()`, then the
error state is exposed by the Command and consumed by the listener in
the `ActivityScreen`, which displays a `SnackBar` and consumes the
state.

Functionality is similar to the one found in [UI events - Consuming
events can trigger state
updates](https://developer.android.com/topic/architecture/ui-layer/events#consuming-trigger-updates)
from the Android architecture guide, as the command state is "consumed"
and cleared.

The Snackbar also includes an action to "try again". Tapping on it calls
to the failed Command `execute()` so users can run the action again.

For example, here the `saveActivities` command failed, so `error` is
`true`. Then we call to `clearResult()` to remove the failed status, and
show a `SnackBar`, with the `SnackBarAction` that runs `saveActivities`
again when tapped.

```dart
    if (widget.viewModel.saveActivities.error) {
      widget.viewModel.saveActivities.clearResult();
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: const Text('Error while saving activities'),
          action: SnackBarAction(
            label: "Try again",
            onPressed: widget.viewModel.saveActivities.execute,
          ),
        ),
      );
    }
```

Since commands expose `running`, `error` and `completed`, it is easy to
implement loading and error indicator widgets:

[Screencast from 2024-07-29
16-55-42.webm](https://github.com/user-attachments/assets/fb5772d0-7b9d-4ded-8fa2-9ce347f4d555)

As side node, we can easily simulate that state by adding these lines in
any of the repository implementations:

```dart
    await Future.delayed(Durations.extralong1);
    return Result.error(Exception('ERROR!'));
```

_In-code error handling:_

The project introduces the `logging` package.

In the entry point `main_development.dart` the log level is configured.
Then in code, a `Logger` is creaded in each View Model with the name of
the class. Then the log calls are used depending on the `Result`
response, some finer traces are also added.


By default, they are printed to the IDE debug console, for example:

```
[SearchFormViewModel] Continents (7) loaded
[SearchFormViewModel] ItineraryConfig loaded
[SearchFormViewModel] Selected continent: Asia
[SearchFormViewModel] Selected date range: 2024-07-30 00:00:00.000 - 2024-08-08 00:00:00.000
[SearchFormViewModel] Set guests number: 1
[SearchFormViewModel] ItineraryConfig saved
```

**Other changes**

- The json files containing destinations and activities are moved into
the `app/assets/` folders, and the server is querying those files
instead of their own copy. This is done to avoid file duplication but we
can make a copy of those assets files for the server if we decide to.

**TODO Next**

- I will implement the "book a trip" screen which would complete the
main application flow, which should introduce a more complex
"component/use case" outside a view model.

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-devrel
channel on [Discord].

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

@ -0,0 +1,4 @@
class Assets {
static const activities = 'assets/activities.json';
static const destinations = 'assets/destinations.json';
}

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart';
import '../../../config/assets.dart';
import '../../../utils/result.dart';
import 'activity_repository.dart';
@ -25,7 +26,7 @@ class ActivityRepositoryLocal implements ActivityRepository {
}
Future<String> _loadAsset() async {
return await rootBundle.loadString('assets/activities.json');
return await rootBundle.loadString(Assets.activities);
}
List<Activity> _parse(String localData) {

@ -6,7 +6,7 @@ import 'continent_repository.dart';
/// Local data source with all possible continents.
class ContinentRepositoryLocal implements ContinentRepository {
@override
Future<Result<List<Continent>>> getContinents() {
Future<Result<List<Continent>>> getContinents() async {
return Future.value(
Result.ok(
[

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart' show rootBundle;
import '../../../config/assets.dart';
import '../../../utils/result.dart';
import 'destination_repository.dart';
@ -22,7 +23,7 @@ class DestinationRepositoryLocal implements DestinationRepository {
}
Future<String> _loadAsset() async {
return await rootBundle.loadString('assets/destinations.json');
return await rootBundle.loadString(Assets.destinations);
}
List<Destination> _parse(String localData) {

@ -1,3 +1,6 @@
import 'package:flutter_localizations/flutter_localizations.dart';
import 'ui/core/localization/applocalization.dart';
import 'ui/core/themes/theme.dart';
import 'routing/router.dart';
import 'package:flutter/material.dart';
@ -17,6 +20,11 @@ class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
localizationsDelegates: [
GlobalWidgetsLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
AppLocalizationDelegate(),
],
scrollBehavior: AppCustomScrollBehavior(),
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'config/dependencies.dart';
@ -8,6 +9,8 @@ import 'main.dart';
/// Launch with `flutter run --target lib/main_development.dart`.
/// Uses local data.
void main() {
Logger.root.level = Level.ALL;
runApp(
MultiProvider(
providers: providersLocal,

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'config/dependencies.dart';
@ -8,6 +9,8 @@ import 'main.dart';
/// Launch with `flutter run --target lib/main_staging.dart`.
/// Uses remote data from a server.
void main() {
Logger.root.level = Level.ALL;
runApp(
MultiProvider(
providers: providersRemote,

@ -1,5 +1,6 @@
import 'package:compass_model/model.dart';
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';
@ -13,15 +14,28 @@ class ActivitiesViewModel extends ChangeNotifier {
}) : _activityRepository = activityRepository,
_itineraryConfigRepository = itineraryConfigRepository {
loadActivities = Command0(_loadActivities)..execute();
saveActivities = Command0(() async {
_log.shout(
'Save activities not implemented',
null,
StackTrace.current,
);
return Result.error(Exception('Not implemented'));
});
}
final _log = Logger('ActivitiesViewModel');
final ActivityRepository _activityRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
List<Activity> _activities = <Activity>[];
List<Activity> _daytimeActivities = <Activity>[];
List<Activity> _eveningActivities = <Activity>[];
final Set<String> _selectedActivities = <String>{};
/// List of [Activity] per destination.
List<Activity> get activities => _activities;
/// 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;
@ -29,18 +43,23 @@ class ActivitiesViewModel extends ChangeNotifier {
/// Load list of [Activity] for a [Destination] by ref.
late final Command0 loadActivities;
Future<void> _loadActivities() async {
/// Save list [selectedActivities] into itinerary configuration.
late final Command0 saveActivities;
Future<Result<void>> _loadActivities() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
if (result is Error) {
// TODO: Handle error
print(result.asError.error);
return;
_log.warning(
'Failed to load stored ItineraryConfig',
result.asError.error,
);
return result;
}
final destinationRef = result.asOk.value.destination;
if (destinationRef == null) {
// TODO: Error here
return;
_log.severe('Destination missing in ItineraryConfig');
return Result.error(Exception('Destination not found'));
}
final resultActivities =
@ -48,35 +67,55 @@ class ActivitiesViewModel extends ChangeNotifier {
switch (resultActivities) {
case Ok():
{
_activities = resultActivities.value;
print(_activities);
_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():
{
// TODO: Handle error
print(resultActivities.error);
_log.warning('Failed to load activities', resultActivities.error);
}
}
notifyListeners();
return resultActivities;
}
/// Add [Activity] to selected list.
void addActivity(String activityRef) {
assert(
activities.any((activity) => activity.ref == activityRef),
(_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(
activities.any((activity) => activity.ref == activityRef),
(_daytimeActivities + _eveningActivities)
.any((activity) => activity.ref == activityRef),
"Activity $activityRef not found",
);
_selectedActivities.remove(activityRef);
_log.finest('Activity $activityRef removed');
notifyListeners();
}
}

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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 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('/results');
},
),
Text(
AppLocalization.of(context).activities,
style: Theme.of(context).textTheme.titleLarge,
),
const HomeButton(),
],
),
);
}
}

@ -0,0 +1,57 @@
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,
),
),
);
}
}

@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/ui/back_button.dart';
import '../../core/ui/home_button.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';
class ActivitiesScreen extends StatelessWidget {
class ActivitiesScreen extends StatefulWidget {
const ActivitiesScreen({
super.key,
required this.viewModel,
@ -13,48 +17,157 @@ class ActivitiesScreen extends StatelessWidget {
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 Scaffold(
body: ListenableBuilder(
listenable: viewModel.loadActivities,
listenable: widget.viewModel.loadActivities,
builder: (context, child) {
if (viewModel.loadActivities.running) {
return const Center(child: CircularProgressIndicator());
if (widget.viewModel.loadActivities.completed) {
return child!;
}
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: viewModel,
listenable: widget.viewModel,
builder: (context, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomBackButton(
onTap: () {
// Navigate to ResultsScreen and edit search
context.go('/results');
},
),
const HomeButton(),
],
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,
),
],
),
// TODO: Display "activities" here
],
),
),
_BottomArea(viewModel: widget.viewModel),
],
);
},
),
),
);
}
void _onResult() {
if (widget.viewModel.saveActivities.completed) {
widget.viewModel.saveActivities.clearResult();
// TODO
}
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(
onPressed: viewModel.selectedActivities.isNotEmpty
? viewModel.saveActivities.execute
: null,
child: Text(AppLocalization.of(context).confirm),
),
],
),
),
),
);
}
}

@ -0,0 +1,39 @@
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,61 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.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,
),
),
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,
),
],
),
),
CustomCheckbox(
key: ValueKey('${activity.ref}-checkbox'),
value: selected,
onChanged: onChanged,
)
],
),
);
}
}

@ -0,0 +1 @@
enum ActivityTimeOfDay { daytime, evening }

@ -0,0 +1,78 @@
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',
'confirm': 'Confirm',
'daytime': 'Daytime',
'errorWhileLoadingActivities': 'Error while loading activities',
'errorWhileLoadingContinents': 'Error while loading continents',
'errorWhileLoadingDestinations': 'Error while loading destinations',
'errorWhileSavingActivities': 'Error while saving activities',
'errorWhileSavingItinerary': 'Error while saving itinerary',
'evening': 'Evening',
'search': 'Search',
'searchDestination': 'Search destination',
'selected': '{1} selected',
'tryAgain': 'Try again',
'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 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 tryAgain => _get('tryAgain');
String get when => _get('when');
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;
}

@ -9,6 +9,7 @@ class AppColors {
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,
@ -18,8 +19,8 @@ class AppColors {
onSecondary: AppColors.white1,
surface: Colors.white,
onSurface: AppColors.black1,
error: Colors.red,
onError: Colors.white,
error: Colors.white,
onError: Colors.red,
);
static const darkColorScheme = ColorScheme(
@ -30,7 +31,7 @@ class AppColors {
onSecondary: AppColors.black1,
surface: AppColors.black1,
onSurface: Colors.white,
error: Colors.red,
onError: Colors.white,
error: Colors.black,
onError: AppColors.red1,
);
}

@ -0,0 +1,53 @@
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,
};
}
/// Mobile dimensions
class DimensMobile extends Dimens {
@override
double paddingScreenHorizontal = Dimens.paddingHorizontal;
@override
double paddingScreenVertical = Dimens.paddingVertical;
}
/// Desktop/Web dimensions
class DimensDesktop extends Dimens {
@override
double paddingScreenHorizontal = 100.0;
@override
double paddingScreenVertical = 64.0;
}

@ -9,9 +9,23 @@ class AppTheme {
fontWeight: FontWeight.w500,
),
bodyMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
),
bodyLarge: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
),
labelSmall: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.grey3,
),
labelLarge: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
color: AppColors.grey3,
),
);
static const _inputDecorationTheme = InputDecorationTheme(

@ -0,0 +1,46 @@
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,
),
),
),
),
),
);
}
}

@ -0,0 +1,59 @@
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),
),
],
);
}
}

@ -1,6 +1,8 @@
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import '../localization/applocalization.dart';
import '../themes/dimens.dart';
import 'date_format_start_end.dart';
import '../themes/colors.dart';
import 'home_button.dart';
@ -34,7 +36,9 @@ class AppSearchBar extends StatelessWidget {
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingHorizontal,
),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: _QueryText(config: config),
@ -74,7 +78,7 @@ class _QueryText extends StatelessWidget {
return Text(
'$continent - ${dateFormatStartEnd(DateTimeRange(start: startDate, end: endDate))} - Guests: $guests',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
style: Theme.of(context).textTheme.bodyLarge,
);
}
}
@ -92,7 +96,7 @@ class _EmptySearch extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Search destination',
AppLocalization.of(context).searchDestination,
textAlign: TextAlign.start,
style: Theme.of(context).inputDecorationTheme.hintStyle,
),

@ -1,4 +1,5 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
@ -14,10 +15,12 @@ class ResultsViewModel extends ChangeNotifier {
required ItineraryConfigRepository itineraryConfigRepository,
}) : _destinationRepository = destinationRepository,
_itineraryConfigRepository = itineraryConfigRepository {
updateItineraryConfig = Command1<bool, String>(_updateItineraryConfig);
updateItineraryConfig = Command1<void, String>(_updateItineraryConfig);
search = Command0(_search)..execute();
}
final _log = Logger('ResultsViewModel');
final DestinationRepository _destinationRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
@ -37,16 +40,17 @@ class ResultsViewModel extends ChangeNotifier {
late final Command0 search;
/// Store ViewModel data into [ItineraryConfigRepository] before navigating.
late final Command1<bool, String> updateItineraryConfig;
late final Command1<void, String> updateItineraryConfig;
Future<void> _search() async {
Future<Result<void>> _search() async {
// Load current itinerary config
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
// TODO: Handle error
// ignore: avoid_print
print(resultConfig.asError.error);
return;
_log.warning(
'Failed to load stored ItineraryConfig',
resultConfig.asError.error,
);
return resultConfig;
}
_itineraryConfig = resultConfig.asOk.value;
notifyListeners();
@ -60,45 +64,40 @@ class ResultsViewModel extends ChangeNotifier {
.where((destination) =>
destination.continent == _itineraryConfig!.continent)
.toList();
_log.fine('Destinations (${_destinations.length}) loaded');
}
case Error():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
_log.warning('Failed to load destinations', result.error);
}
}
// After finish loading results, notify the view
notifyListeners();
return result;
}
Future<bool> _updateItineraryConfig(String destinationRef) async {
Future<Result<void>> _updateItineraryConfig(String destinationRef) async {
assert(destinationRef.isNotEmpty, "destinationRef should not be empty");
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
// TODO: Handle error
// ignore: avoid_print
print(resultConfig.asError.error);
return false;
_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));
switch (result) {
case Ok<void>():
{
return true;
}
case Error<void>():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
return false;
}
if (result is Error) {
_log.warning(
'Failed to store ItineraryConfig',
result.asError.error,
);
}
return result;
}
}

@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../utils/result.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 StatelessWidget {
class ResultsScreen extends StatefulWidget {
const ResultsScreen({
super.key,
required this.viewModel,
@ -14,37 +16,70 @@ class ResultsScreen extends StatelessWidget {
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 Scaffold(
body: ListenableBuilder(
listenable: viewModel.search,
listenable: widget.viewModel.search,
builder: (context, child) {
if (viewModel.search.running) {
return const Center(child: CircularProgressIndicator());
if (widget.viewModel.search.completed) {
return child!;
}
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: viewModel,
listenable: widget.viewModel,
builder: (context, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: AppSearchBar(
config: viewModel.config,
onTap: () {
// Navigate to SearchFormScreen and edit search
context.go('/');
},
),
),
child: _AppSearchBar(widget: widget),
),
_Grid(viewModel: viewModel),
_Grid(viewModel: widget.viewModel),
],
),
);
@ -53,6 +88,47 @@ class ResultsScreen extends StatelessWidget {
),
);
}
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go('/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 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.go('/');
},
),
);
}
}
class _Grid extends StatelessWidget {
@ -78,14 +154,7 @@ class _Grid extends StatelessWidget {
key: ValueKey(destination.ref),
destination: destination,
onTap: () {
viewModel.updateItineraryConfig.execute(
argument: destination.ref,
onComplete: (result) {
if (result) {
context.go('/activities');
}
},
);
viewModel.updateItineraryConfig.execute(destination.ref);
},
);
},

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../data/repositories/continent/continent_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
@ -20,6 +21,7 @@ class SearchFormViewModel extends ChangeNotifier {
load = Command0(_load)..execute();
}
final _log = Logger('SearchFormViewModel');
final ContinentRepository _continentRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
List<Continent> _continents = [];
@ -43,6 +45,7 @@ class SearchFormViewModel extends ChangeNotifier {
/// Set to null to clear the selection.
set selectedContinent(String? continent) {
_selectedContinent = continent;
_log.finest('Selected continent: $continent');
notifyListeners();
}
@ -54,6 +57,7 @@ class SearchFormViewModel extends ChangeNotifier {
/// Can be set to null to clear selection.
set dateRange(DateTimeRange? dateRange) {
_dateRange = dateRange;
_log.finest('Selected date range: $dateRange');
notifyListeners();
}
@ -68,6 +72,7 @@ class SearchFormViewModel extends ChangeNotifier {
} else {
_guests = quantity;
}
_log.finest('Set guests number: $_guests');
notifyListeners();
}
@ -75,31 +80,34 @@ class SearchFormViewModel extends ChangeNotifier {
late final Command0 load;
/// Store ViewModel data into [ItineraryConfigRepository] before navigating.
late final Command0<bool> updateItineraryConfig;
late final Command0 updateItineraryConfig;
Future<void> _load() async {
await _loadContinents();
await _loadItineraryConfig();
Future<Result<void>> _load() async {
final result = await _loadContinents();
if (result is Error) {
return result;
}
return await _loadItineraryConfig();
}
Future<void> _loadContinents() async {
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():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
_log.warning('Failed to load continents', result.asError.error);
}
}
notifyListeners();
return result;
}
Future<void> _loadItineraryConfig() async {
Future<Result<void>> _loadItineraryConfig() async {
final result = await _itineraryConfigRepository.getItineraryConfig();
switch (result) {
case Ok<ItineraryConfig>():
@ -114,38 +122,36 @@ class SearchFormViewModel extends ChangeNotifier {
);
}
_guests = itineraryConfig.guests ?? 0;
_log.fine('ItineraryConfig loaded');
notifyListeners();
}
case Error<ItineraryConfig>():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
_log.warning(
'Failed to load stored ItineraryConfig',
result.asError.error,
);
}
}
return result;
}
Future<bool> _updateItineraryConfig() async {
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,
));
final result = await _itineraryConfigRepository.setItineraryConfig(
ItineraryConfig(
continent: _selectedContinent,
startDate: _dateRange!.start,
endDate: _dateRange!.end,
guests: _guests,
),
);
switch (result) {
case Ok<void>():
{
return true;
}
_log.fine('ItineraryConfig saved');
case Error<void>():
{
// TODO: Handle error
// ignore: avoid_print
print(result.error);
return false;
}
_log.warning('Failed to store ItineraryConfig', result.error);
}
return result;
}
}

@ -3,7 +3,10 @@ import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.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
@ -31,6 +34,15 @@ class SearchFormContinent extends StatelessWidget {
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(
@ -39,7 +51,7 @@ class SearchFormContinent extends StatelessWidget {
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: viewModel.continents.length,
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
itemBuilder: (BuildContext context, int index) {
final Continent(:imageUrl, :name) = viewModel.continents[index];
return _CarouselItem(

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.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';
@ -19,7 +20,11 @@ class SearchFormDate extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 24, left: 20, right: 20),
padding: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
),
child: InkWell(
borderRadius: BorderRadius.circular(16.0),
onTap: () {
@ -36,12 +41,14 @@ class SearchFormDate extends StatelessWidget {
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingHorizontal,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'When',
AppLocalization.of(context).when,
style: Theme.of(context).textTheme.titleMedium,
),
ListenableBuilder(
@ -51,11 +58,11 @@ class SearchFormDate extends StatelessWidget {
if (dateRange != null) {
return Text(
dateFormatStartEnd(dateRange),
style: Theme.of(context).textTheme.bodyMedium,
style: Theme.of(context).textTheme.bodyLarge,
);
} else {
return Text(
'Add Dates',
AppLocalization.of(context).addDates,
style: Theme.of(context).inputDecorationTheme.hintStyle,
);
}

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/themes/colors.dart';
import '../../core/themes/dimens.dart';
import '../view_models/search_form_viewmodel.dart';
/// Number of guests selection form
@ -18,7 +19,11 @@ class SearchFormGuests extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 24, left: 20, right: 20),
padding: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
),
child: Container(
height: 64,
decoration: BoxDecoration(
@ -26,7 +31,9 @@ class SearchFormGuests extends StatelessWidget {
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingHorizontal,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [

@ -1,5 +1,6 @@
import 'package:flutter/material.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';
@ -27,12 +28,14 @@ class SearchFormScreen extends StatelessWidget {
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 24,
Padding(
padding: EdgeInsets.only(
top: Dimens.of(context).paddingScreenVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.paddingVertical,
),
child: AppSearchBar(),
child: const AppSearchBar(),
),
SearchFormContinent(viewModel: viewModel),
SearchFormDate(viewModel: viewModel),

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../utils/result.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../results/widgets/results_screen.dart';
import '../view_models/search_form_viewmodel.dart';
@ -10,7 +11,7 @@ import '../view_models/search_form_viewmodel.dart';
/// The button is disabled when the form is data is incomplete.
/// When tapped, it navigates to the [ResultsScreen]
/// passing the search options as query parameters.
class SearchFormSubmit extends StatelessWidget {
class SearchFormSubmit extends StatefulWidget {
const SearchFormSubmit({
super.key,
required this.viewModel,
@ -18,31 +19,52 @@ class SearchFormSubmit extends StatelessWidget {
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) {
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 Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
padding: EdgeInsets.only(
top: Dimens.paddingVertical,
left: Dimens.of(context).paddingScreenHorizontal,
right: Dimens.of(context).paddingScreenHorizontal,
bottom: Dimens.of(context).paddingScreenVertical,
),
child: ListenableBuilder(
listenable: viewModel,
child: const SizedBox(
listenable: widget.viewModel,
child: SizedBox(
height: 52,
child: Center(
child: Text('Search'),
child: Text(AppLocalization.of(context).search),
),
),
builder: (context, child) {
return FilledButton(
key: const ValueKey('submit_button'),
onPressed: viewModel.valid
? () async {
await viewModel.updateItineraryConfig.execute(
onComplete: (result) {
if (result) {
context.go('/results');
}
},
);
}
onPressed: widget.viewModel.valid
? widget.viewModel.updateItineraryConfig.execute
: null,
child: child,
);
@ -50,4 +72,22 @@ class SearchFormSubmit extends StatelessWidget {
),
);
}
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go('/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,
),
));
}
}
}

@ -1,17 +1,25 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
typedef CommandAction0<T> = Future<T> Function();
typedef CommandAction1<T, A> = Future<T> Function(A);
typedef OnComplete<T> = Function(T);
import 'result.dart';
typedef CommandAction0<T> = Future<Result<T>> Function();
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);
/// Facilitates interaction with a ViewModel.
///
/// Encapsulates an action,
/// exposes its running state,
/// exposes its running and error states,
/// and ensures that it can't be launched again until it finishes.
///
/// Use [Command0] for actions without arguments.
/// Use [Command1] for actions with one argument.
///
/// Actions must return a [Result].
///
/// Consume the action result by listening to changes,
/// then call to [clearResult] when the state is consumed.
abstract class Command<T> extends ChangeNotifier {
Command();
@ -20,11 +28,25 @@ abstract class Command<T> extends ChangeNotifier {
/// True when the action is running.
bool get running => _running;
Result<T>? _result;
/// true if action completed with error
bool get error => _result is Error;
/// true if action completed successfully
bool get completed => _result is Ok;
/// Get last action result
Result? get result => _result;
/// Clear last action result
void clearResult() {
_result = null;
notifyListeners();
}
/// Internal execute implementation
Future<void> _execute(
CommandAction0<T> action,
OnComplete<T>? onComplete,
) async {
Future<void> _execute(CommandAction0<T> action) async {
// Ensure the action can't launch multiple times.
// e.g. avoid multiple taps on button
if (_running) return;
@ -32,11 +54,11 @@ abstract class Command<T> extends ChangeNotifier {
// Notify listeners.
// e.g. button shows loading state
_running = true;
_result = null;
notifyListeners();
try {
final result = await action();
onComplete?.call(result);
_result = await action();
} finally {
_running = false;
notifyListeners();
@ -52,9 +74,8 @@ class Command0<T> extends Command<T> {
final CommandAction0<T> _action;
/// Executes the action.
/// onComplete is called when the action completes.
Future<void> execute({OnComplete<T>? onComplete}) async {
await _execute(() => _action(), onComplete);
Future<void> execute() async {
await _execute(() => _action());
}
}
@ -66,8 +87,7 @@ class Command1<T, A> extends Command<T> {
final CommandAction1<T, A> _action;
/// Executes the action with the argument.
/// onComplete is called when the action completes.
Future<void> execute({required A argument, OnComplete<T>? onComplete}) async {
await _execute(() => _action(argument), onComplete);
Future<void> execute(A argument) async {
await _execute(() => _action(argument));
}
}

@ -12,9 +12,12 @@ dependencies:
path: ../model
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
go_router: ^14.2.0
google_fonts: ^6.2.1
intl: ^0.19.0
intl: any
logging: ^1.2.0
provider: ^6.1.2
dev_dependencies:

@ -0,0 +1,68 @@
import 'package:compass_app/ui/activities/view_models/activities_viewmodel.dart';
import 'package:compass_app/ui/activities/widgets/activities_screen.dart';
import 'package:compass_app/ui/activities/widgets/activity_entry.dart';
import 'package:compass_app/ui/core/themes/theme.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';
import '../../util/fakes/repositories/fake_activities_repository.dart';
import '../../util/fakes/repositories/fake_itinerary_config_repository.dart';
void main() {
group('ResultsScreen widget tests', () {
late ActivitiesViewModel viewModel;
setUp(() {
viewModel = ActivitiesViewModel(
activityRepository: FakeActivityRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(
itineraryConfig: ItineraryConfig(
continent: 'Europe',
startDate: DateTime(2024, 01, 01),
endDate: DateTime(2024, 01, 31),
guests: 2,
destination: 'DESTINATION',
),
),
);
});
// Build and render the ResultsScreen widget
Future<void> loadScreen(WidgetTester tester) async {
// Load some data
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.lightTheme,
home: ActivitiesScreen(
viewModel: viewModel,
),
),
);
}
testWidgets('should load screen', (WidgetTester tester) async {
await mockNetworkImages(() async {
await loadScreen(tester);
expect(find.byType(ActivitiesScreen), findsOneWidget);
});
});
testWidgets('should list activity', (WidgetTester tester) async {
await mockNetworkImages(() async {
await loadScreen(tester);
expect(find.byType(ActivityEntry), findsOneWidget);
expect(find.text('NAME'), findsOneWidget);
});
});
testWidgets('should select activity', (WidgetTester tester) async {
await mockNetworkImages(() async {
await loadScreen(tester);
await tester.tap(find.byKey(const ValueKey('REF-checkbox')));
expect(viewModel.selectedActivities, contains('REF'));
});
});
});
}

@ -62,11 +62,8 @@ void main() {
viewModel.dateRange = newDateRange;
expect(viewModel.valid, true);
await viewModel.updateItineraryConfig.execute(
onComplete: (result) {
expect(result, true);
},
);
await viewModel.updateItineraryConfig.execute();
expect(viewModel.updateItineraryConfig.completed, true);
});
});
}

@ -0,0 +1,27 @@
import 'package:compass_app/data/repositories/activity/activity_repository.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/activity/activity.dart';
class FakeActivityRepository implements ActivityRepository {
Map<String, List<Activity>> activities = {
"DESTINATION": [
const Activity(
description: 'DESCRIPTION',
destinationRef: 'DESTINATION',
duration: 3,
familyFriendly: true,
imageUrl: 'http://example.com/img.png',
locationName: 'LOCATION NAME',
name: 'NAME',
price: 3,
ref: 'REF',
timeOfDay: TimeOfDay.afternoon,
),
],
};
@override
Future<Result<List<Activity>>> getByDestination(String ref) async {
return Result.ok(activities[ref]!);
}
}

@ -2,8 +2,6 @@ import 'package:compass_app/data/repositories/itinerary_config/itinerary_config_
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/itinerary_config/itinerary_config.dart';
import 'fake_destination_repository.dart';
class FakeItineraryConfigRepository implements ItineraryConfigRepository {
FakeItineraryConfigRepository({this.itineraryConfig});

@ -55,7 +55,7 @@ class FakeApiClient implements ApiClient {
'Embark on a thrilling adventure exploring the awe-inspiring glaciers of Alaska. Hike across the icy terrain, marvel at the deep blue crevasses, and even try your hand at ice climbing for an unforgettable experience.',
locationName: 'Matanuska Glacier or Mendenhall Glacier',
duration: 8,
timeOfDay: 'morning',
timeOfDay: TimeOfDay.morning,
familyFriendly: false,
price: 4,
destinationRef: 'alaska',

@ -1,40 +1,34 @@
import 'dart:math';
import 'package:compass_app/utils/command.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Command0 tests', () {
test('should complete void command', () async {
// Void action
final command = Command0<void>(() => Future.value());
bool completed = false;
final command = Command0<void>(() => Future.value(Result.ok(null)));
// Run void action
await command.execute(onComplete: (_) {
completed = true;
});
await command.execute();
// Action completed
expect(completed, true);
expect(command.completed, true);
});
test('should complete bool command', () async {
// Action that returns bool
final command = Command0<bool>(() => Future.value(true));
bool completed = false;
final command = Command0<bool>(() => Future.value(Result.ok(true)));
// Run action with result
await command.execute(onComplete: (value) {
completed = value;
});
await command.execute();
// Action completed with result
expect(completed, true);
// Action completed
expect(command.completed, true);
expect(command.result!.asOk.value, true);
});
test('running should be true', () async {
final command = Command0<void>(() => Future.value());
final command = Command0<void>(() => Future.value(Result.ok(null)));
final future = command.execute();
// Action is running
@ -49,7 +43,7 @@ void main() {
test('should only run once', () async {
int count = 0;
final command = Command0<int>(() => Future.value(count++));
final command = Command0<int>(() => Future.value(Result.ok(count++)));
final future = command.execute();
// Run multiple times
@ -64,6 +58,14 @@ void main() {
// Action is called once
expect(count, 1);
});
test('should handle errors', () async {
final command =
Command0<int>(() => Future.value(Result.error(Exception('ERROR!'))));
await command.execute();
expect(command.error, true);
expect(command.result, isA<Error>());
});
});
group('Command1 tests', () {
@ -71,37 +73,26 @@ void main() {
// Void action with bool argument
final command = Command1<void, bool>((a) {
expect(a, true);
return Future.value();
return Future.value(Result.ok(null));
});
bool completed = false;
// Run void action, ignore void return
await command.execute(
argument: true,
onComplete: (_) {
completed = true;
},
);
expect(completed, true);
await command.execute(true);
expect(command.completed, true);
});
test('should complete bool command, bool argument', () async {
// Action that returns bool argument
final command = Command1<bool, bool>((a) => Future.value(a));
bool completed = false;
final command =
Command1<bool, bool>((a) => Future.value(Result.ok(true)));
// Run action with result and argument
await command.execute(
argument: true,
onComplete: (value) {
completed = value;
},
);
await command.execute(true);
// Argument was passed to onComplete
expect(completed, true);
expect(command.completed, true);
expect(command.result!.asOk.value, true);
});
});
}

@ -4,6 +4,14 @@ part 'activity.freezed.dart';
part 'activity.g.dart';
enum TimeOfDay {
any,
morning,
afternoon,
evening,
night,
}
@freezed
class Activity with _$Activity {
const factory Activity({
@ -21,7 +29,7 @@ class Activity with _$Activity {
required int duration,
/// e.g. 'morning'
required String timeOfDay,
required TimeOfDay timeOfDay,
/// e.g. false
required bool familyFriendly,

@ -34,7 +34,7 @@ mixin _$Activity {
int get duration => throw _privateConstructorUsedError;
/// e.g. 'morning'
String get timeOfDay => throw _privateConstructorUsedError;
TimeOfDay get timeOfDay => throw _privateConstructorUsedError;
/// e.g. false
bool get familyFriendly => throw _privateConstructorUsedError;
@ -71,7 +71,7 @@ abstract class $ActivityCopyWith<$Res> {
String description,
String locationName,
int duration,
String timeOfDay,
TimeOfDay timeOfDay,
bool familyFriendly,
int price,
String destinationRef,
@ -125,7 +125,7 @@ class _$ActivityCopyWithImpl<$Res, $Val extends Activity>
timeOfDay: null == timeOfDay
? _value.timeOfDay
: timeOfDay // ignore: cast_nullable_to_non_nullable
as String,
as TimeOfDay,
familyFriendly: null == familyFriendly
? _value.familyFriendly
: familyFriendly // ignore: cast_nullable_to_non_nullable
@ -163,7 +163,7 @@ abstract class _$$ActivityImplCopyWith<$Res>
String description,
String locationName,
int duration,
String timeOfDay,
TimeOfDay timeOfDay,
bool familyFriendly,
int price,
String destinationRef,
@ -215,7 +215,7 @@ class __$$ActivityImplCopyWithImpl<$Res>
timeOfDay: null == timeOfDay
? _value.timeOfDay
: timeOfDay // ignore: cast_nullable_to_non_nullable
as String,
as TimeOfDay,
familyFriendly: null == familyFriendly
? _value.familyFriendly
: familyFriendly // ignore: cast_nullable_to_non_nullable
@ -277,7 +277,7 @@ class _$ActivityImpl implements _Activity {
/// e.g. 'morning'
@override
final String timeOfDay;
final TimeOfDay timeOfDay;
/// e.g. false
@override
@ -365,7 +365,7 @@ abstract class _Activity implements Activity {
required final String description,
required final String locationName,
required final int duration,
required final String timeOfDay,
required final TimeOfDay timeOfDay,
required final bool familyFriendly,
required final int price,
required final String destinationRef,
@ -394,7 +394,7 @@ abstract class _Activity implements Activity {
/// e.g. 'morning'
@override
String get timeOfDay;
TimeOfDay get timeOfDay;
/// e.g. false
@override

@ -12,7 +12,7 @@ _$ActivityImpl _$$ActivityImplFromJson(Map<String, dynamic> json) =>
description: json['description'] as String,
locationName: json['locationName'] as String,
duration: (json['duration'] as num).toInt(),
timeOfDay: json['timeOfDay'] as String,
timeOfDay: $enumDecode(_$TimeOfDayEnumMap, json['timeOfDay']),
familyFriendly: json['familyFriendly'] as bool,
price: (json['price'] as num).toInt(),
destinationRef: json['destinationRef'] as String,
@ -26,10 +26,18 @@ Map<String, dynamic> _$$ActivityImplToJson(_$ActivityImpl instance) =>
'description': instance.description,
'locationName': instance.locationName,
'duration': instance.duration,
'timeOfDay': instance.timeOfDay,
'timeOfDay': _$TimeOfDayEnumMap[instance.timeOfDay]!,
'familyFriendly': instance.familyFriendly,
'price': instance.price,
'destinationRef': instance.destinationRef,
'ref': instance.ref,
'imageUrl': instance.imageUrl,
};
const _$TimeOfDayEnumMap = {
TimeOfDay.any: 'any',
TimeOfDay.morning: 'morning',
TimeOfDay.afternoon: 'afternoon',
TimeOfDay.evening: 'evening',
TimeOfDay.night: 'night',
};

File diff suppressed because it is too large Load Diff

@ -9,7 +9,7 @@ import 'package:shelf_router/shelf_router.dart';
// Configure routes.
final _router = Router()
..get('/continent', continentHandler)
..get('/destination', destinationHandler);
..mount('/destination', DestinationApi().router.call);
void main(List<String> args) async {
// Use any available host or container IP (usually `0.0.0.0`).

@ -0,0 +1,4 @@
class Assets {
static const activities = '../app/assets/activities.json';
static const destinations = '../app/assets/destinations.json';
}

@ -1,9 +1,42 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
Future<Response> destinationHandler(Request req) async {
final file = File('assets/destinations.json');
final jsonString = await file.readAsString();
return Response.ok(jsonString);
import '../config/assets.dart';
class DestinationApi {
final List<Destination> destinations =
(json.decode(File(Assets.destinations).readAsStringSync()) as List)
.map((element) => Destination.fromJson(element))
.toList();
final List<Activity> activities =
(json.decode(File(Assets.activities).readAsStringSync()) as List)
.map((element) => Activity.fromJson(element))
.toList();
Router get router {
final router = Router();
router.get('/', (Request request) {
return Response.ok(
json.encode(destinations),
headers: {'Content-Type': 'application/json'},
);
});
router.get('/<id>/activity', (Request request, String id) {
final list = activities
.where((activity) => activity.destinationRef == id)
.toList();
return Response.ok(
json.encode(list),
headers: {'Content-Type': 'application/json'},
);
});
return router;
}
}

@ -46,6 +46,18 @@ void main() {
expect(destination.first.name, 'Alaska');
});
test('Get Activities end-point', () async {
// Query /destination/alaska/activity end-point
final response = await get(Uri.parse('$host/destination/alaska/activity'));
expect(response.statusCode, 200);
// Parse json response list
final list = jsonDecode(response.body) as List<dynamic>;
// Parse items
final activity = list.map((element) => Activity.fromJson(element));
expect(activity.length, 20);
expect(activity.first.name, 'Glacier Trekking and Ice Climbing');
});
test('404', () async {
final response = await get(Uri.parse('$host/foobar'));
expect(response.statusCode, 404);

Loading…
Cancel
Save