Skip to content

Commit 0305894

Browse files
Compass App: Activities screen, error handling and logs (#2371)
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
1 parent 175195e commit 0305894

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1183
-1482
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class Assets {
2+
static const activities = 'assets/activities.json';
3+
static const destinations = 'assets/destinations.json';
4+
}

compass_app/app/lib/data/repositories/activity/activity_repository_local.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'package:compass_model/model.dart';
44
import 'package:flutter/services.dart';
55

6+
import '../../../config/assets.dart';
67
import '../../../utils/result.dart';
78
import 'activity_repository.dart';
89

@@ -25,7 +26,7 @@ class ActivityRepositoryLocal implements ActivityRepository {
2526
}
2627

2728
Future<String> _loadAsset() async {
28-
return await rootBundle.loadString('assets/activities.json');
29+
return await rootBundle.loadString(Assets.activities);
2930
}
3031

3132
List<Activity> _parse(String localData) {

compass_app/app/lib/data/repositories/continent/continent_repository_local.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import 'continent_repository.dart';
66
/// Local data source with all possible continents.
77
class ContinentRepositoryLocal implements ContinentRepository {
88
@override
9-
Future<Result<List<Continent>>> getContinents() {
9+
Future<Result<List<Continent>>> getContinents() async {
1010
return Future.value(
1111
Result.ok(
1212
[

compass_app/app/lib/data/repositories/destination/destination_repository_local.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'package:compass_model/model.dart';
44
import 'package:flutter/services.dart' show rootBundle;
55

6+
import '../../../config/assets.dart';
67
import '../../../utils/result.dart';
78
import 'destination_repository.dart';
89

@@ -22,7 +23,7 @@ class DestinationRepositoryLocal implements DestinationRepository {
2223
}
2324

2425
Future<String> _loadAsset() async {
25-
return await rootBundle.loadString('assets/destinations.json');
26+
return await rootBundle.loadString(Assets.destinations);
2627
}
2728

2829
List<Destination> _parse(String localData) {

compass_app/app/lib/main.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'package:flutter_localizations/flutter_localizations.dart';
2+
3+
import 'ui/core/localization/applocalization.dart';
14
import 'ui/core/themes/theme.dart';
25
import 'routing/router.dart';
36
import 'package:flutter/material.dart';
@@ -17,6 +20,11 @@ class MainApp extends StatelessWidget {
1720
@override
1821
Widget build(BuildContext context) {
1922
return MaterialApp.router(
23+
localizationsDelegates: [
24+
GlobalWidgetsLocalizations.delegate,
25+
GlobalMaterialLocalizations.delegate,
26+
AppLocalizationDelegate(),
27+
],
2028
scrollBehavior: AppCustomScrollBehavior(),
2129
theme: AppTheme.lightTheme,
2230
darkTheme: AppTheme.darkTheme,

compass_app/app/lib/main_development.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:logging/logging.dart';
23
import 'package:provider/provider.dart';
34

45
import 'config/dependencies.dart';
@@ -8,6 +9,8 @@ import 'main.dart';
89
/// Launch with `flutter run --target lib/main_development.dart`.
910
/// Uses local data.
1011
void main() {
12+
Logger.root.level = Level.ALL;
13+
1114
runApp(
1215
MultiProvider(
1316
providers: providersLocal,

compass_app/app/lib/main_staging.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:logging/logging.dart';
23
import 'package:provider/provider.dart';
34

45
import 'config/dependencies.dart';
@@ -8,6 +9,8 @@ import 'main.dart';
89
/// Launch with `flutter run --target lib/main_staging.dart`.
910
/// Uses remote data from a server.
1011
void main() {
12+
Logger.root.level = Level.ALL;
13+
1114
runApp(
1215
MultiProvider(
1316
providers: providersRemote,
Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:compass_model/model.dart';
22
import 'package:flutter/foundation.dart';
3+
import 'package:logging/logging.dart';
34

45
import '../../../data/repositories/activity/activity_repository.dart';
56
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
@@ -13,70 +14,108 @@ class ActivitiesViewModel extends ChangeNotifier {
1314
}) : _activityRepository = activityRepository,
1415
_itineraryConfigRepository = itineraryConfigRepository {
1516
loadActivities = Command0(_loadActivities)..execute();
17+
saveActivities = Command0(() async {
18+
_log.shout(
19+
'Save activities not implemented',
20+
null,
21+
StackTrace.current,
22+
);
23+
return Result.error(Exception('Not implemented'));
24+
});
1625
}
1726

27+
final _log = Logger('ActivitiesViewModel');
1828
final ActivityRepository _activityRepository;
1929
final ItineraryConfigRepository _itineraryConfigRepository;
20-
List<Activity> _activities = <Activity>[];
30+
List<Activity> _daytimeActivities = <Activity>[];
31+
List<Activity> _eveningActivities = <Activity>[];
2132
final Set<String> _selectedActivities = <String>{};
2233

23-
/// List of [Activity] per destination.
24-
List<Activity> get activities => _activities;
34+
/// List of daytime [Activity] per destination.
35+
List<Activity> get daytimeActivities => _daytimeActivities;
36+
37+
/// List of evening [Activity] per destination.
38+
List<Activity> get eveningActivities => _eveningActivities;
2539

2640
/// Selected [Activity] by ref.
2741
Set<String> get selectedActivities => _selectedActivities;
2842

2943
/// Load list of [Activity] for a [Destination] by ref.
3044
late final Command0 loadActivities;
3145

32-
Future<void> _loadActivities() async {
46+
/// Save list [selectedActivities] into itinerary configuration.
47+
late final Command0 saveActivities;
48+
49+
Future<Result<void>> _loadActivities() async {
3350
final result = await _itineraryConfigRepository.getItineraryConfig();
3451
if (result is Error) {
35-
// TODO: Handle error
36-
print(result.asError.error);
37-
return;
52+
_log.warning(
53+
'Failed to load stored ItineraryConfig',
54+
result.asError.error,
55+
);
56+
return result;
3857
}
3958

4059
final destinationRef = result.asOk.value.destination;
4160
if (destinationRef == null) {
42-
// TODO: Error here
43-
return;
61+
_log.severe('Destination missing in ItineraryConfig');
62+
return Result.error(Exception('Destination not found'));
4463
}
4564

4665
final resultActivities =
4766
await _activityRepository.getByDestination(destinationRef);
4867
switch (resultActivities) {
4968
case Ok():
5069
{
51-
_activities = resultActivities.value;
52-
print(_activities);
70+
_daytimeActivities = resultActivities.value
71+
.where((activity) => [
72+
TimeOfDay.any,
73+
TimeOfDay.morning,
74+
TimeOfDay.afternoon,
75+
].contains(activity.timeOfDay))
76+
.toList();
77+
78+
_eveningActivities = resultActivities.value
79+
.where((activity) => [
80+
TimeOfDay.evening,
81+
TimeOfDay.night,
82+
].contains(activity.timeOfDay))
83+
.toList();
84+
85+
_log.fine('Activities (daytime: ${_daytimeActivities.length}, '
86+
'evening: ${_eveningActivities.length}) loaded');
5387
}
5488
case Error():
5589
{
56-
// TODO: Handle error
57-
print(resultActivities.error);
90+
_log.warning('Failed to load activities', resultActivities.error);
5891
}
5992
}
93+
6094
notifyListeners();
95+
return resultActivities;
6196
}
6297

6398
/// Add [Activity] to selected list.
6499
void addActivity(String activityRef) {
65100
assert(
66-
activities.any((activity) => activity.ref == activityRef),
101+
(_daytimeActivities + _eveningActivities)
102+
.any((activity) => activity.ref == activityRef),
67103
"Activity $activityRef not found",
68104
);
69105
_selectedActivities.add(activityRef);
106+
_log.finest('Activity $activityRef added');
70107
notifyListeners();
71108
}
72109

73110
/// Remove [Activity] from selected list.
74111
void removeActivity(String activityRef) {
75112
assert(
76-
activities.any((activity) => activity.ref == activityRef),
113+
(_daytimeActivities + _eveningActivities)
114+
.any((activity) => activity.ref == activityRef),
77115
"Activity $activityRef not found",
78116
);
79117
_selectedActivities.remove(activityRef);
118+
_log.finest('Activity $activityRef removed');
80119
notifyListeners();
81120
}
82121
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:go_router/go_router.dart';
3+
4+
import '../../core/localization/applocalization.dart';
5+
import '../../core/themes/dimens.dart';
6+
import '../../core/ui/back_button.dart';
7+
import '../../core/ui/home_button.dart';
8+
9+
class ActivitiesHeader extends StatelessWidget {
10+
const ActivitiesHeader({super.key});
11+
12+
@override
13+
Widget build(BuildContext context) {
14+
return Padding(
15+
padding: EdgeInsets.only(
16+
left: Dimens.of(context).paddingScreenHorizontal,
17+
right: Dimens.of(context).paddingScreenHorizontal,
18+
top: Dimens.of(context).paddingScreenVertical,
19+
bottom: Dimens.paddingVertical,
20+
),
21+
child: Row(
22+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
23+
children: [
24+
CustomBackButton(
25+
onTap: () {
26+
// Navigate to ResultsScreen and edit search
27+
context.go('/results');
28+
},
29+
),
30+
Text(
31+
AppLocalization.of(context).activities,
32+
style: Theme.of(context).textTheme.titleLarge,
33+
),
34+
const HomeButton(),
35+
],
36+
),
37+
);
38+
}
39+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'package:flutter/material.dart';
2+
3+
import '../../core/themes/dimens.dart';
4+
import '../view_models/activities_viewmodel.dart';
5+
import 'activity_entry.dart';
6+
import 'activity_time_of_day.dart';
7+
8+
class ActivitiesList extends StatelessWidget {
9+
const ActivitiesList({
10+
super.key,
11+
required this.viewModel,
12+
required this.activityTimeOfDay,
13+
});
14+
15+
final ActivitiesViewModel viewModel;
16+
final ActivityTimeOfDay activityTimeOfDay;
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
final list = switch (activityTimeOfDay) {
21+
ActivityTimeOfDay.daytime => viewModel.daytimeActivities,
22+
ActivityTimeOfDay.evening => viewModel.eveningActivities,
23+
};
24+
return SliverPadding(
25+
padding: EdgeInsets.only(
26+
top: Dimens.paddingVertical,
27+
left: Dimens.of(context).paddingScreenHorizontal,
28+
right: Dimens.of(context).paddingScreenHorizontal,
29+
bottom: Dimens.paddingVertical,
30+
),
31+
sliver: SliverList(
32+
delegate: SliverChildBuilderDelegate(
33+
(context, index) {
34+
final activity = list[index];
35+
return Padding(
36+
padding:
37+
EdgeInsets.only(bottom: index < list.length - 1 ? 20 : 0),
38+
child: ActivityEntry(
39+
key: ValueKey(activity.ref),
40+
activity: activity,
41+
selected: viewModel.selectedActivities.contains(activity.ref),
42+
onChanged: (value) {
43+
if (value!) {
44+
viewModel.addActivity(activity.ref);
45+
} else {
46+
viewModel.removeActivity(activity.ref);
47+
}
48+
},
49+
),
50+
);
51+
},
52+
childCount: list.length,
53+
),
54+
),
55+
);
56+
}
57+
}

0 commit comments

Comments
 (0)