snapshot of https://github.com/flutter/samples.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Main Branch CI / Test Flutter beta on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter stable on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter beta on macos-latest (push) Has been cancelled
Main Branch CI / Test Flutter stable on macos-latest (push) Has been cancelled
Some checks failed
Main Branch CI / Test Flutter beta on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter stable on ubuntu-latest (push) Has been skipped
Main Branch CI / Test Flutter beta on macos-latest (push) Has been cancelled
Main Branch CI / Test Flutter stable on macos-latest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// 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/cupertino.dart';
|
||||
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';
|
||||
|
||||
/// 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();
|
||||
switch (resultConfig) {
|
||||
case Error<ItineraryConfig>():
|
||||
_log.warning(
|
||||
'Failed to load stored ItineraryConfig',
|
||||
resultConfig.error,
|
||||
);
|
||||
return resultConfig;
|
||||
case Ok<ItineraryConfig>():
|
||||
}
|
||||
_itineraryConfig = resultConfig.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();
|
||||
switch (resultConfig) {
|
||||
case Error<ItineraryConfig>():
|
||||
_log.warning(
|
||||
'Failed to load stored ItineraryConfig',
|
||||
resultConfig.error,
|
||||
);
|
||||
return resultConfig;
|
||||
case Ok<ItineraryConfig>():
|
||||
}
|
||||
|
||||
final itineraryConfig = resultConfig.value;
|
||||
final result = await _itineraryConfigRepository.setItineraryConfig(
|
||||
itineraryConfig.copyWith(destination: destinationRef, activities: []),
|
||||
);
|
||||
if (result is Error) {
|
||||
_log.warning('Failed to store ItineraryConfig', result.error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
76
compass_app/app/lib/ui/results/widgets/result_card.dart
Normal file
76
compass_app/app/lib/ui/results/widgets/result_card.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
170
compass_app/app/lib/ui/results/widgets/results_screen.dart
Normal file
170
compass_app/app/lib/ui/results/widgets/results_screen.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
// 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.mobile.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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user