diff --git a/README.md b/README.md index 6c2ea7c9..b7540672 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,69 @@ # RestauranTour -Be sure to read **all** of this document carefully, and follow the guidelines within. +PT/BR 🇧🇷 +- Projeto proposto pela equipe da Super Formula. Onde é mostrado duas telas principais. Uma tela de catálogo de restaurantes onde o usuário possui as opção de clicar ver detalhes e a tela de favoritos onde estão listados os restaurantes favoritados. -## Vendorized Flutter +English 🇺🇸 +- Project proposed by Super Formula team. Where it is shown a two main screen. Oee catalogue of restaurants where the user has the option tap and see more details and the favorites screen where are listed the favorited restaurants. -3. We use [fvm](https://fvm.app/) for managing the flutter version within the project. Using terminal, while being on the test repository, install the tools dependencies by running the following commands: +## Autor +- Guilherme Fonseca [Github](https://github.com/fonsecguilherme) e [Linkedin](https://www.linkedin.com/in/devfonsecguilherme/) - ```sh - dart pub global activate fvm - ``` +## Stack +Dart and Flutter +**Packages:** [Mocktail](https://pub.dev/packages/mocktail), [Flutter_bloc](https://pub.dev/packages/flutter_bloc), [Bloc]( https://pub.dev/packages/bloc), [Network Image Mock](https://pub.dev/packages/network_image_mock), [Equatable](https://pub.dev/packages/equatable), [Bloc test](https://pub.dev/packages/bloc_test), [GetIt](https://pub.dev/packages/get_it) - The output of the command will ask to add the folder `./pub-cache/bin` to your PATH variables, if you didn't already. If that is the case, add it to your environment variables, and restart the terminal. - - ```sh - export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables - ``` - -4. Install the project's flutter version using `fvm`. - - ```sh - fvm use - ``` - -5. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. - - ```sh - fvm flutter pub get - ``` - -More information on the approach can be found here: - -> hhttps://fvm.app/docs/getting_started/installation - -From the root directory: - - -### IDE Setup - -
-Use with VSCode -

- -If you're a VScode user link the new Flutter SDK path in your settings -`$projectRoot/.vscode/settings.json` (create if it doesn't exist yet) - -```json -{ - "dart.flutterSdkPath": ".fvm/flutter_sdk" -} -``` +## BLoc +PT/BR 🇧🇷 +- Para esse projeto, foi utilizado cubits para gerenciamento de estados e arquitetura proposta na documentação do bloc. A escolha foi baseada justamente por ser um padrão bem definido, altamente testável, com boa adoção pelo mercado e as possibilidades de ajustes finos na UI. +English 🇺🇸 +- For this project, I used cubits for state management and the architecture proposed in bloc documentation. This choice was based on the fact that bloc is a well defined standard, highly testable, well received in the market and the possibilities of precise adjustments in the UI +

+

-
-
-Use with IntelliJ / Android Studio -

+## Tests +PT/BR 🇧🇷 +- Tentei cobrir as 3 principais componentes do app: listagem dos restaurantes, tela de detalhes e tela de favoritos. Além disso também foi testes do cubit favoritos. -Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK path to `$projectRoot/.fvm/flutter_sdk` +English 🇺🇸 +- I tried to cover all 3 main componentes of the app: restaurants listing, details screen and favorites screen. Beyond that, there are also tests for favorites cubit. -IntelliJ Settings +## Video +[Video](https://drive.google.com/file/d/1MmWPcCwgEg64gvIHnNFpTsiJdiltdfQn/view?usp=share_link) +## Screenshots +* Home page Android +

+ +

-
- -## Requirements - -### App Structure - -#### Restaurant List Page - -- Tab Bar - - List of favorites (stored client side) - - List of businesses - - Hero image - - Name - - Price - - Category - - Rating (rounded to the nearest value) - - Open/Closed - -#### Restaurant Detail View - -- Ability to favorite a business -- Name -- Hero image -- Price and category -- Address -- Rating -- Total reviews -- List of reviews - - User name - - Rating - - User image - - Review Text (These are just snippets of the full review, usually like 3-4 lines long) - -#### Misc. - -- Clear documentation on the structure and architecture of your application. -- Clear and logical commit messages. - - We suggest following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - -## Test Coverage - -To demonstrate your experience writing different types of tests in Flutter please do the following: - -- Choose ONE portion of your state management and write a unit test. -- Choose ONE widget and write a widget test. - -Feel free to add more tests as you see fit but the above is the minimum requirement. - -## Design -- See this [Figma File](https://www.figma.com/file/KsEhQUp66m9yeVkvQ0hSZm/Flutter-Test?node-id=0%3A1) for design information related to the overall look and feel of the application. We do not expect pixel-perfection but would like the application to visually be close to what is specified in the Figma file. - -![List View](screenshots/listview.png) -![Detail View](screenshots/detailview.png) - -## API - -The [Yelp GraphQL API](https://www.yelp.com/developers/graphql/guides/intro) is used as the API for this Application. We have provided the boilerplate of the API requests and backing data models to save you some time. To successfully make a request to the Yelp GraphQL API, please follow these steps: - -1. Please go to https://www.yelp.com/signup and sign up for a developer account. -1. Once signed up, navigate to https://www.yelp.com/developers/v3/manage_app. -1. Create a new app by filling out the required information. -1. Once your app is created, scroll down and join the `Developer Beta`. This allows you to use the GraphQL API. -1. Copy your API Key from your app page and paste it on `line 5` [yelp_repository.dart](app/lib/yelp_repository.dart) replacing the `` with your key. -1. Run the app and tap the `Fetch Restaurants` button. If you see a log like `Fetched x restaurants` you are all set! - -## Technical Requirements - -### State Management - -Please restrict your usage of state management or dependency injection to the following options: - -1. [provider](https://pub.dev/packages/provider) -2. [Riverpod](https://pub.dev/packages/riverpod) -3. [bloc](https://pub.dev/packages/bloc) -4. [get_it](https://pub.dev/packages/get_it)/[get_it_mixins](https://pub.dev/packages/get_it_mixin) -5. [Mobx](https://pub.dev/packages/mobx) - -We ask this because this challenge values consistency and efficiency over ingenuity. Using commonly used libraries ensures that we can review your code in a timely manner and allows us to provide better feedback. - -## Coding Values - -At **Superformula** we strive to build applications that have - -- Consistent architecture -- Extensible, clean code -- Solid testing -- Good security & performance best practices - -### Clear, consistent architecture - -Approach your submission as if it were a real world app. This includes Use any libraries that you would normally choose. - -_Please note: we're interested in your code & the way you solve the problem, not how well you can use a particular library or feature._ - -### Easy to understand - -Writing boring code that is easy to follow is essential at **Superformula**. - -We're interested in your method and how you approach the problem just as much as we're interested in the end result. - -### Solid testing approach - -While the purpose of this challenge is not to gauge whether you can achieve 100% test coverage, we do seek to evaluate whether you know how & what to test. - -## Q&A +* Favorites page Android +

