mirror of
https://github.com/flutter/samples.git
synced 2025-11-11 23:39:14 +00:00
Compass app (#2446)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../../data/repositories/activity/activity_repository.dart';
|
||||
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
|
||||
import '../../../domain/models/activity/activity.dart';
|
||||
import '../../../utils/command.dart';
|
||||
import '../../../utils/result.dart';
|
||||
|
||||
class ActivitiesViewModel extends ChangeNotifier {
|
||||
ActivitiesViewModel({
|
||||
required ActivityRepository activityRepository,
|
||||
required ItineraryConfigRepository itineraryConfigRepository,
|
||||
}) : _activityRepository = activityRepository,
|
||||
_itineraryConfigRepository = itineraryConfigRepository {
|
||||
loadActivities = Command0(_loadActivities)..execute();
|
||||
saveActivities = Command0(_saveActivities);
|
||||
}
|
||||
|
||||
final _log = Logger('ActivitiesViewModel');
|
||||
final ActivityRepository _activityRepository;
|
||||
final ItineraryConfigRepository _itineraryConfigRepository;
|
||||
List<Activity> _daytimeActivities = <Activity>[];
|
||||
List<Activity> _eveningActivities = <Activity>[];
|
||||
final Set<String> _selectedActivities = <String>{};
|
||||
|
||||
/// List of daytime [Activity] per destination.
|
||||
List<Activity> get daytimeActivities => _daytimeActivities;
|
||||
|
||||
/// List of evening [Activity] per destination.
|
||||
List<Activity> get eveningActivities => _eveningActivities;
|
||||
|
||||
/// Selected [Activity] by ref.
|
||||
Set<String> get selectedActivities => _selectedActivities;
|
||||
|
||||
/// Load list of [Activity] for a [Destination] by ref.
|
||||
late final Command0 loadActivities;
|
||||
|
||||
/// Save list [selectedActivities] into itinerary configuration.
|
||||
late final Command0 saveActivities;
|
||||
|
||||
Future<Result<void>> _loadActivities() async {
|
||||
final result = await _itineraryConfigRepository.getItineraryConfig();
|
||||
if (result is Error) {
|
||||
_log.warning(
|
||||
'Failed to load stored ItineraryConfig',
|
||||
result.asError.error,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
final destinationRef = result.asOk.value.destination;
|
||||
if (destinationRef == null) {
|
||||
_log.severe('Destination missing in ItineraryConfig');
|
||||
return Result.error(Exception('Destination not found'));
|
||||
}
|
||||
|
||||
_selectedActivities.addAll(result.asOk.value.activities);
|
||||
|
||||
final resultActivities =
|
||||
await _activityRepository.getByDestination(destinationRef);
|
||||
switch (resultActivities) {
|
||||
case Ok():
|
||||
{
|
||||
_daytimeActivities = resultActivities.value
|
||||
.where((activity) => [
|
||||
TimeOfDay.any,
|
||||
TimeOfDay.morning,
|
||||
TimeOfDay.afternoon,
|
||||
].contains(activity.timeOfDay))
|
||||
.toList();
|
||||
|
||||
_eveningActivities = resultActivities.value
|
||||
.where((activity) => [
|
||||
TimeOfDay.evening,
|
||||
TimeOfDay.night,
|
||||
].contains(activity.timeOfDay))
|
||||
.toList();
|
||||
|
||||
_log.fine('Activities (daytime: ${_daytimeActivities.length}, '
|
||||
'evening: ${_eveningActivities.length}) loaded');
|
||||
}
|
||||
case Error():
|
||||
{
|
||||
_log.warning('Failed to load activities', resultActivities.error);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
return resultActivities;
|
||||
}
|
||||
|
||||
/// Add [Activity] to selected list.
|
||||
void addActivity(String activityRef) {
|
||||
assert(
|
||||
(_daytimeActivities + _eveningActivities)
|
||||
.any((activity) => activity.ref == activityRef),
|
||||
"Activity $activityRef not found",
|
||||
);
|
||||
_selectedActivities.add(activityRef);
|
||||
_log.finest('Activity $activityRef added');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove [Activity] from selected list.
|
||||
void removeActivity(String activityRef) {
|
||||
assert(
|
||||
(_daytimeActivities + _eveningActivities)
|
||||
.any((activity) => activity.ref == activityRef),
|
||||
"Activity $activityRef not found",
|
||||
);
|
||||
_selectedActivities.remove(activityRef);
|
||||
_log.finest('Activity $activityRef removed');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<Result<void>> _saveActivities() async {
|
||||
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
|
||||
if (resultConfig is Error) {
|
||||
_log.warning(
|
||||
'Failed to load stored ItineraryConfig',
|
||||
resultConfig.asError.error,
|
||||
);
|
||||
return resultConfig;
|
||||
}
|
||||
|
||||
final itineraryConfig = resultConfig.asOk.value;
|
||||
final result = await _itineraryConfigRepository.setItineraryConfig(
|
||||
itineraryConfig.copyWith(activities: _selectedActivities.toList()));
|
||||
if (result is Error) {
|
||||
_log.warning(
|
||||
'Failed to store ItineraryConfig',
|
||||
result.asError.error,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../routing/routes.dart';
|
||||
import '../../core/localization/applocalization.dart';
|
||||
import '../../core/themes/dimens.dart';
|
||||
import '../../core/ui/back_button.dart';
|
||||
import '../../core/ui/home_button.dart';
|
||||
|
||||
class ActivitiesHeader extends StatelessWidget {
|
||||
const ActivitiesHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: Dimens.of(context).paddingScreenHorizontal,
|
||||
right: Dimens.of(context).paddingScreenHorizontal,
|
||||
top: Dimens.of(context).paddingScreenVertical,
|
||||
bottom: Dimens.paddingVertical,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomBackButton(
|
||||
onTap: () {
|
||||
// Navigate to ResultsScreen and edit search
|
||||
context.go(Routes.results);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
AppLocalization.of(context).activities,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const HomeButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/themes/dimens.dart';
|
||||
import '../view_models/activities_viewmodel.dart';
|
||||
import 'activity_entry.dart';
|
||||
import 'activity_time_of_day.dart';
|
||||
|
||||
class ActivitiesList extends StatelessWidget {
|
||||
const ActivitiesList({
|
||||
super.key,
|
||||
required this.viewModel,
|
||||
required this.activityTimeOfDay,
|
||||
});
|
||||
|
||||
final ActivitiesViewModel viewModel;
|
||||
final ActivityTimeOfDay activityTimeOfDay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = switch (activityTimeOfDay) {
|
||||
ActivityTimeOfDay.daytime => viewModel.daytimeActivities,
|
||||
ActivityTimeOfDay.evening => viewModel.eveningActivities,
|
||||
};
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: Dimens.paddingVertical,
|
||||
left: Dimens.of(context).paddingScreenHorizontal,
|
||||
right: Dimens.of(context).paddingScreenHorizontal,
|
||||
bottom: Dimens.paddingVertical,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final activity = list[index];
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: index < list.length - 1 ? 20 : 0),
|
||||
child: ActivityEntry(
|
||||
key: ValueKey(activity.ref),
|
||||
activity: activity,
|
||||
selected: viewModel.selectedActivities.contains(activity.ref),
|
||||
onChanged: (value) {
|
||||
if (value!) {
|
||||
viewModel.addActivity(activity.ref);
|
||||
} else {
|
||||
viewModel.removeActivity(activity.ref);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: list.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
compass_app/app/lib/ui/activities/widgets/activities_screen.dart
Normal file
188
compass_app/app/lib/ui/activities/widgets/activities_screen.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../routing/routes.dart';
|
||||
import '../../core/localization/applocalization.dart';
|
||||
import '../../core/themes/dimens.dart';
|
||||
import '../../core/ui/error_indicator.dart';
|
||||
import '../view_models/activities_viewmodel.dart';
|
||||
import 'activities_header.dart';
|
||||
import 'activities_list.dart';
|
||||
import 'activities_title.dart';
|
||||
import 'activity_time_of_day.dart';
|
||||
|
||||
const String confirmButtonKey = 'confirm-button';
|
||||
|
||||
class ActivitiesScreen extends StatefulWidget {
|
||||
const ActivitiesScreen({
|
||||
super.key,
|
||||
required this.viewModel,
|
||||
});
|
||||
|
||||
final ActivitiesViewModel viewModel;
|
||||
|
||||
@override
|
||||
State<ActivitiesScreen> createState() => _ActivitiesScreenState();
|
||||
}
|
||||
|
||||
class _ActivitiesScreenState extends State<ActivitiesScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.viewModel.saveActivities.addListener(_onResult);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ActivitiesScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
oldWidget.viewModel.saveActivities.removeListener(_onResult);
|
||||
widget.viewModel.saveActivities.addListener(_onResult);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.viewModel.saveActivities.removeListener(_onResult);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, r) {
|
||||
if (!didPop) context.go(Routes.results);
|
||||
},
|
||||
child: Scaffold(
|
||||
body: ListenableBuilder(
|
||||
listenable: widget.viewModel.loadActivities,
|
||||
builder: (context, child) {
|
||||
if (widget.viewModel.loadActivities.completed) {
|
||||
return child!;
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
const ActivitiesHeader(),
|
||||
if (widget.viewModel.loadActivities.running)
|
||||
const Expanded(
|
||||
child: Center(child: CircularProgressIndicator())),
|
||||
if (widget.viewModel.loadActivities.error)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ErrorIndicator(
|
||||
title: AppLocalization.of(context)
|
||||
.errorWhileLoadingActivities,
|
||||
label: AppLocalization.of(context).tryAgain,
|
||||
onPressed: widget.viewModel.loadActivities.execute,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: ListenableBuilder(
|
||||
listenable: widget.viewModel,
|
||||
builder: (context, child) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(
|
||||
child: ActivitiesHeader(),
|
||||
),
|
||||
ActivitiesTitle(
|
||||
viewModel: widget.viewModel,
|
||||
activityTimeOfDay: ActivityTimeOfDay.daytime,
|
||||
),
|
||||
ActivitiesList(
|
||||
viewModel: widget.viewModel,
|
||||
activityTimeOfDay: ActivityTimeOfDay.daytime,
|
||||
),
|
||||
ActivitiesTitle(
|
||||
viewModel: widget.viewModel,
|
||||
activityTimeOfDay: ActivityTimeOfDay.evening,
|
||||
),
|
||||
ActivitiesList(
|
||||
viewModel: widget.viewModel,
|
||||
activityTimeOfDay: ActivityTimeOfDay.evening,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomArea(viewModel: widget.viewModel),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onResult() {
|
||||
if (widget.viewModel.saveActivities.completed) {
|
||||
widget.viewModel.saveActivities.clearResult();
|
||||
context.go(Routes.booking);
|
||||
}
|
||||
|
||||
if (widget.viewModel.saveActivities.error) {
|
||||
widget.viewModel.saveActivities.clearResult();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalization.of(context).errorWhileSavingActivities),
|
||||
action: SnackBarAction(
|
||||
label: AppLocalization.of(context).tryAgain,
|
||||
onPressed: widget.viewModel.saveActivities.execute,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomArea extends StatelessWidget {
|
||||
const _BottomArea({
|
||||
required this.viewModel,
|
||||
});
|
||||
|
||||
final ActivitiesViewModel viewModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
bottom: true,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: Dimens.of(context).paddingScreenHorizontal,
|
||||
right: Dimens.of(context).paddingScreenVertical,
|
||||
top: Dimens.paddingVertical,
|
||||
bottom: Dimens.of(context).paddingScreenVertical,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalization.of(context)
|
||||
.selected(viewModel.selectedActivities.length),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key(confirmButtonKey),
|
||||
onPressed: viewModel.selectedActivities.isNotEmpty
|
||||
? viewModel.saveActivities.execute
|
||||
: null,
|
||||
child: Text(AppLocalization.of(context).confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/localization/applocalization.dart';
|
||||
import '../../core/themes/dimens.dart';
|
||||
import '../view_models/activities_viewmodel.dart';
|
||||
import 'activity_time_of_day.dart';
|
||||
|
||||
class ActivitiesTitle extends StatelessWidget {
|
||||
const ActivitiesTitle({
|
||||
super.key,
|
||||
required this.activityTimeOfDay,
|
||||
required this.viewModel,
|
||||
});
|
||||
|
||||
final ActivitiesViewModel viewModel;
|
||||
final ActivityTimeOfDay activityTimeOfDay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = switch (activityTimeOfDay) {
|
||||
ActivityTimeOfDay.daytime => viewModel.daytimeActivities,
|
||||
ActivityTimeOfDay.evening => viewModel.eveningActivities,
|
||||
};
|
||||
if (list.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: SizedBox());
|
||||
}
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
|
||||
child: Text(_label(context)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(BuildContext context) => switch (activityTimeOfDay) {
|
||||
ActivityTimeOfDay.daytime => AppLocalization.of(context).daytime,
|
||||
ActivityTimeOfDay.evening => AppLocalization.of(context).evening,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../domain/models/activity/activity.dart';
|
||||
import '../../../utils/image_error_listener.dart';
|
||||
import '../../core/ui/custom_checkbox.dart';
|
||||
|
||||
class ActivityEntry extends StatelessWidget {
|
||||
const ActivityEntry({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Activity activity;
|
||||
final bool selected;
|
||||
final ValueChanged<bool?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: activity.imageUrl,
|
||||
height: 80,
|
||||
width: 80,
|
||||
errorListener: imageErrorListener,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
activity.timeOfDay.name.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
activity.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
CustomCheckbox(
|
||||
key: ValueKey('${activity.ref}-checkbox'),
|
||||
value: selected,
|
||||
onChanged: onChanged,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2024 The Flutter team. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
enum ActivityTimeOfDay { daytime, evening }
|
||||
Reference in New Issue
Block a user