1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +00:00

Compass app (#2446)

This commit is contained in:
Eric Windmill
2024-09-27 18:49:27 -04:00
committed by GitHub
parent fcf2552cda
commit 46b5a26b26
326 changed files with 53272 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:logging/logging.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../domain/models/itinerary_config/itinerary_config.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
import 'package:flutter/cupertino.dart';
/// Results screen view model
/// Based on https://docs.flutter.dev/get-started/fwe/state-management#using-mvvm-for-your-applications-architecture
class ResultsViewModel extends ChangeNotifier {
ResultsViewModel({
required DestinationRepository destinationRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _destinationRepository = destinationRepository,
_itineraryConfigRepository = itineraryConfigRepository {
updateItineraryConfig = Command1<void, String>(_updateItineraryConfig);
search = Command0(_search)..execute();
}
final _log = Logger('ResultsViewModel');
final DestinationRepository _destinationRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
// Setters are private
List<Destination> _destinations = [];
/// List of destinations, may be empty but never null
List<Destination> get destinations => _destinations;
ItineraryConfig? _itineraryConfig;
/// Filter options to display on search bar
ItineraryConfig get config => _itineraryConfig ?? const ItineraryConfig();
/// Perform search
late final Command0 search;
/// Store ViewModel data into [ItineraryConfigRepository] before navigating.
late final Command1<void, String> updateItineraryConfig;
Future<Result<void>> _search() async {
// Load current itinerary config
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
_log.warning(
'Failed to load stored ItineraryConfig',
resultConfig.asError.error,
);
return resultConfig;
}
_itineraryConfig = resultConfig.asOk.value;
notifyListeners();
final result = await _destinationRepository.getDestinations();
switch (result) {
case Ok():
{
// If the result is Ok, update the list of destinations
_destinations = result.value
.where((destination) =>
destination.continent == _itineraryConfig!.continent)
.toList();
_log.fine('Destinations (${_destinations.length}) loaded');
}
case Error():
{
_log.warning('Failed to load destinations', result.error);
}
}
// After finish loading results, notify the view
notifyListeners();
return result;
}
Future<Result<void>> _updateItineraryConfig(String destinationRef) async {
assert(destinationRef.isNotEmpty, "destinationRef should not be empty");
final resultConfig = await _itineraryConfigRepository.getItineraryConfig();
if (resultConfig is Error) {
_log.warning(
'Failed to load stored ItineraryConfig',
resultConfig.asError.error,
);
return resultConfig;
}
final itineraryConfig = resultConfig.asOk.value;
final result = await _itineraryConfigRepository
.setItineraryConfig(itineraryConfig.copyWith(
destination: destinationRef,
activities: [],
));
if (result is Error) {
_log.warning(
'Failed to store ItineraryConfig',
result.asError.error,
);
}
return result;
}
}

View File

@@ -0,0 +1,89 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../domain/models/destination/destination.dart';
import '../../../utils/image_error_listener.dart';
import '../../core/ui/tag_chip.dart';
class ResultCard extends StatelessWidget {
const ResultCard({
super.key,
required this.destination,
required this.onTap,
});
final Destination destination;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: destination.imageUrl,
fit: BoxFit.fitHeight,
errorWidget: (context, url, error) => const Icon(Icons.error),
errorListener: imageErrorListener,
),
Positioned(
bottom: 12.0,
left: 12.0,
right: 12.0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
destination.name.toUpperCase(),
style: _cardTitleStyle,
),
const SizedBox(
height: 6,
),
Wrap(
spacing: 4.0,
runSpacing: 4.0,
direction: Axis.horizontal,
children:
destination.tags.map((e) => TagChip(tag: e)).toList(),
),
],
),
),
// Handle taps
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
),
),
),
],
),
);
}
}
final _cardTitleStyle = GoogleFonts.rubik(
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15.0,
color: Colors.white,
letterSpacing: 1,
shadows: [
// Helps to read the text a bit better
Shadow(
blurRadius: 3.0,
color: Colors.black,
)
],
),
);

View File

@@ -0,0 +1,180 @@
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../routing/routes.dart';
import '../../core/localization/applocalization.dart';
import '../../core/themes/dimens.dart';
import '../../core/ui/error_indicator.dart';
import '../../core/ui/search_bar.dart';
import '../view_models/results_viewmodel.dart';
import 'result_card.dart';
class ResultsScreen extends StatefulWidget {
const ResultsScreen({
super.key,
required this.viewModel,
});
final ResultsViewModel viewModel;
@override
State<ResultsScreen> createState() => _ResultsScreenState();
}
class _ResultsScreenState extends State<ResultsScreen> {
@override
void initState() {
super.initState();
widget.viewModel.updateItineraryConfig.addListener(_onResult);
}
@override
void didUpdateWidget(covariant ResultsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.updateItineraryConfig.removeListener(_onResult);
widget.viewModel.updateItineraryConfig.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.updateItineraryConfig.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, r) {
if (!didPop) context.go(Routes.search);
},
child: Scaffold(
body: ListenableBuilder(
listenable: widget.viewModel.search,
builder: (context, child) {
if (widget.viewModel.search.completed) {
return child!;
}
return Column(
children: [
_AppSearchBar(widget: widget),
if (widget.viewModel.search.running)
const Expanded(
child: Center(child: CircularProgressIndicator())),
if (widget.viewModel.search.error)
Expanded(
child: Center(
child: ErrorIndicator(
title: AppLocalization.of(context)
.errorWhileLoadingDestinations,
label: AppLocalization.of(context).tryAgain,
onPressed: widget.viewModel.search.execute,
),
),
),
],
);
},
child: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) {
return Padding(
padding: Dimens.of(context).edgeInsetsScreenHorizontal,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _AppSearchBar(widget: widget),
),
_Grid(viewModel: widget.viewModel),
],
),
);
},
),
),
),
);
}
void _onResult() {
if (widget.viewModel.updateItineraryConfig.completed) {
widget.viewModel.updateItineraryConfig.clearResult();
context.go(Routes.activities);
}
if (widget.viewModel.updateItineraryConfig.error) {
widget.viewModel.updateItineraryConfig.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalization.of(context).errorWhileSavingItinerary),
),
);
}
}
}
class _AppSearchBar extends StatelessWidget {
const _AppSearchBar({
required this.widget,
});
final ResultsScreen widget;
@override
Widget build(BuildContext context) {
return SafeArea(
top: true,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: Dimens.of(context).paddingScreenVertical,
bottom: Dimens.dimensMobile.paddingScreenVertical,
),
child: AppSearchBar(
config: widget.viewModel.config,
onTap: () {
// Navigate to SearchFormScreen and edit search
context.pop();
},
),
),
);
}
}
class _Grid extends StatelessWidget {
const _Grid({
required this.viewModel,
});
final ResultsViewModel viewModel;
@override
Widget build(BuildContext context) {
return SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
childAspectRatio: 182 / 222,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final destination = viewModel.destinations[index];
return ResultCard(
key: ValueKey(destination.ref),
destination: destination,
onTap: () {
viewModel.updateItineraryConfig.execute(destination.ref);
},
);
},
childCount: viewModel.destinations.length,
),
);
}
}