+ + +

-> Where should I send back the result when I'm done? +* Restaurant details Android +

+ + + +

-Please fork this repo and then send us a pull request to our repo when you think you are done. There is no deadline for this task unless otherwise noted to you directly. +* Error to fetch restaurans Android +

+ +

-> What if I have a question? -Just create a new issue in this repo and we will respond and get back to you quickly. +* Restaurant details snack bar Android +

+ + +

-## Review -The coding challenge is a take-home test upon which we'll be conducting a thorough code review once complete. The review will consist of meeting some more of our mobile engineers and giving a review of the solution you have designed. Please be prepared to share your screen and run/demo the application to the group. During this process, the engineers will be asking questions. diff --git a/android/app/build.gradle b/android/app/build.gradle index e47cb81d..1d3affa9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,10 +22,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { compileSdkVersion flutter.compileSdkVersion @@ -62,7 +59,3 @@ android { flutter { source '../..' } - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/android/build.gradle b/android/build.gradle index 24047dce..bc157bd1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.3.50' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -26,6 +13,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index bc6a58af..cfe88f69 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..7d064493 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.7.10" apply false +} + +include ":app" \ No newline at end of file diff --git a/assets/fonts/Lora-Bold.ttf b/assets/fonts/Lora-Bold.ttf new file mode 100644 index 00000000..530c9e11 Binary files /dev/null and b/assets/fonts/Lora-Bold.ttf differ diff --git a/assets/fonts/Lora-Medium.ttf b/assets/fonts/Lora-Medium.ttf new file mode 100644 index 00000000..85ca5a27 Binary files /dev/null and b/assets/fonts/Lora-Medium.ttf differ diff --git a/assets/fonts/OpenSans-Regular.ttf b/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 00000000..67803bb6 Binary files /dev/null and b/assets/fonts/OpenSans-Regular.ttf differ diff --git a/assets/fonts/OpenSans-SemiBold.ttf b/assets/fonts/OpenSans-SemiBold.ttf new file mode 100644 index 00000000..e5ab4644 Binary files /dev/null and b/assets/fonts/OpenSans-SemiBold.ttf differ diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 00000000..b7cf6ac1 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; +import 'package:restaurantour/presentation/pages/home_page.dart'; + +import 'business_logic/restaurants/restaurants_cubit.dart'; + +class App extends StatefulWidget { + const App({Key? key}) : super(key: key); + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + late YelpRepository repository; + + @override + void initState() { + super.initState(); + repository = GetIt.I(); + } + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + home: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => RestaurantsCubit(repository), + ), + BlocProvider( + create: (context) => FavoriteCubit(), + ), + ], + child: const HomePage(), + ), + ); +} diff --git a/lib/business_logic/favorite/favorite_cubit.dart b/lib/business_logic/favorite/favorite_cubit.dart new file mode 100644 index 00000000..5e55cebd --- /dev/null +++ b/lib/business_logic/favorite/favorite_cubit.dart @@ -0,0 +1,77 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +import 'favorite_state.dart'; + +class FavoriteCubit extends Cubit { + FavoriteCubit() : super(FavoriteState()); + + Future favoriteRestaurant(Restaurant restaurant) async { + final newFavorites = List.from(state.favorites); + final isAlreadyFavorited = !state.favorites.any( + (element) => element.id == restaurant.id, + ); + if (restaurant.id != null) { + if (isAlreadyFavorited) { + newFavorites.add(restaurant); + emit(state.copyWith(status: FavoriteStatus.favoriteSuccess)); + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.success, + ), + ); + } else { + newFavorites.remove(restaurant); + + if (newFavorites.isNotEmpty) { + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.removed, + ), + ); + + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.success, + ), + ); + } else { + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.removed, + ), + ); + emit(state.copyWith(status: FavoriteStatus.initial)); + } + } + } else { + emit( + state.copyWith( + status: FavoriteStatus.failure, + errorMessage: + 'Could favorite this restaurant! Refresh the app to try again! ', + ), + ); + } + } + + Future loadFavorites() async { + emit(state.copyWith(status: FavoriteStatus.loading)); + if (state.favorites.isEmpty) { + emit( + state.copyWith(status: FavoriteStatus.initial), + ); + } else { + emit( + state.copyWith( + status: FavoriteStatus.success, + favorites: state.favorites, + ), + ); + } + } +} diff --git a/lib/business_logic/favorite/favorite_state.dart b/lib/business_logic/favorite/favorite_state.dart new file mode 100644 index 00000000..bab4bfd6 --- /dev/null +++ b/lib/business_logic/favorite/favorite_state.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; + +import '../../models/restaurant.dart'; + +enum FavoriteStatus { + initial, + loading, + success, + removed, + favoriteSuccess, + failure +} + +extension FavoriteStatusX on FavoriteStatus { + bool get isInitial => this == FavoriteStatus.initial; + bool get isLoading => this == FavoriteStatus.loading; + bool get isSuccess => this == FavoriteStatus.success; + bool get isRemoved => this == FavoriteStatus.removed; + bool get isFavoriteSuccess => this == FavoriteStatus.favoriteSuccess; + bool get isFailure => this == FavoriteStatus.failure; +} + +class FavoriteState { + FavoriteState({ + this.status = FavoriteStatus.initial, + this.favorites = const [], + this.errorMessage = '', + }); + + final FavoriteStatus status; + final List favorites; + final String errorMessage; + + FavoriteState copyWith({ + FavoriteStatus? status, + List? favorites, + String? errorMessage, + }) { + return FavoriteState( + status: status ?? this.status, + favorites: favorites ?? this.favorites, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + bool operator ==(covariant FavoriteState other) { + if (identical(this, other)) return true; + + return other.status == status && + listEquals(other.favorites, favorites) && + other.errorMessage == errorMessage; + } + + @override + int get hashCode => + status.hashCode ^ favorites.hashCode ^ errorMessage.hashCode; +} diff --git a/lib/business_logic/restaurants/restaurants_cubit.dart b/lib/business_logic/restaurants/restaurants_cubit.dart new file mode 100644 index 00000000..1eff6ca0 --- /dev/null +++ b/lib/business_logic/restaurants/restaurants_cubit.dart @@ -0,0 +1,45 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/repositories/yelp_repository.dart'; +import 'restaurants_state.dart'; + +class RestaurantsCubit extends Cubit { + RestaurantsCubit(this.yelpRepo) : super(RestaurantsState()); + + final YelpRepository yelpRepo; + + Future fetchRestaurants() async { + emit(state.copyWith(status: RestaurantsStatus.loading)); + + try { + final result = await yelpRepo.getRestaurants(); + + final isValidResult = result != null && + result.restaurants != null && + result.restaurants!.isNotEmpty; + + if (isValidResult) { + emit( + state.copyWith( + status: RestaurantsStatus.success, + restaurants: result!.restaurants, + ), + ); + } else { + emit( + state.copyWith( + status: RestaurantsStatus.failure, + errorMessage: 'An unexpected error occurred', + ), + ); + } + } catch (e) { + emit( + state.copyWith( + status: RestaurantsStatus.failure, + errorMessage: 'Failed to fetch restaurants: $e', + ), + ); + } + } +} diff --git a/lib/business_logic/restaurants/restaurants_state.dart b/lib/business_logic/restaurants/restaurants_state.dart new file mode 100644 index 00000000..b606a494 --- /dev/null +++ b/lib/business_logic/restaurants/restaurants_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; + +import '../../models/restaurant.dart'; + +enum RestaurantsStatus { initial, loading, success, failure } + +extension RestaurantsStatusX on RestaurantsStatus { + bool get isInitial => this == RestaurantsStatus.initial; + bool get isLoading => this == RestaurantsStatus.loading; + bool get isSuccess => this == RestaurantsStatus.success; + bool get isFailure => this == RestaurantsStatus.failure; +} + +class RestaurantsState { + RestaurantsState({ + this.status = RestaurantsStatus.initial, + this.restaurants = const [], + this.errorMessage = '', + }); + + final RestaurantsStatus status; + final List restaurants; + final String errorMessage; + + @override + bool operator ==(covariant RestaurantsState other) { + if (identical(this, other)) return true; + + return other.status == status && + listEquals(other.restaurants, restaurants) && + other.errorMessage == errorMessage; + } + + @override + int get hashCode => + status.hashCode ^ restaurants.hashCode ^ errorMessage.hashCode; + + RestaurantsState copyWith({ + RestaurantsStatus? status, + List? restaurants, + String? errorMessage, + }) { + return RestaurantsState( + status: status ?? this.status, + restaurants: restaurants ?? this.restaurants, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} diff --git a/lib/data/repositories/yelp_repository.dart b/lib/data/repositories/yelp_repository.dart new file mode 100644 index 00000000..a2f1e7f5 --- /dev/null +++ b/lib/data/repositories/yelp_repository.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class YelpRepository { + final dio = GetIt.I(); + + Future getRestaurants({int offset = 0}) async { + try { + final response = await dio.post>( + '/v3/graphql', + data: _getQuery(offset), + ); + return RestaurantQueryResult.fromJson(response.data!['data']['search']); + } catch (e) { + return null; + } + } + + String _getQuery(int offset) { + return ''' +query getRestaurants { + search(location: "Las Vegas", limit: 20, offset: $offset) { + total + business { + id + name + price + rating + photos + reviews { + id + rating + user { + id + image_url + name + } + } + categories { + title + alias + } + hours { + is_open_now + } + location { + formatted_address + } + } + } +} +'''; + } +} diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..a647d64a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,57 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:restaurantour/app.dart'; -void main() { - runApp(const Restaurantour()); -} - -class Restaurantour extends StatelessWidget { - // This widget is the root of your application. - const Restaurantour({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'RestauranTour', - theme: ThemeData( - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - home: const HomePage(), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); +import 'service_locator.dart'; - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurantour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - final yelpRepo = YelpRepository(); - - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), - ); - } +void main() { + WidgetsFlutterBinding.ensureInitialized(); + setupLocator(); + runApp(const App()); } diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart index 87c7aab5..8b738463 100644 --- a/lib/models/restaurant.dart +++ b/lib/models/restaurant.dart @@ -1,13 +1,14 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; part 'restaurant.g.dart'; @JsonSerializable() -class Category { +class Category extends Equatable { final String? alias; final String? title; - Category({ + const Category({ this.alias, this.title, }); @@ -16,10 +17,16 @@ class Category { _$CategoryFromJson(json); Map toJson() => _$CategoryToJson(this); + + @override + List get props => [ + alias, + title, + ]; } @JsonSerializable() -class Hours { +class Hours extends Equatable { @JsonKey(name: 'is_open_now') final bool? isOpenNow; @@ -30,6 +37,9 @@ class Hours { factory Hours.fromJson(Map json) => _$HoursFromJson(json); Map toJson() => _$HoursToJson(this); + + @override + List get props => [isOpenNow]; } @JsonSerializable() @@ -51,7 +61,7 @@ class User { } @JsonSerializable() -class Review { +class Review extends Equatable { final String? id; final int? rating; final User? user; @@ -65,6 +75,13 @@ class Review { factory Review.fromJson(Map json) => _$ReviewFromJson(json); Map toJson() => _$ReviewToJson(this); + + @override + List get props => [ + id, + rating, + user, + ]; } @JsonSerializable() @@ -83,7 +100,7 @@ class Location { } @JsonSerializable() -class Restaurant { +class Restaurant extends Equatable { final String? id; final String? name; final String? price; @@ -135,6 +152,19 @@ class Restaurant { } return false; } + + @override + List get props => [ + id, + name, + price, + rating, + photos, + categories, + hours, + reviews, + location, + ]; } @JsonSerializable() diff --git a/lib/presentation/pages/favorites_page.dart b/lib/presentation/pages/favorites_page.dart new file mode 100644 index 00000000..dafec06f --- /dev/null +++ b/lib/presentation/pages/favorites_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; + +import '../../models/restaurant.dart'; +import '../widgets/restaurant_card_widget.dart'; +import 'restaurants_page.dart'; + +class FavoritesPage extends StatefulWidget { + final List restaurants; + + const FavoritesPage({ + Key? key, + required this.restaurants, + }) : super(key: key); + + @override + State createState() => _FavoritesPageState(); +} + +class _FavoritesPageState extends State { + FavoriteCubit get favoriteCubit => context.read(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ListView.builder( + itemCount: widget.restaurants.length, + itemBuilder: (context, index) { + final restaurant = widget.restaurants.elementAt(index); + return RestaurantCardWidget( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: favoriteCubit, + child: RestaurantPage( + restaurant: restaurant, + ), + ), + ), + ), + restaurant: widget.restaurants[index], + ); + }, + ), + ); + } +} diff --git a/lib/presentation/pages/home_page.dart b/lib/presentation/pages/home_page.dart new file mode 100644 index 00000000..905ed751 --- /dev/null +++ b/lib/presentation/pages/home_page.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_state.dart'; +import 'package:restaurantour/business_logic/restaurants/restaurants_cubit.dart'; +import 'package:restaurantour/business_logic/restaurants/restaurants_state.dart'; +import 'package:restaurantour/presentation/pages/favorites_page.dart'; + +import '../../utils/typography/restaurantour_text_styles.dart'; +import '../widgets/restaurant_card_widget.dart'; +import 'restaurants_page.dart'; + +class HomePage extends StatefulWidget { + const HomePage({Key? key}) : super(key: key); + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State with SingleTickerProviderStateMixin { + RestaurantsCubit get cubit => context.read(); + FavoriteCubit get favoriteCubit => context.read(); + late TabController tabController; + + @override + void initState() { + tabController = TabController(length: 2, vsync: this); + super.initState(); + cubit.fetchRestaurants(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + 'RestauranTour', + style: RestaurantourTextStyles.h6, + ), + bottom: TabBar( + controller: tabController, + tabs: const [ + Tab( + child: Text( + 'All Restaurants', + textAlign: TextAlign.center, + style: RestaurantourTextStyles.body, + ), + ), + Tab( + child: Text( + 'Favorite Restaurants', + textAlign: TextAlign.center, + style: RestaurantourTextStyles.body, + ), + ), + ], + ), + ), + body: SafeArea( + child: TabBarView( + controller: tabController, + children: [ + BlocBuilder( + builder: (context, state) { + switch (state.status) { + case RestaurantsStatus.initial: + return const SizedBox.shrink( + key: Key('initial state'), + ); + case RestaurantsStatus.loading: + return const Center( + child: CircularProgressIndicator(), + ); + case RestaurantsStatus.success: + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ListView.builder( + itemCount: state.restaurants.length, + itemBuilder: (context, index) { + final restaurant = + state.restaurants.elementAt(index); + + return RestaurantCardWidget( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: favoriteCubit, + child: RestaurantPage( + restaurant: restaurant, + ), + ), + ), + ), + restaurant: state.restaurants[index], + ); + }, + ), + ); + + case RestaurantsStatus.failure: + return Center( + child: Text(state.errorMessage), + ); + } + }, + ), + BlocBuilder( + builder: (context, state) { + switch (state.status) { + case FavoriteStatus.initial: + return const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inbox, + size: 48, + ), + Text( + 'You have not added any favorite resaturants!', + ), + ], + ); + case FavoriteStatus.loading: + return const Center( + child: CircularProgressIndicator(), + ); + case FavoriteStatus.success: + return FavoritesPage( + restaurants: state.favorites, + ); + default: + return const SizedBox.shrink(); + } + }, + ), + ], + ), + ), + ); +} diff --git a/lib/presentation/pages/restaurants_page.dart b/lib/presentation/pages/restaurants_page.dart new file mode 100644 index 00000000..0133fbab --- /dev/null +++ b/lib/presentation/pages/restaurants_page.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/presentation/widgets/restaurant_rating_widget.dart'; + +import '../../utils/typography/restaurantour_text_styles.dart'; +import '../../business_logic/favorite/favorite_cubit.dart'; +import '../../business_logic/favorite/favorite_state.dart'; + +class RestaurantPage extends StatefulWidget { + final Restaurant restaurant; + + const RestaurantPage({ + Key? key, + required this.restaurant, + }) : super(key: key); + + @override + State createState() => _RestaurantPageState(); +} + +class _RestaurantPageState extends State { + FavoriteCubit get favoriteCubit => context.read(); + + void listener(BuildContext context, FavoriteState state) { + if (state.status.isFavoriteSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('You favorited this restaurant!'), + ), + ); + } else if (state.status.isRemoved) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('You unfavorited this restaurant!'), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + widget.restaurant.name ?? '', + style: RestaurantourTextStyles.h6, + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + onPressed: () => + favoriteCubit.favoriteRestaurant(widget.restaurant), + icon: BlocConsumer( + bloc: favoriteCubit, + listener: listener, + builder: (context, state) { + if (state.favorites + .any((element) => element.id == widget.restaurant.id)) { + return const Icon(Icons.favorite); + } else { + return const Icon(Icons.favorite_border); + } + }, + ), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 361, + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage( + widget.restaurant.heroImage, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 28.0, + horizontal: 24.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${widget.restaurant.price} ${widget.restaurant.displayCategory}', + style: RestaurantourTextStyles.caption, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + widget.restaurant.isOpen ? 'Open now' : 'Closed', + style: RestaurantourTextStyles.overline, + ), + const SizedBox(width: 8.0), + Container( + height: 8.0, + width: 8.0, + decoration: BoxDecoration( + color: widget.restaurant.isOpen + ? Colors.green + : Colors.red, + shape: BoxShape.circle, + ), + ), + ], + ), + ], + ), + ), + const Divider(color: Color(0xFFEEEEEE)), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 28.0, + horizontal: 24.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Addres', + style: RestaurantourTextStyles.caption, + ), + const SizedBox(height: 24.0), + Text( + widget.restaurant.location?.formattedAddress ?? '', + style: RestaurantourTextStyles.address, + ), + ], + ), + ), + const Divider(color: Color(0xFFEEEEEE)), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 28.0, + horizontal: 24.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Overall Rating', + style: RestaurantourTextStyles.caption, + ), + const SizedBox(height: 24.0), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '${widget.restaurant.rating ?? 0}', + style: RestaurantourTextStyles.rating, + ), + const Icon( + Icons.star, + size: 12, + color: Color(0xFFFFB800), + ), + ], + ), + ], + ), + ), + const Divider(color: Color(0xFFEEEEEE)), + Padding( + padding: const EdgeInsets.only( + top: 28.0, + bottom: 16, + left: 24.0, + right: 24.0, + ), + child: Text( + '${widget.restaurant.reviews?.length ?? 0} Reviews', + style: RestaurantourTextStyles.caption, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 24.0, + right: 24.0, + bottom: 16.0, + ), + child: ListView.builder( + itemCount: widget.restaurant.reviews?.length ?? 0, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) => Column( + children: [ + StarRating( + color: const Color(0xFFFFB800), + rating: widget.restaurant.reviews + ?.elementAt(index) + .rating! + .toDouble() ?? + 0, + ), + const SizedBox(height: 8.0), + const Text( + 'Review text goes here. Review text goes here. This is a review. This is a review that is 3 lines long.', + style: RestaurantourTextStyles.body, + ), + const SizedBox(height: 8.0), + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage( + widget.restaurant.reviews + ?.elementAt(index) + .user! + .imageUrl ?? + 'http://via.placeholder.com/200x150', + ), + ), + const SizedBox(width: 8.0), + Text( + widget.restaurant.reviews + ?.elementAt(index) + .user + ?.name ?? + '', + style: RestaurantourTextStyles.caption, + ), + const SizedBox(height: 16.0), + ], + ), + const Divider(color: Color(0xFFEEEEEE)), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/restaurant_card_widget.dart b/lib/presentation/widgets/restaurant_card_widget.dart new file mode 100644 index 00000000..43943674 --- /dev/null +++ b/lib/presentation/widgets/restaurant_card_widget.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/presentation/widgets/restaurant_rating_widget.dart'; + +import '../../models/restaurant.dart'; +import '../../utils/typography/restaurantour_text_styles.dart'; + +class RestaurantCardWidget extends StatelessWidget { + final Restaurant restaurant; + final VoidCallback onTap; + + const RestaurantCardWidget({ + Key? key, + required this.restaurant, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 12.0, + ), + child: InkWell( + onTap: onTap, + child: Material( + elevation: 5, + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(8.0), + height: 104, + width: 351, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all( + Radius.circular(20), + ), + ), + child: Row( + children: [ + Container( + width: 88, + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage( + restaurant.heroImage, + ), + ), + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + restaurant.name ?? '', + style: RestaurantourTextStyles.subtitle1Lora, + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + Text( + '${restaurant.price} ${restaurant.displayCategory}', + style: RestaurantourTextStyles.caption, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StarRating( + color: const Color(0xFFFFB800), + rating: restaurant.rating ?? 0.0, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + restaurant.isOpen ? 'Open now' : 'Closed', + style: RestaurantourTextStyles.overline, + ), + const SizedBox(width: 8.0), + Container( + height: 8.0, + width: 8.0, + decoration: BoxDecoration( + color: restaurant.isOpen + ? Colors.green + : Colors.red, + shape: BoxShape.circle, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/presentation/widgets/restaurant_rating_widget.dart b/lib/presentation/widgets/restaurant_rating_widget.dart new file mode 100644 index 00000000..c88713db --- /dev/null +++ b/lib/presentation/widgets/restaurant_rating_widget.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class StarRating extends StatelessWidget { + final int starCount; + final double rating; + final Color color; + + const StarRating({ + Key? key, + this.starCount = 5, + this.rating = .0, + required this.color, + }) : super(key: key); + + Widget buildStar(BuildContext context, int index) { + Icon icon; + if (index < rating.floor()) { + icon = Icon( + Icons.star, + size: 12, + color: color, + ); + } else if (index == rating.floor() && rating - rating.floor() < 0.5) { + icon = Icon( + Icons.star_border, + size: 12, + color: color, + ); + } else if (index == rating.floor() && rating - rating.floor() >= 0.5) { + icon = Icon( + Icons.star, + size: 12, + color: color, + ); + } else { + icon = Icon( + Icons.star_border, + size: 12, + color: color, + ); + } + return InkResponse( + child: icon, + ); + } + + @override + Widget build(BuildContext context) { + return Row( + children: List.generate(starCount, (index) => buildStar(context, index)), + ); + } +} diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart deleted file mode 100644 index f251d7b4..00000000 --- a/lib/repositories/yelp_repository.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:restaurantour/models/restaurant.dart'; - -const _apiKey = ''; - -class YelpRepository { - late Dio dio; - - YelpRepository({ - @visibleForTesting Dio? dio, - }) : dio = dio ?? - Dio( - BaseOptions( - baseUrl: 'https://api.yelp.com', - headers: { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }, - ), - ); - - /// Returns a response in this shape - /// { - /// "data": { - /// "search": { - /// "total": 5056, - /// "business": [ - /// { - /// "id": "faPVqws-x-5k2CQKDNtHxw", - /// "name": "Yardbird Southern Table & Bar", - /// "price": "$$", - /// "rating": 4.5, - /// "photos": [ - /// "https:///s3-media4.fl.yelpcdn.com/bphoto/_zXRdYX4r1OBfF86xKMbDw/o.jpg" - /// ], - /// "reviews": [ - /// { - /// "id": "sjZoO8wcK1NeGJFDk5i82Q", - /// "rating": 5, - /// "user": { - /// "id": "BuBCkWFNT_O2dbSnBZvpoQ", - /// "image_url": "https:///s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - /// "name": "Gina T." - /// } - /// }, - /// { - /// "id": "okpO9hfpxQXssbTZTKq9hA", - /// "rating": 5, - /// "user": { - /// "id": "0x9xu_b0Ct_6hG6jaxpztw", - /// "image_url": "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - /// "name": "Crystal L." - /// } - /// }, - /// ... - /// ] - /// } - /// } - /// - Future getRestaurants({int offset = 0}) async { - try { - final response = await dio.post>( - '/v3/graphql', - data: _getQuery(offset), - ); - return RestaurantQueryResult.fromJson(response.data!['data']['search']); - } catch (e) { - return null; - } - } - - String _getQuery(int offset) { - return ''' -query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { - total - business { - id - name - price - rating - photos - reviews { - id - rating - user { - id - image_url - name - } - } - categories { - title - alias - } - hours { - is_open_now - } - location { - formatted_address - } - } - } -} -'''; - } -} diff --git a/lib/service_locator.dart b/lib/service_locator.dart new file mode 100644 index 00000000..15d89379 --- /dev/null +++ b/lib/service_locator.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; + +final dependency = GetIt.instance; + +const _apiKey = ''; + +void setupLocator() { + dependency.registerLazySingleton( + () => YelpRepository(), + ); + + dependency.registerLazySingleton( + () => Dio( + BaseOptions( + baseUrl: 'https://api.yelp.com', + headers: { + 'Authorization': 'Bearer $_apiKey', + 'Content-Type': 'application/graphql', + }, + ), + ), + ); +} diff --git a/lib/utils/typography/restaurantour_text_styles.dart b/lib/utils/typography/restaurantour_text_styles.dart new file mode 100644 index 00000000..76f65687 --- /dev/null +++ b/lib/utils/typography/restaurantour_text_styles.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class RestaurantourTextStyles { + static const _loraFontFamily = 'Lora'; + static const _openSansFontFamily = 'OpenSans'; + + static const TextStyle h6 = TextStyle( + fontFamily: _loraFontFamily, + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.black, + ); + + static const TextStyle subtitle1Lora = TextStyle( + fontFamily: _loraFontFamily, + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black, + ); + + static const TextStyle rating = TextStyle( + fontFamily: _loraFontFamily, + fontSize: 28, + fontWeight: FontWeight.w700, + color: Colors.black, + ); + + static const TextStyle caption = TextStyle( + fontFamily: _openSansFontFamily, + fontSize: 12, + fontWeight: FontWeight.w400, + color: Colors.black, + ); + + static const TextStyle overline = TextStyle( + fontFamily: _openSansFontFamily, + fontSize: 12, + fontWeight: FontWeight.w400, + color: Colors.black, + ); + + static const TextStyle address = TextStyle( + fontFamily: _openSansFontFamily, + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black, + ); + + static const TextStyle body = TextStyle( + fontFamily: _openSansFontFamily, + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.black, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 0b052c68..0ea77869 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_test: + dependency: "direct main" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -161,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + url: "https://pub.dev" + source: hosted + version: "1.8.0" crypto: dependency: transitive description: @@ -185,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: @@ -193,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -222,6 +262,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: f0ecf6e6eb955193ca60af2d5ca39565a86b8a142452c5b24d96fb477428f4d2 + url: "https://pub.dev" + source: hosted + version: "8.1.5" flutter_lints: dependency: "direct dev" description: @@ -251,6 +299,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" glob: dependency: transitive description: @@ -295,10 +351,10 @@ packages: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -315,6 +371,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: @@ -335,26 +415,26 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.0" mime: dependency: transitive description: @@ -363,6 +443,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + mockito: + dependency: transitive + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" + mocktail: + dependency: "direct main" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + network_image_mock: + dependency: "direct main" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -375,10 +495,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_parsing: dependency: transitive description: @@ -391,10 +511,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "5.4.0" pool: dependency: transitive description: @@ -403,6 +523,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: @@ -427,6 +555,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" shelf_web_socket: dependency: transitive description: @@ -456,6 +600,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -504,14 +664,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" test_api: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" timing: dependency: transitive description: @@ -560,22 +736,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - watcher: + vm_service: dependency: transitive description: - name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "1.0.1" - web: + version: "14.2.1" + watcher: dependency: transitive description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + name: watcher + sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.0.1" web_socket_channel: dependency: transitive description: @@ -584,14 +760,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" xml: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.3.0" yaml: dependency: transitive description: @@ -601,5 +785,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.7.0-0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..9b587151 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,13 @@ dependencies: dio: ^5.4.0 json_annotation: ^4.8.1 flutter_svg: ^2.0.9 + bloc: ^8.1.4 + flutter_bloc: ^8.1.5 + mocktail: ^1.0.4 + bloc_test: ^9.1.7 + network_image_mock: ^2.1.1 + equatable: ^2.0.5 + get_it: ^7.7.0 dev_dependencies: flutter_test: diff --git a/test/business_logic/favorite/favorite_cubit_test.dart b/test/business_logic/favorite/favorite_cubit_test.dart new file mode 100644 index 00000000..3f1866a1 --- /dev/null +++ b/test/business_logic/favorite/favorite_cubit_test.dart @@ -0,0 +1,153 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_state.dart'; + +class FakeRestaurant extends Fake implements Restaurant {} + +Restaurant _restaurant = Restaurant( + categories: const [Category(title: 'Italiano'), Category(title: 'Mexicano')], + photos: const [ + "https://s3-media2.fl.yelpcdn.com/bphoto/FxmtjuzPDiL7vx5KyceWuQ/o.jpg", + ], + hours: const [Hours(isOpenNow: true)], + location: Location(formattedAddress: 'casa doparaguai'), + id: '1', + name: 'boteco da bruxa', + price: 'bem caro', + rating: 4.4, + reviews: const [ + Review(id: '1234', rating: 4, user: User(name: 'Guilherme')), + ], +); + +void main() { + late FavoriteCubit favoriteCubit; + + setUp(() { + favoriteCubit = FavoriteCubit(); + registerFallbackValue(FakeRestaurant); + }); + + tearDown(() { + favoriteCubit.close(); + }); + + tearDownAll(() { + favoriteCubit.close(); + }); + + group('Favorite Restaurant function test', () { + blocTest( + 'Should emit favoriteSuccess and Success when an address is added to empty list ', + build: () => favoriteCubit, + act: (cubit) => cubit.favoriteRestaurant(_restaurant), + expect: () => [ + FavoriteState(status: FavoriteStatus.favoriteSuccess), + FavoriteState(status: FavoriteStatus.success, favorites: [_restaurant]), + ], + ); + blocTest( + 'When removing last address should emit an empty list and removed and initial states ', + build: () => favoriteCubit, + seed: () => FavoriteState(favorites: [_restaurant]), + act: (cubit) => favoriteCubit.favoriteRestaurant(_restaurant), + expect: () => [ + isA() + .having((f) => f.status, 'status', FavoriteStatus.removed) + .having((f) => f.favorites, 'favorites', []), + FavoriteState(status: FavoriteStatus.initial), + ], + ); + + blocTest( + 'Should remove an already added address ', + build: () => favoriteCubit, + seed: () => FavoriteState( + favorites: [ + const Restaurant(id: '1', name: 'Breaking bad'), + const Restaurant(id: '2', name: 'Better call saul'), + ], + ), + act: (cubit) { + const restaurant = Restaurant(id: '1', name: 'Breaking bad'); + + cubit.favoriteRestaurant(restaurant); + + return cubit; + }, + expect: () => [ + isA() + .having((f) => f.status, 'status', FavoriteStatus.removed) + .having( + (f) => f.favorites, + 'favorites', + const [Restaurant(id: '2', name: 'Better call saul')], + ), + isA() + .having((f) => f.status, 'status', FavoriteStatus.success) + .having( + (f) => f.favorites, + 'favorites', + const [Restaurant(id: '2', name: 'Better call saul')], + ), + ], + ); + + blocTest( + 'Shoudl emit failure state when restaurant has empty id.', + build: () => favoriteCubit, + act: (cubit) => cubit.favoriteRestaurant(const Restaurant(id: null)), + expect: () => [ + FavoriteState( + status: FavoriteStatus.failure, + errorMessage: + 'Could favorite this restaurant! Refresh the app to try again! ', + ), + ], + ); + }); + + group('Load favorites Restaurants test', () { + blocTest( + 'Emit inital state when user not favorited any', + build: () => favoriteCubit, + act: (cubit) => cubit.loadFavorites(), + expect: () => [ + FavoriteState(status: FavoriteStatus.loading), + FavoriteState(status: FavoriteStatus.initial), + ], + ); + }); + + blocTest( + 'Emit success state when user not favorited any', + seed: () => FavoriteState( + favorites: [ + const Restaurant(id: '1', name: 'Better call saul'), + ], + ), + build: () => favoriteCubit, + act: (cubit) => cubit.loadFavorites(), + expect: () => [ + isA().having( + (f) => f.status, + 'status', + FavoriteStatus.loading, + ), + isA() + .having( + (f) => f.status, + 'status', + FavoriteStatus.success, + ) + .having( + (f) => f.favorites.first, + 'favorites', + const Restaurant(id: '1', name: 'Better call saul'), + ), + ], + ); +} diff --git a/test/presentation/pages/favorites_page_test.dart b/test/presentation/pages/favorites_page_test.dart new file mode 100644 index 00000000..dedad82c --- /dev/null +++ b/test/presentation/pages/favorites_page_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_state.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/presentation/pages/favorites_page.dart'; +import 'package:restaurantour/presentation/widgets/restaurant_card_widget.dart'; + +class MockFavoriteCubit extends MockCubit + implements FavoriteCubit {} + +late FavoriteCubit favoriteCubit; + +void main() { + setUp(() { + WidgetsFlutterBinding.ensureInitialized(); + () => HttpOverrides.global = null; + favoriteCubit = MockFavoriteCubit(); + }); + + tearDown( + () { + favoriteCubit.close(); + }, + ); + + testWidgets('Find one favorited restaurant', (tester) async { + when(() => favoriteCubit.loadFavorites()).thenAnswer( + (_) async => Future.value(), + ); + + when(() => favoriteCubit.state).thenReturn( + FavoriteState( + status: FavoriteStatus.success, + favorites: [_restaurant], + ), + ); + + await mockNetworkImagesFor(() => _createWidget(tester, [_restaurant])); + + await tester.pumpAndSettle(); + + expect(find.byType(RestaurantCardWidget), findsOneWidget); + }); + + testWidgets('Find two favorited restaurant', (tester) async { + when(() => favoriteCubit.loadFavorites()).thenAnswer( + (_) async => Future.value(), + ); + + when(() => favoriteCubit.state).thenReturn( + FavoriteState( + status: FavoriteStatus.success, + favorites: _restaurantList, + ), + ); + + await mockNetworkImagesFor(() => _createWidget(tester, _restaurantList)); + + await tester.pumpAndSettle(); + + expect(find.byType(RestaurantCardWidget), findsNWidgets(2)); + }); +} + +Future _createWidget( + WidgetTester tester, + List restaurants, +) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: favoriteCubit, + child: FavoritesPage( + restaurants: restaurants, + ), + ), + ), + ), + ); +} + +Restaurant _restaurant = const Restaurant(id: '1', name: 'Pollos hermanos'); +List _restaurantList = [ + const Restaurant(id: '1', name: 'Pollos hermanos'), + const Restaurant(id: '2', name: 'Pizza Planet'), +]; diff --git a/test/presentation/pages/home_page_test.dart b/test/presentation/pages/home_page_test.dart new file mode 100644 index 00000000..3e0cd755 --- /dev/null +++ b/test/presentation/pages/home_page_test.dart @@ -0,0 +1,78 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; +import 'package:restaurantour/business_logic/restaurants/restaurants_cubit.dart'; +import 'package:restaurantour/business_logic/restaurants/restaurants_state.dart'; +import 'package:restaurantour/presentation/pages/home_page.dart'; +import 'package:restaurantour/presentation/widgets/restaurant_card_widget.dart'; + +class MockRestaurantsCubit extends MockCubit + implements RestaurantsCubit {} + +late RestaurantsCubit cubit; + +void main() { + setUp( + () => cubit = MockRestaurantsCubit(), + ); + + testWidgets('Find restaurants inital state', (tester) async { + when(() => cubit.fetchRestaurants()).thenAnswer((_) => Future.value()); + + when(() => cubit.state) + .thenReturn(RestaurantsState(status: RestaurantsStatus.initial)); + + await _createWidget(tester); + + expect(find.byKey(const Key('initial state')), findsOneWidget); + }); + + testWidgets('Find restaurants loading state', (tester) async { + when(() => cubit.fetchRestaurants()).thenAnswer((_) => Future.value()); + + when(() => cubit.state) + .thenReturn(RestaurantsState(status: RestaurantsStatus.loading)); + + await _createWidget(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('Find restaurants success state', (tester) async { + when(() => cubit.fetchRestaurants()).thenAnswer((_) => Future.value()); + + when(() => cubit.state).thenReturn( + RestaurantsState( + status: RestaurantsStatus.success, + restaurants: [const Restaurant(id: '1', name: 'POllos hermanos')], + ), + ); + + await mockNetworkImagesFor(() => _createWidget(tester)); + + expect(find.byType(RestaurantCardWidget), findsOneWidget); + }); +} + +Future _createWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: cubit, + ), + BlocProvider.value( + value: FavoriteCubit(), + ), + ], + child: const HomePage(), + ), + ), + ); +} diff --git a/test/presentation/pages/restaurants_page_test.dart b/test/presentation/pages/restaurants_page_test.dart new file mode 100644 index 00000000..0d1beabe --- /dev/null +++ b/test/presentation/pages/restaurants_page_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_cubit.dart'; +import 'package:restaurantour/business_logic/favorite/favorite_state.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/presentation/pages/restaurants_page.dart'; + +class MockFavoriteCubit extends MockCubit + implements FavoriteCubit {} + +late FavoriteCubit favoriteCubit; +void main() { + setUp(() => favoriteCubit = MockFavoriteCubit()); + + tearDown(() => favoriteCubit.close()); + + group('Snack bar tests', () { + testWidgets('Should show favorited snack bar', (tester) async { + await tester.runAsync( + () async { + final state = StreamController(); + + whenListen( + favoriteCubit, + state.stream, + initialState: FavoriteState(), + ); + + await mockNetworkImagesFor(() => _createWidget(tester)); + + state.add(FavoriteState(status: FavoriteStatus.favoriteSuccess)); + + await tester.pumpAndSettle(); + + expect(find.text('You favorited this restaurant!'), findsOneWidget); + }, + ); + }); + + testWidgets('Should show removed snack bar', (tester) async { + await tester.runAsync( + () async { + final state = StreamController(); + + whenListen( + favoriteCubit, + state.stream, + initialState: FavoriteState(), + ); + + await mockNetworkImagesFor(() => _createWidget(tester)); + + state.add(FavoriteState(status: FavoriteStatus.removed)); + + await tester.pumpAndSettle(); + + expect(find.text('You unfavorited this restaurant!'), findsOneWidget); + }, + ); + }); + }); + + testWidgets('Find page appbar and body', (tester) async { + when(() => favoriteCubit.state).thenReturn(FavoriteState()); + + await mockNetworkImagesFor(() => _createWidget(tester)); + + expect(find.byType(AppBar), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); +} + +Future _createWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: favoriteCubit, + child: RestaurantPage( + restaurant: _restaurant, + ), + ), + ), + ); +} + +Restaurant _restaurant = const Restaurant(name: 'Pollos hermanos', id: '1'); diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 83fbeae4..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:restaurantour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const Restaurantour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -}