mirror of https://github.com/flutter/samples.git
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.mdpull/2389/head
parent
175195eae6
commit
0305894b0e
@ -0,0 +1,4 @@
|
||||
class Assets {
|
||||
static const activities = 'assets/activities.json';
|
||||
static const destinations = 'assets/destinations.json';
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,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;
|
||||
}
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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]!);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in new issue