Create compass-app first feature (#2342)
As part of the work for the compass-app / architecture examples This PR is considerably large, so apologies for that, but as it contains the first feature there is a lot of set up work involved. Could be easier to review by opening the project on the IDE. **Merge to `compass-app` not `main`** cc. @ericwindmill ### Details #### Folder structure The project follows this folder structure: - `lib/config/`: Put here any configuration files. - `lib/config/dependencies.dart`: Configures the dependency tree (i.e. Provider) - `lib/data/models/`: Data classes - `lib/data/repositories/`: Data repositories - `lib/data/services/`: Data services (e.g. network API client) - `lib/routing`: Everything related to navigation (could be moved to `common`) - `lib/ui/core/themes`: several theming classes are here: colors, text styles and the app theme. - `lib/ui/core/ui`: widget components to use across the app - `lib/ui/<feature>/view_models`: ViewModels for the feature. - `lib/ui/<feature>/widgets`: Widgets for the feature. Unit tests also follow the same structure. #### State Management Most importantly, the project uses MVVM approach using `ChangeNotifier` with the help of Provider. This could be implemented without Provider or using any other way to inject the VM into the UI classes. #### Architecture approach - Data follows a unidirectional flow from Repository -> Usecase -> ViewModel -> Widgets -> User. - The provided data Repository is using local data from the `assets` folder, an abstract class is provided to hide this implementation detail to the Usecase, and also to allow multiple implementations in the future. ### Screenshots ![image](https://github.com/flutter/samples/assets/2494376/64c08c73-1f2c-4edd-82f6-3c9065f5995f) ### Extra notes: - Moved the app code to the `app` folder. We need to create a `server` project eventually. ### TODO: - Integrate a logging framework instead of using `print()`. - Do proper error handling. - Improve image loading and caching. - Complete tests with edge-cases and errors. - Better Desktop UI. ## 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
@ -1 +0,0 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
@ -0,0 +1,5 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
- prefer_relative_imports
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 544 B |
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 442 B |
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 721 B |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 450 B |
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 282 B |
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 462 B |
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 704 B |
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 586 B |
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 762 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
@ -0,0 +1,14 @@
|
||||
import '../data/repositories/destination/destination_repository.dart';
|
||||
import '../data/repositories/destination/destination_repository_local.dart';
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Configure dependencies as a list of Providers
|
||||
List<SingleChildWidget> get providers {
|
||||
// List of Providers
|
||||
return [
|
||||
Provider.value(
|
||||
value: DestinationRepositoryLocal() as DestinationRepository,
|
||||
),
|
||||
];
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
/// Model class for Destination data
|
||||
class Destination {
|
||||
Destination({
|
||||
required this.ref,
|
||||
required this.name,
|
||||
required this.country,
|
||||
required this.continent,
|
||||
required this.knownFor,
|
||||
required this.tags,
|
||||
required this.imageUrl,
|
||||
});
|
||||
|
||||
/// e.g. 'alaska'
|
||||
final String ref;
|
||||
|
||||
/// e.g. 'Alaska'
|
||||
final String name;
|
||||
|
||||
/// e.g. 'United States'
|
||||
final String country;
|
||||
|
||||
/// e.g. 'North America'
|
||||
final String continent;
|
||||
|
||||
/// e.g. 'Alaska is a haven for outdoor enthusiasts ...'
|
||||
final String knownFor;
|
||||
|
||||
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
|
||||
final List<String> tags;
|
||||
|
||||
/// e.g. 'https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg'
|
||||
final String imageUrl;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Destination{ref: $ref, name: $name, country: $country, continent: $continent, knownFor: $knownFor, tags: $tags, imageUrl: $imageUrl}';
|
||||
}
|
||||
|
||||
factory Destination.fromJson(Map<String, dynamic> json) {
|
||||
return Destination(
|
||||
ref: json['ref'] as String,
|
||||
name: json['name'] as String,
|
||||
country: json['country'] as String,
|
||||
continent: json['continent'] as String,
|
||||
knownFor: json['knownFor'] as String,
|
||||
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
imageUrl: json['imageUrl'] as String,
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import '../../../utils/result.dart';
|
||||
import '../../models/destination.dart';
|
||||
|
||||
/// Data source with all possible destinations
|
||||
abstract class DestinationRepository {
|
||||
/// Get complete list of destinations
|
||||
Future<Result<List<Destination>>> getDestinations();
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../utils/result.dart';
|
||||
import '../../models/destination.dart';
|
||||
import 'destination_repository.dart';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
/// Local implementation of DestinationRepository
|
||||
/// Uses data from assets folder
|
||||
class DestinationRepositoryLocal implements DestinationRepository {
|
||||
/// Obtain list of destinations from local assets
|
||||
@override
|
||||
Future<Result<List<Destination>>> getDestinations() async {
|
||||
try {
|
||||
final localData = await _loadAsset();
|
||||
final list = _parse(localData);
|
||||
return Result.ok(list);
|
||||
} on Exception catch (error) {
|
||||
return Result.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _loadAsset() async {
|
||||
return await rootBundle.loadString('assets/destinations.json');
|
||||
}
|
||||
|
||||
List<Destination> _parse(String localData) {
|
||||
final parsed = (jsonDecode(localData) as List).cast<Map<String, dynamic>>();
|
||||
|
||||
return parsed
|
||||
.map<Destination>((json) => Destination.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import 'config/dependencies.dart';
|
||||
import 'ui/core/themes/theme.dart';
|
||||
import 'routing/router.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void main() {
|
||||
runApp(
|
||||
MultiProvider(
|
||||
// Loading the default providers
|
||||
// NOTE: We can load different configurations e.g. fakes
|
||||
providers: providers,
|
||||
child: const MainApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MainApp extends StatelessWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import '../ui/results/widgets/results_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../ui/results/view_models/results_viewmodel.dart';
|
||||
|
||||
/// Top go_router entry point
|
||||
final router = GoRouter(
|
||||
initialLocation: '/results',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/results',
|
||||
builder: (context, state) {
|
||||
final viewModel = ResultsViewModel(
|
||||
destinationRepository: context.read(),
|
||||
);
|
||||
return ResultsScreen(viewModel: viewModel);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
static const black1 = Color(0xFF101010);
|
||||
static const white1 = Color(0xFFFFF7FA);
|
||||
static const grey1 = Color(0xFFF2F2F2);
|
||||
static const grey2 = Color(0xFF4D4D4D);
|
||||
static const whiteTransparent =
|
||||
Color(0x4DFFFFFF); // Figma rgba(255, 255, 255, 0.3)
|
||||
static const blackTransparent =
|
||||
Color(0x4D000000); // Figma rgba(255, 255, 255, 0.3)
|
||||
|
||||
static const lightColorScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: AppColors.black1,
|
||||
onPrimary: AppColors.white1,
|
||||
secondary: AppColors.black1,
|
||||
onSecondary: AppColors.white1,
|
||||
surface: Colors.white,
|
||||
onSurface: AppColors.black1,
|
||||
error: Colors.red,
|
||||
onError: Colors.white,
|
||||
);
|
||||
|
||||
static const darkColorScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: AppColors.white1,
|
||||
onPrimary: AppColors.black1,
|
||||
secondary: AppColors.white1,
|
||||
onSecondary: AppColors.black1,
|
||||
surface: AppColors.black1,
|
||||
onSurface: Colors.white,
|
||||
error: Colors.red,
|
||||
onError: Colors.white,
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// TODO: Maybe the text styles here should be moved to the respective widgets
|
||||
class TextStyles {
|
||||
// Note: original Figma file uses Nikkei Maru
|
||||
// which is not available on GoogleFonts
|
||||
// Note: Card title theme doesn't change based on light/dark mode
|
||||
static 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,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import 'colors.dart';
|
||||
import '../ui/tag_chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData lightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
extensions: [
|
||||
TagChipTheme(
|
||||
chipColor: AppColors.whiteTransparent,
|
||||
onChipColor: Colors.white,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
static ThemeData darkTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: AppColors.darkColorScheme,
|
||||
extensions: [
|
||||
TagChipTheme(
|
||||
chipColor: AppColors.blackTransparent,
|
||||
onChipColor: Colors.white,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import '../themes/colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class TagChip extends StatelessWidget {
|
||||
const TagChip({
|
||||
super.key,
|
||||
required this.tag,
|
||||
});
|
||||
|
||||
final String tag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).extension<TagChipTheme>()?.chipColor ??
|
||||
AppColors.whiteTransparent,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 20.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_iconFrom(tag),
|
||||
color: Theme.of(context)
|
||||
.extension<TagChipTheme>()
|
||||
?.onChipColor,
|
||||
size: 10,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
tag,
|
||||
textAlign: TextAlign.center,
|
||||
style: _textStyle(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData? _iconFrom(String tag) {
|
||||
return switch (tag) {
|
||||
'Adventure sports' => Icons.kayaking_outlined,
|
||||
'Beach' => Icons.beach_access_outlined,
|
||||
'City' => Icons.location_city_outlined,
|
||||
'Cultural experiences' => Icons.museum_outlined,
|
||||
'Foodie' || 'Food tours' => Icons.restaurant,
|
||||
'Hiking' => Icons.hiking,
|
||||
'Historic' => Icons.menu_book_outlined,
|
||||
'Island' || 'Coastal' || 'Lake' || 'River' => Icons.water,
|
||||
'Luxury' => Icons.attach_money_outlined,
|
||||
'Mountain' || 'Wildlife watching' => Icons.landscape_outlined,
|
||||
'Nightlife' => Icons.local_bar_outlined,
|
||||
'Off-the-beaten-path' => Icons.do_not_step_outlined,
|
||||
'Romantic' => Icons.favorite_border_outlined,
|
||||
'Rural' => Icons.agriculture_outlined,
|
||||
'Secluded' => Icons.church_outlined,
|
||||
'Sightseeing' => Icons.attractions_outlined,
|
||||
'Skiing' => Icons.downhill_skiing_outlined,
|
||||
'Wine tasting' => Icons.wine_bar_outlined,
|
||||
'Winter destination' => Icons.ac_unit,
|
||||
_ => Icons.label_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
// Note: original Figma file uses Google Sans
|
||||
// which is not available on GoogleFonts
|
||||
_textStyle(BuildContext context) => GoogleFonts.openSans(
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).extension<TagChipTheme>()?.onChipColor ??
|
||||
Colors.white,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class TagChipTheme extends ThemeExtension<TagChipTheme> {
|
||||
final Color chipColor;
|
||||
final Color onChipColor;
|
||||
|
||||
TagChipTheme({
|
||||
required this.chipColor,
|
||||
required this.onChipColor,
|
||||
});
|
||||
|
||||
@override
|
||||
ThemeExtension<TagChipTheme> copyWith({
|
||||
Color? chipColor,
|
||||
Color? onChipColor,
|
||||
}) {
|
||||
return TagChipTheme(
|
||||
chipColor: chipColor ?? this.chipColor,
|
||||
onChipColor: onChipColor ?? this.onChipColor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ThemeExtension<TagChipTheme> lerp(
|
||||
covariant ThemeExtension<TagChipTheme> other,
|
||||
double t,
|
||||
) {
|
||||
if (other is! TagChipTheme) {
|
||||
return this;
|
||||
}
|
||||
return TagChipTheme(
|
||||
chipColor: Color.lerp(chipColor, other.chipColor, t) ?? chipColor,
|
||||
onChipColor: Color.lerp(onChipColor, other.onChipColor, t) ?? onChipColor,
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
import '../../../data/repositories/destination/destination_repository.dart';
|
||||
import '../../../utils/result.dart';
|
||||
import '../../../data/models/destination.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,
|
||||
}) : _destinationRepository = destinationRepository {
|
||||
// Preload a search result
|
||||
search(continent: 'Europe');
|
||||
}
|
||||
|
||||
final DestinationRepository _destinationRepository;
|
||||
|
||||
// Setters are private
|
||||
List<Destination> _destinations = [];
|
||||
bool _loading = false;
|
||||
String? _continent;
|
||||
|
||||
/// List of destinations, may be empty but never null
|
||||
List<Destination> get destinations => _destinations;
|
||||
|
||||
/// Loading state
|
||||
bool get loading => _loading;
|
||||
|
||||
/// Return a formatted String with all the filter options
|
||||
String get filters => _continent ?? '';
|
||||
|
||||
/// Perform search
|
||||
Future<void> search({String? continent}) async {
|
||||
// Set loading state and notify the view
|
||||
_loading = true;
|
||||
_continent = continent;
|
||||
notifyListeners();
|
||||
|
||||
final result = await _destinationRepository.getDestinations();
|
||||
// Set loading state to false
|
||||
_loading = false;
|
||||
switch (result) {
|
||||
case Ok():
|
||||
{
|
||||
// If the result is Ok, update the list of destinations
|
||||
_destinations = result.value
|
||||
.where((destination) => _filter(destination, continent))
|
||||
.toList();
|
||||
}
|
||||
case Error():
|
||||
{
|
||||
// TODO: Handle error
|
||||
// ignore: avoid_print
|
||||
print(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// After finish loading results, notify the view
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _filter(Destination destination, String? continent) {
|
||||
return (continent == null || destination.continent == continent);
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/themes/text_styles.dart';
|
||||
import '../../core/ui/tag_chip.dart';
|
||||
import '../../../data/models/destination.dart';
|
||||
|
||||
class ResultCard extends StatelessWidget {
|
||||
const ResultCard({
|
||||
super.key,
|
||||
required this.destination,
|
||||
});
|
||||
|
||||
final Destination destination;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
// TODO: Improve image loading and caching
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: destination.imageUrl,
|
||||
fit: BoxFit.fitHeight,
|
||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
destination.name.toUpperCase(),
|
||||
style: TextStyles.cardTitleStyle,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 6,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 4.0,
|
||||
direction: Axis.horizontal,
|
||||
children:
|
||||
destination.tags.map((e) => TagChip(tag: e)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
import '../../core/themes/colors.dart';
|
||||
import '../view_models/results_viewmodel.dart';
|
||||
import 'result_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ResultsScreen extends StatelessWidget {
|
||||
const ResultsScreen({
|
||||
super.key,
|
||||
required this.viewModel,
|
||||
});
|
||||
|
||||
final ResultsViewModel viewModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: ListenableBuilder(
|
||||
listenable: viewModel,
|
||||
builder: (context, child) {
|
||||
if (viewModel.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
_Search(viewModel: viewModel),
|
||||
_Grid(viewModel: viewModel),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return ResultCard(
|
||||
key: ValueKey(viewModel.destinations[index].ref),
|
||||
destination: viewModel.destinations[index],
|
||||
);
|
||||
},
|
||||
childCount: viewModel.destinations.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Search extends StatelessWidget {
|
||||
const _Search({
|
||||
required this.viewModel,
|
||||
});
|
||||
|
||||
final ResultsViewModel viewModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60, bottom: 24),
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grey1),
|
||||
borderRadius: BorderRadius.circular(16.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
viewModel.filters,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 0,
|
||||
leadingDistribution: TextLeadingDistribution.even,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
/// Utility class to wrap result data
|
||||
///
|
||||
/// Evaluate the result using a switch statement:
|
||||
/// ```dart
|
||||
/// switch (result) {
|
||||
/// case Ok(): {
|
||||
/// print(result.value);
|
||||
/// }
|
||||
/// case Error(): {
|
||||
/// print(result.error);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
sealed class Result<T> {
|
||||
const Result();
|
||||
|
||||
/// Creates an instance of Result containing a value
|
||||
factory Result.ok(T value) => Ok(value);
|
||||
|
||||
/// Create an instance of Result containing an error
|
||||
factory Result.error(Exception error) => Error(error);
|
||||
|
||||
/// Convenience method to cast to Ok
|
||||
Ok<T> get asOk => this as Ok<T>;
|
||||
|
||||
/// Convenience method to cast to Error
|
||||
Error get asError => this as Error<T>;
|
||||
}
|
||||
|
||||
/// Subclass of Result for values
|
||||
final class Ok<T> extends Result<T> {
|
||||
const Ok(this.value);
|
||||
|
||||
/// Returned value in result
|
||||
final T value;
|
||||
|
||||
@override
|
||||
String toString() => 'Result<$T>.ok($value)';
|
||||
}
|
||||
|
||||
/// Subclass of Result for errors
|
||||
final class Error<T> extends Result<T> {
|
||||
const Error(this.error);
|
||||
|
||||
/// Returned error in result
|
||||
final Exception error;
|
||||
|
||||
@override
|
||||
String toString() => 'Result<$T>.error($error)';
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import path_provider_foundation
|
||||
import sqflite
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
}
|