-
Notifications
You must be signed in to change notification settings - Fork 7.8k
Create compass-app first feature #2342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 26 commits
56ff720
f58fd1c
92e5de8
ce60385
92af5b6
c97b83a
431177e
df928c6
e978f4d
16fa89a
c343c82
9885024
a583b31
c6ccc23
717ab93
9736a53
aefd0d8
ef5a639
9a3940b
f5e4c94
a2bd371
cc7346a
d04c8e3
bb102a8
813ded4
ec70627
3b1bd6c
fbd136e
f63e93c
ed0da99
7319afd
51b68a3
5f1f3e5
dc9aa3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import 'package:compass_app/data/repositories/destination/destination_repository_local.dart'; | ||
| import 'package:compass_app/ui/results/business/search_destination_usecase.dart'; | ||
| import 'package:compass_app/ui/results/presentation/results_viewmodel.dart'; | ||
| import 'package:provider/provider.dart'; | ||
| import 'package:provider/single_child_widget.dart'; | ||
|
|
||
| /// Configure dependencies as a list of Providers | ||
| List<SingleChildWidget> get providers { | ||
| // These dependencies don't need to be in the widget tree (yet?) | ||
| final destinationRepository = DestinationRepositoryLocal(); | ||
| // Configure usecase to use the local data repository implementation | ||
| final searchDestinationUsecase = SearchDestinationUsecase( | ||
| repository: destinationRepository, | ||
| ); | ||
|
|
||
| // List of Providers | ||
| return [ | ||
| // ViewModels are injected into Views using Provider | ||
| ChangeNotifierProvider( | ||
| create: (_) => ResultsViewModel( | ||
| searchDestinationUsecase: searchDestinationUsecase, | ||
| ), | ||
| // create this ViewModel only when needed | ||
| lazy: true, | ||
| ), | ||
| ]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import 'package:compass_app/utils/result.dart'; | ||
| import 'package:compass_app/data/models/destination.dart'; | ||
|
|
||
| /// Data source with all possible destinations | ||
| abstract class DestinationRepository { | ||
| /// Get complete list of destinations | ||
| Future<Result<List<Destination>>> getDestinations(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import 'dart:convert'; | ||
|
|
||
| import 'package:compass_app/utils/result.dart'; | ||
| import 'package:compass_app/data/models/destination.dart'; | ||
| import 'package:compass_app/data/repositories/destination/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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import 'package:compass_app/config/dependencies.dart'; | ||
| import 'package:compass_app/ui/core/themes/theme.dart'; | ||
| import 'package:compass_app/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.theme, | ||
| routerConfig: router, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import 'package:compass_app/ui/results/presentation/results_screen.dart'; | ||
| import 'package:go_router/go_router.dart'; | ||
|
|
||
| /// Top go_router entry point | ||
| final router = GoRouter( | ||
| initialLocation: '/results', | ||
| routes: [ | ||
| GoRoute( | ||
| path: '/results', | ||
| builder: (context, state) { | ||
| return const ResultsScreen(); | ||
| }, | ||
| ), | ||
| ], | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import 'dart:ui'; | ||
|
|
||
| class AppColors { | ||
miquelbeltran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:google_fonts/google_fonts.dart'; | ||
|
|
||
| class TextStyles { | ||
| // Note: original Figma file uses Nikkei Maru | ||
| // which is not available on GoogleFonts | ||
| static final cardTitleStyle = GoogleFonts.rubik( | ||
| textStyle: const TextStyle( | ||
| fontWeight: FontWeight.w800, | ||
| fontSize: 15.0, | ||
| color: Colors.white, | ||
miquelbeltran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| letterSpacing: 1, | ||
| shadows: [ | ||
| // Helps to read the text a bit better | ||
| Shadow( | ||
| blurRadius: 3.0, | ||
| color: Colors.black, | ||
| ) | ||
| ], | ||
| ), | ||
| ); | ||
|
|
||
| // Note: original Figma file uses Google Sans | ||
| // which is not available on GoogleFonts | ||
| static final chipTagStyle = GoogleFonts.openSans( | ||
| textStyle: const TextStyle( | ||
| fontWeight: FontWeight.w500, | ||
| fontSize: 10, | ||
| color: Colors.white, | ||
miquelbeltran marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| textBaseline: TextBaseline.alphabetic, | ||
| ), | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import 'package:compass_app/ui/core/themes/colors.dart'; | ||
| import 'package:flutter/material.dart'; | ||
|
|
||
| class AppTheme { | ||
| static ThemeData theme = ThemeData( | ||
|
||
| useMaterial3: true, | ||
| colorScheme: const 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, | ||
| ), | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import 'dart:ui'; | ||
|
|
||
| import 'package:compass_app/ui/core/themes/colors.dart'; | ||
| import 'package:compass_app/ui/core/themes/text_styles.dart'; | ||
| import 'package:flutter/material.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: const BoxDecoration( | ||
| color: 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: Colors.white, | ||
| size: 10, | ||
| ), | ||
| const SizedBox(width: 4), | ||
| Text( | ||
| tag, | ||
| textAlign: TextAlign.center, | ||
| style: TextStyles.chipTagStyle, | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| 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, | ||
| }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import 'package:compass_app/utils/result.dart'; | ||
| import 'package:compass_app/data/models/destination.dart'; | ||
| import 'package:compass_app/data/repositories/destination/destination_repository.dart'; | ||
|
|
||
| /// Search Destinations Usecase | ||
miquelbeltran marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| class SearchDestinationUsecase { | ||
miquelbeltran marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| SearchDestinationUsecase({ | ||
| required DestinationRepository repository, | ||
| }) : _repository = repository; | ||
|
|
||
| final DestinationRepository _repository; | ||
|
|
||
| /// Perform search over possible destinations | ||
| /// All search filter options are optional | ||
| Future<Result<List<Destination>>> search({String? continent}) async { | ||
| bool filter(Destination destination) { | ||
| return (continent == null || destination.continent == continent); | ||
| } | ||
|
|
||
| final result = await _repository.getDestinations(); | ||
| return switch (result) { | ||
| Ok() => Result.ok(result.value.where(filter).toList()), | ||
| Error() => result, | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import 'package:compass_app/ui/core/themes/text_styles.dart'; | ||
| import 'package:compass_app/ui/core/ui/tag_chip.dart'; | ||
| import 'package:compass_app/data/models/destination.dart'; | ||
| import 'package:flutter/material.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: [ | ||
| Image.network( | ||
| destination.imageUrl, | ||
| fit: BoxFit.fitHeight, | ||
| ), | ||
| 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(), | ||
| ), | ||
| ], | ||
| ), | ||
| ) | ||
| ], | ||
| ), | ||
| ); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.