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  ### 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"))
|
||||||
|
}
|