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.md
pull/2389/head
Miguel Beltran 2 months ago committed by GitHub
parent 170e10c383
commit cfedff5a5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1 +0,0 @@
include: package:flutter_lints/flutter.yaml

@ -0,0 +1,5 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
- prefer_relative_imports

File diff suppressed because it is too large Load Diff

@ -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:

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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save