diff --git a/.gitignore b/.gitignore index 1be2d87..860f7ed 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,11 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +# exclude all .env files from source control +*.env +*env.g.dart + +#exclude api-key file +*api-keys.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d0f1d3..c1bf85b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,9 +5,20 @@ "version": "0.2.0", "configurations": [ { - "name": "app", + "name": "App Dev", "request": "launch", - "type": "dart" + "type": "dart", + "program": "lib/main_dev.dart" + }, + { + "name": "App Prod", + "request": "launch", + "type": "dart", + "program": "lib/main_prod.dart", + "args": [ + "--dart-define-from-file", + "api-keys.json" + ] } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f285aa4..bd4be28 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/flutter_sdk", + "dart.flutterSdkPath": "/opt/homebrew/Caskroom/flutter/3.22.2/flutter", "search.exclude": { "**/.fvm": true }, diff --git a/README.md b/README.md index 412d444..e9ac64e 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,219 @@ -# Restaurant Tour -Welcome to Superformula's Coding challenge, we are excited to see what you can build! -This take home test aims to evaluate your skills in building a Flutter application. We are looking for a well-structured and well-tested application that demonstrates your knowledge of Flutter and the Dart language. +# Project Documentation 🇺🇸 -We are not looking for pixel perfect designs, but we are looking for a well-structured application that demonstrates your skills and best practices developing a flutter application. We know there are many ways to solve a problem, and we are interested in seeing how you approach this one. If you have any questions, please don't hesitate to ask. +## Overview -Things we'll be looking on your submission: -- App structure for scalability -- Error and optional (?) handling -- Widget tree optimization -- State management -- Test coverage +This challenge was proposed by the Superformula team, and it’s an application I developed focused on restaurants. The app features two main screens: -Think of the app you'll be building as the final product, do not over engineer it for possible future features, but do not under engineer it either. We are looking for a balance. We want that the functionalities that you implement are well thought out and implemented. +### Home Page -As an example, for the favorites feature you can simply use SharedPreferences, you don't need to use a complex database solution, but we're looking for a solid shared preferences implementation. +- **Tab View**: I organized the home page into two tabs: + - **Restaurant Catalog**: Displays a list of restaurants with relevant information. + - **Favorites**: Shows the list of restaurants that user marks as favorites. +### Restaurant Page +- **Restaurant Details**: Provides detailed information about restaurants, which are obtained via the Yelp API. +- **Favorite Option**: Allows the user to add a restaurant to a favorites list. Favorited restaurants are stored locally on user device. -Be sure to read **all** of this document carefully, and follow the guidelines within. +## Development Environments -## Vendorized Flutter +The application supports two distinct environments: -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: +- **Development Environment (Dev)**: I created a mocked data json endpoint to avoid exceeding Yelp API’s daily limit. the endpoint can be accessed using [link](https://raw.githubusercontent.com/fonsecguilherme/sf_flutter_test/master/restaurants.json). + +- **Production Environment (Prod)**: Connects to the official Yelp API endpoint to retrieve real data. - ```sh - dart pub global activate fvm - ``` +## API Key Configuration - 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. +To ensure the security of the Yelp API key, I used `dart-define` along with a JSON file containing the key. This file is not included in the Git repository to protect the key from unauthorized access. - ```sh - export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables - ``` +### API Key File Structure -4. Install the project's flutter version using `fvm`. +The `api-keys.json` file should follow this structure: - ```sh - fvm use - ``` +```json +{ + "YELP_KEY": "" +} +``` -5. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. +### Security Considerations - ```sh - fvm flutter pub get - ``` +- **Key Protection**: By using `dart-define` and not include the API key file from the Git repository, I protect the key from unauthorized access. The key will not be accessible to end users upon APK decompilation. -More information on the approach can be found here: +# Project Technical Overview -> hhttps://fvm.app/docs/getting_started/installation +## Technologies and Packages -From the root directory: +### Dart and Flutter Packages +- **[Mocktail](https://pub.dev/packages/mocktail)**: A package used for creating mock objects for unit testing. +- **[Flutter_bloc](https://pub.dev/packages/flutter_bloc)**: Provides integration between Flutter and BLoC for state management. +- **[Bloc](https://pub.dev/packages/bloc)**: A library for implementing the BLoC pattern. +- **[Mocktail_image_network](https://pub.dev/packages/mocktail_image_network)**: Provides mock image responses for network image testing. +- **[Bloc_test](https://pub.dev/packages/bloc_test)**: A package used for testing BLoC events and states. +- **[GetIt](https://pub.dev/packages/get_it)**: A service locator for dependency injection. +- **[Dartz](https://pub.dev/packages/dartz)**: A library for functional programming in Dart. +- **[Shared_preferences](https://pub.dev/packages/shared_preferences)**: For local data persistence using key-value pairs. -### IDE Setup +### Key Features and Practices -
-Use with VSCode -

+- **State Management**: I utilized BLoC and Flutter BLoC for managing the state of the application. +- **Dependency Injection**: Managed through the GetIt package for efficient service location and injection. +- **API Requests**: Handled via the HTTP package for network communication. +- **Testing**: Mocktail, Bloc Test, and Mocktail Image Network for comprehensive unit and widget testing. +- **Data Persistence**: Achieved with Shared Preferences for storing key-value data locally. +- **Functional Programming**: Leveraged through the Dartz package to incorporate functional programming concepts into the application. -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) +This combination of packages and practices ensures a robust, maintainable, and testable application architecture. -```json -{ - "dart.flutterSdkPath": ".fvm/flutter_sdk" -} -``` +## BLoC +- In this project, I chose to use **cubits** for state management. My choice was motivated by several reasons: cubits is a well-defined pattern, highly testable, widely adopted in the market, and offers flexibility for fine-tuning the user interface. -

-
+## Tests -
-Use with IntelliJ / Android Studio -

+- Page tests (home, favorites, and restaurant), cubits (favorite and restaurants), and the dev repository. -Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK path to `$projectRoot/.fvm/flutter_sdk` +## App Structure -IntelliJ Settings +- **Core**: Essential components and features used throughout the app. +- **Data**: Handles communication with external sources and data management. +- **View**: Visual representation of app screens, incorporating cubits and states. +

+

-
-## Requirements +## Video + +[Video](https://drive.google.com/file/d/1zMk82eiCxKuIeOENQ8drDcDEveOictqX/view?usp=sharing) + +## Screenshots -### App Structure +App screenshots are at the end of the file. -#### 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 +## Documentação do Projeto 🇧🇷 -#### Restaurant Detail View +## Visão Geral -- 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) +Este desafio foi proposto pela equipe Superformula, e trata-se de um aplicativo que foi desenvolvido focado em restaurantes. O aplicativo possui duas telas principais: -#### Misc. +### Página Inicial -- 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/) +- **Visualização por Abas**: Organizei a página inicial em duas abas: + - **Catálogo de Restaurantes**: Exibe uma lista de restaurantes com informações relevantes. + - **Favoritos**: Mostra a lista de restaurantes que o usuário marca como favorito. -## Test Coverage +### Página do Restaurante -To demonstrate your experience writing different types of tests in Flutter please do the following: +- **Detalhes do Restaurante**: Fornece informações detalhadas sobre restaurantes, obtidas através da API do Yelp. +- **Opção de Favorito**: Permite que o usuário adicione um restaurante à lista de favoritos. Os restaurantes favoritados são armazenados localmente no dispositivo do usuário. -- We are looking to see how you write tests in Flutter. We are not looking for 100% coverage but we are looking for a good mix of unit and widget tests. -- We are specially looking for you to cover at least one file for each domain layer (interface, application, repositories, etc). +## Ambientes de Desenvolvimento -Feel free to add more tests as you see fit but the above is the minimum requirement. +O aplicativo suporta dois ambientes distintos: -## Design +- **Ambiente de Desenvolvimento (Dev)**: Criei um endpoint JSON de dados simulados para evitar exceder o limite diário da API do Yelp. O endpoint pode ser acessado usando o [link](https://raw.githubusercontent.com/fonsecguilherme/sf_flutter_test/master/restaurants.json). -- 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. +- **Ambiente de Produção (Prod)**: Conecta-se ao endpoint oficial da API do Yelp para recuperar dados reais. -![List View](screenshots/listview.png) -![Detail View](screenshots/detailview.png) +## Configuração da Chave API -## API +Para garantir a segurança da chave API do Yelp, usei `dart-define` juntamente com um arquivo JSON contendo a chave. Este arquivo não está incluído no repositório Git para proteger a chave de acesso não autorizado. -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: +### Estrutura do Arquivo da Chave API -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! +O arquivo `api-keys.json` deve seguir esta estrutura: -## Technical Requirements +```json +{ + "YELP_KEY": "" +} +``` -### State Management +### Considerações de Segurança -Please restrict your usage of state management or dependency injection to the following options: +- **Proteção da Chave**: Usando `dart-define` e não incluindo o arquivo que contém a chave API no repositório Git, proteje a chave de acesso não autorizado. A chave não será acessível aos usuários caso o apk do app seja decompilado. -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) +# Visão Técnica do Projeto -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. +## Tecnologias e Pacotes -## Coding Values +### Pacotes Dart e Flutter -At **Superformula** we strive to build applications that have +- **[Mocktail](https://pub.dev/packages/mocktail)**: Um pacote usado para criar mocks para testes unitários. +- **[Flutter_bloc](https://pub.dev/packages/flutter_bloc)**: Fornece integração entre Flutter e BLoC para gerenciamento de estado. +- **[Bloc](https://pub.dev/packages/bloc)**: Uma biblioteca para implementar o padrão BLoC. +- **[Mocktail_image_network](https://pub.dev/packages/mocktail_image_network)**: Fornece image mocks para testes de imagens de rede. +- **[Bloc_test](https://pub.dev/packages/bloc_test)**: Pacote usado para testar eventos e estados BLoC. +- **[GetIt](https://pub.dev/packages/get_it)**: Service locator para injeção de dependência. +- **[Dartz](https://pub.dev/packages/dartz)**: Uma biblioteca para programação funcional em Dart. +- **[Shared_preferences](https://pub.dev/packages/shared_preferences)**: Para persistência de dados local usando pares chave-valor. -- Consistent architecture -- Extensible, clean code -- Solid testing -- Good security & performance best practices +### Principais Recursos e Práticas -### Clear, consistent architecture +- **Gerenciamento de Estado**: Foi utilizado BLoC e Flutter BLoC para gerenciar o estado do aplicativo. +- **Injeção de Dependência**: Gerenciado através do pacote GetIt para localização e injeção de serviços eficientes. +- **Solicitações de API**: HTTP para comunicação de rede. +- **Testes**: Mocktail, Bloc Test e Mocktail Image Network para testes unitários e de widgets. +- **Persistência de Dados**: Obtida com Shared Preferences para armazenar dados chave-valor localmente. +- **Programação Funcional**: Aproveitada através do pacote Dartz para incorporar conceitos de programação funcional no aplicativo. -Approach your submission as if it were a real world app. This includes Use any libraries that you would normally choose. +Esta combinação de pacotes e práticas garante uma arquitetura de aplicativo robusta, manutenível e testável. -_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._ +## BLoC -### Easy to understand +- Neste projeto, escolhi usar **cubits** para gerenciamento de estado. Minha escolha foi motivada por vários motivos: cubits/BLoC é um padrão bem definido, altamente testável, amplamente adotado no mercado e oferece flexibilidade para ajustar a interface do usuário. -Writing boring code that is easy to follow is essential at **Superformula**. +## Testes -We're interested in your method and how you approach the problem just as much as we're interested in the end result. +- Testes de página (home, favorite e restaurant), cubits (favorit e restaurant) e o repositório dev. -### Solid testing approach +## Estrutura do Aplicativo -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. +- **Core**: Componentes e recursos essenciais usados em todo o aplicativo. +- **Data**: Lidar com a comunicação com fontes externas e gerenciamento de dados. +- **View**: Representação visual das telas do aplicativo, incorporando cubits e estados. -## Q&A +

+ +

-> Where should I send back the result when I'm done? +## Vídeo -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. +[Vídeo](https://drive.google.com/file/d/1zMk82eiCxKuIeOENQ8drDcDEveOictqX/view?usp=sharing) -> What if I have a question? +## Screenshots +* Home page Android +

+ + +

-Just create a new issue in this repo and we will respond and get back to you quickly. +* Favorites page Android +

+ + +

-## Review +* Restaurant details Android +

+ + +

-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. +* Error to fetch restaurans Android +

+ +

+ +* Restaurant details snack bar Android +

+ + +

diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..b1de52b --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/core/dependency_injection/service_locator.dart'; +import 'package:restaurant_tour/data/repositories/yelp_repository.dart'; +import 'package:restaurant_tour/data/shared_services.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; +import 'package:restaurant_tour/view/pages/home/home_page.dart'; + +import 'view/cubit/restaurants/restaurants.dart'; + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + home: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + RestaurantsCubit(dependency()), + ), + BlocProvider( + create: (context) => FavoriteCubit( + sharedServices: dependency(), + ), + ), + ], + child: const HomePage(), + ), + ); +} diff --git a/lib/core/dependency_injection/service_locator.dart b/lib/core/dependency_injection/service_locator.dart new file mode 100644 index 0000000..80e7b4a --- /dev/null +++ b/lib/core/dependency_injection/service_locator.dart @@ -0,0 +1,35 @@ +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:restaurant_tour/core/http_service/http_client.dart'; +import 'package:restaurant_tour/data/shared_services.dart'; + +import '../../data/repositories/yelp_dev_repository.dart'; +import '../../data/repositories/yelp_prod_repository.dart'; +import '../../data/repositories/yelp_repository.dart'; +import '../flavors.dart'; + +final dependency = GetIt.instance; + +void setupLocator({required Flavor flavor}) { + dependency.registerLazySingleton( + () => http.Client(), + ); + + dependency.registerLazySingleton( + () => HttpClient(dependency()), + ); + + dependency.registerLazySingleton( + () => SharedServices(), + ); + + dependency.registerLazySingleton( + () { + if (flavor == Flavor.prod) { + return YelpProdRepository(client: dependency()); + } + + return YelpDevRepository(client: dependency()); + }, + ); +} diff --git a/lib/core/flavors.dart b/lib/core/flavors.dart new file mode 100644 index 0000000..450d4be --- /dev/null +++ b/lib/core/flavors.dart @@ -0,0 +1 @@ +enum Flavor { prod, dev } diff --git a/lib/core/http_service/http_client.dart b/lib/core/http_service/http_client.dart new file mode 100644 index 0000000..3eea303 --- /dev/null +++ b/lib/core/http_service/http_client.dart @@ -0,0 +1,72 @@ +import 'package:http/http.dart' as http; + +abstract class IHttpClient { + Future post( + String url, { + Map? headers, + Object? body, + }); + + Future get( + String url, { + Map? headers, + }); +} + +class HttpResponse { + final String body; + final int statusCode; + + const HttpResponse({ + required this.body, + required this.statusCode, + }); +} + +class HttpClient implements IHttpClient { + final http.Client client; + + const HttpClient(this.client); + + @override + Future post( + String url, { + Map? headers, + Object? body, + }) async { + try { + final response = await client.post( + Uri.parse(url), + headers: headers, + body: body, + ); + + return HttpResponse( + body: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + throw Exception('An error happened: $e'); + } + } + + @override + Future get( + String url, { + Map? headers, + }) async { + try { + final response = await client.get( + Uri.parse(url), + headers: headers, + ); + + return HttpResponse( + body: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + throw Exception('An error happened: $e'); + } + } +} diff --git a/lib/query.dart b/lib/core/query.dart similarity index 100% rename from lib/query.dart rename to lib/core/query.dart diff --git a/lib/typography.dart b/lib/core/utils/typography.dart similarity index 100% rename from lib/typography.dart rename to lib/core/utils/typography.dart diff --git a/lib/models/restaurant.dart b/lib/data/models/restaurant.dart similarity index 100% rename from lib/models/restaurant.dart rename to lib/data/models/restaurant.dart diff --git a/lib/models/restaurant.g.dart b/lib/data/models/restaurant.g.dart similarity index 95% rename from lib/models/restaurant.g.dart rename to lib/data/models/restaurant.g.dart index 3ed33f9..dea6677 100644 --- a/lib/models/restaurant.g.dart +++ b/lib/data/models/restaurant.g.dart @@ -38,15 +38,17 @@ Map _$UserToJson(User instance) => { Review _$ReviewFromJson(Map json) => Review( id: json['id'] as String?, - rating: json['rating'] as int?, + rating: (json['rating'] as num?)?.toInt(), user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + text: json['text'] as String?, ); Map _$ReviewToJson(Review instance) => { 'id': instance.id, 'rating': instance.rating, + 'text': instance.text, 'user': instance.user, }; @@ -95,7 +97,7 @@ Map _$RestaurantToJson(Restaurant instance) => RestaurantQueryResult _$RestaurantQueryResultFromJson( Map json) => RestaurantQueryResult( - total: json['total'] as int?, + total: (json['total'] as num?)?.toInt(), restaurants: (json['business'] as List?) ?.map((e) => Restaurant.fromJson(e as Map)) .toList(), diff --git a/lib/data/repositories/yelp_dev_repository.dart b/lib/data/repositories/yelp_dev_repository.dart new file mode 100644 index 0000000..ad8e35f --- /dev/null +++ b/lib/data/repositories/yelp_dev_repository.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:dartz/dartz.dart'; +import 'package:restaurant_tour/core/http_service/http_client.dart'; +import 'package:restaurant_tour/data/repositories/yelp_repository.dart'; + +import '../models/restaurant.dart'; + +class YelpDevRepository implements YelpRepository { + final IHttpClient client; + + YelpDevRepository({required this.client}); + + @override + Future> getRestaurants({int offset = 0}) async { + const baseUrl = + 'https://raw.githubusercontent.com/fonsecguilherme/sf_flutter_test/master/restaurants.json'; + + try { + final response = await client.get(baseUrl); + + if (response.statusCode == 200) { + return Some( + RestaurantQueryResult.fromJson( + jsonDecode(response.body)['data']['search'], + ), + ); + } else { + print('Failed to load restaurants: ${response.statusCode}'); + return const None(); + } + } catch (e) { + print('Error fetching restaurants: $e'); + return const None(); + } + } +} diff --git a/lib/data/repositories/yelp_prod_repository.dart b/lib/data/repositories/yelp_prod_repository.dart new file mode 100644 index 0000000..3240c5b --- /dev/null +++ b/lib/data/repositories/yelp_prod_repository.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:dartz/dartz.dart'; +import 'package:restaurant_tour/core/http_service/http_client.dart'; +import 'package:restaurant_tour/data/repositories/yelp_repository.dart'; + +import '../../core/query.dart'; +import '../models/restaurant.dart'; + +class YelpProdRepository implements YelpRepository { + final IHttpClient client; + + YelpProdRepository({required this.client}); + + @override + Future> getRestaurants({int offset = 0}) async { + const yelpApiKey = String.fromEnvironment('YELP_KEY'); + if (yelpApiKey.isEmpty) { + throw AssertionError('YELP KEY IS NOT SET'); + } + + final headers = { + 'Authorization': 'Bearer $yelpApiKey', + 'Content-Type': 'application/graphql', + }; + + const baseUrl = 'https://api.yelp.com/v3/graphql'; + + try { + final response = await client.post( + baseUrl, + headers: headers, + body: query(offset), + ); + if (response.statusCode == 200) { + return Some( + RestaurantQueryResult.fromJson( + jsonDecode(response.body)['data']['search'], + ), + ); + } else { + print('Failed to load restaurants: ${response.statusCode}'); + return const None(); + } + } catch (e) { + print('Error fetching restaurants: $e'); + return const None(); + } + } +} diff --git a/lib/data/repositories/yelp_repository.dart b/lib/data/repositories/yelp_repository.dart new file mode 100644 index 0000000..1957fd1 --- /dev/null +++ b/lib/data/repositories/yelp_repository.dart @@ -0,0 +1,7 @@ +import 'package:dartz/dartz.dart'; + +import '../models/restaurant.dart'; + +abstract class YelpRepository { + Future> getRestaurants({int offset = 0}); +} diff --git a/lib/data/shared_services.dart b/lib/data/shared_services.dart new file mode 100644 index 0000000..ee1618b --- /dev/null +++ b/lib/data/shared_services.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models/restaurant.dart'; + +class SharedServices { + static SharedPreferences? _preferences; + + static Future _getPreferences() async { + _preferences ??= await SharedPreferences.getInstance(); + } + + Future saveListString( + String key, + List restaurantList, + ) async { + await _getPreferences(); + + List encodedList = restaurantList + .map((restaurant) => jsonEncode(restaurant.toJson())) + .toList(); + + await _preferences!.setStringList(key, encodedList); + } + + Future> getListString(String key) async { + await _getPreferences(); + + final jsonList = _preferences!.getStringList(key) ?? []; + + return jsonList.map((e) => Restaurant.fromJson(json.decode(e))).toList(); + } +} + +class SharedPreferencesKeys { + static String savedRestaurants = 'savedRestaurants'; +} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index ae7012a..0000000 --- a/lib/main.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:restaurant_tour/models/restaurant.dart'; -import 'package:restaurant_tour/query.dart'; - -const _apiKey = ''; -const _baseUrl = 'https://api.yelp.com/v3/graphql'; - -void main() { - runApp(const RestaurantTour()); -} - -class RestaurantTour extends StatelessWidget { - const RestaurantTour({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - title: 'Restaurant Tour', - home: HomePage(), - ); - } -} - -// TODO: Architect code -// This is just a POC of the API integration -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - Future getRestaurants({int offset = 0}) async { - final headers = { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }; - - try { - final response = await http.post( - Uri.parse(_baseUrl), - headers: headers, - body: query(offset), - ); - - if (response.statusCode == 200) { - return RestaurantQueryResult.fromJson( - jsonDecode(response.body)['data']['search'], - ); - } else { - print('Failed to load restaurants: ${response.statusCode}'); - return null; - } - } catch (e) { - print('Error fetching restaurants: $e'); - return null; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurant Tour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - try { - final result = await getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/main_dev.dart b/lib/main_dev.dart new file mode 100644 index 0000000..997aa91 --- /dev/null +++ b/lib/main_dev.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/app.dart'; +import 'package:restaurant_tour/core/flavors.dart'; + +import 'core/dependency_injection/service_locator.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + setupLocator(flavor: Flavor.dev); + runApp(const App()); +} diff --git a/lib/main_prod.dart b/lib/main_prod.dart new file mode 100644 index 0000000..01c6483 --- /dev/null +++ b/lib/main_prod.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:restaurant_tour/app.dart'; +import 'package:restaurant_tour/core/flavors.dart'; + +import 'core/dependency_injection/service_locator.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + setupLocator(flavor: Flavor.prod); + runApp(const App()); +} diff --git a/lib/view/cubit/favorite/favorite.dart b/lib/view/cubit/favorite/favorite.dart new file mode 100644 index 0000000..e7a43b7 --- /dev/null +++ b/lib/view/cubit/favorite/favorite.dart @@ -0,0 +1,2 @@ +export 'favorite_cubit.dart'; +export 'favorite_state.dart'; diff --git a/lib/view/cubit/favorite/favorite_cubit.dart b/lib/view/cubit/favorite/favorite_cubit.dart new file mode 100644 index 0000000..f73aa8c --- /dev/null +++ b/lib/view/cubit/favorite/favorite_cubit.dart @@ -0,0 +1,82 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/data/shared_services.dart'; + +import '../../../data/models/restaurant.dart'; +import 'favorite_state.dart'; + +class FavoriteCubit extends Cubit { + FavoriteCubit({required this.sharedServices}) : super(FavoriteState()); + + final SharedServices sharedServices; + + Future favoriteRestaurant(Restaurant restaurant) async { + final newFavorites = List.from(state.favorites); + + final containsAddress = state.favorites.contains(restaurant); + + if (containsAddress) { + newFavorites.remove(restaurant); + + await sharedServices.saveListString( + SharedPreferencesKeys.savedRestaurants, + newFavorites, + ); + + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.removed, + ), + ); + + if (newFavorites.isEmpty) { + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.initial, + ), + ); + } + } else { + newFavorites.add(restaurant); + + await sharedServices.saveListString( + SharedPreferencesKeys.savedRestaurants, + newFavorites, + ); + + emit( + state.copyWith( + status: FavoriteStatus.favoriteSuccess, + ), + ); + emit( + state.copyWith( + favorites: newFavorites, + status: FavoriteStatus.success, + ), + ); + } + } + + Future loadRestaurants() async { + final restaurantList = await sharedServices + .getListString(SharedPreferencesKeys.savedRestaurants); + + if (restaurantList.isEmpty) { + emit( + state.copyWith( + status: FavoriteStatus.initial, + favorites: [], + ), + ); + } else { + emit( + state.copyWith( + status: FavoriteStatus.success, + favorites: restaurantList, + ), + ); + } + } +} diff --git a/lib/view/cubit/favorite/favorite_state.dart b/lib/view/cubit/favorite/favorite_state.dart new file mode 100644 index 0000000..f20c55c --- /dev/null +++ b/lib/view/cubit/favorite/favorite_state.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; + +import '../../../data/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/view/cubit/restaurants/restaurants.dart b/lib/view/cubit/restaurants/restaurants.dart new file mode 100644 index 0000000..cd1aff3 --- /dev/null +++ b/lib/view/cubit/restaurants/restaurants.dart @@ -0,0 +1,2 @@ +export 'restaurants_cubit.dart'; +export 'restaurants_state.dart'; diff --git a/lib/view/cubit/restaurants/restaurants_cubit.dart b/lib/view/cubit/restaurants/restaurants_cubit.dart new file mode 100644 index 0000000..52a5934 --- /dev/null +++ b/lib/view/cubit/restaurants/restaurants_cubit.dart @@ -0,0 +1,41 @@ +import 'package:bloc/bloc.dart'; +import 'package:restaurant_tour/view/cubit/restaurants/restaurants.dart'; + +import '../../../data/repositories/yelp_repository.dart'; + +class RestaurantsCubit extends Cubit { + RestaurantsCubit(this.yelpRepo) : super(RestaurantsState()); + + final YelpRepository yelpRepo; + + Future fetchRestaurants() async { + emit(state.copyWith(status: RestaurantsStatus.loading)); + + final result = await yelpRepo.getRestaurants(); + + emit( + result.fold( + () => state.copyWith( + status: RestaurantsStatus.failure, + errorMessage: 'An unexpected error occurred', + ), + (queryResult) { + final restaurants = queryResult.restaurants; + final isValidResult = restaurants != null && restaurants.isNotEmpty; + + if (isValidResult) { + return state.copyWith( + status: RestaurantsStatus.success, + restaurants: restaurants, + ); + } + + return state.copyWith( + status: RestaurantsStatus.failure, + errorMessage: 'Invalid restaurants', + ); + }, + ), + ); + } +} diff --git a/lib/view/cubit/restaurants/restaurants_state.dart b/lib/view/cubit/restaurants/restaurants_state.dart new file mode 100644 index 0000000..cb866b2 --- /dev/null +++ b/lib/view/cubit/restaurants/restaurants_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; + +import '../../../data/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/view/pages/favorites/favorites_page.dart b/lib/view/pages/favorites/favorites_page.dart new file mode 100644 index 0000000..2c566e2 --- /dev/null +++ b/lib/view/pages/favorites/favorites_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../data/models/restaurant.dart'; +import '../../cubit/favorite/favorite_cubit.dart'; +import '../restaurant/restaurant_page.dart'; +import '../../widgets/restaurant_card_widget.dart'; + +class FavoritesListBuilder extends StatefulWidget { + final List restaurants; + + const FavoritesListBuilder({ + super.key, + required this.restaurants, + }); + + @override + State createState() => _FavoritesListBuilderState(); +} + +class _FavoritesListBuilderState 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/view/pages/home/home_page.dart b/lib/view/pages/home/home_page.dart new file mode 100644 index 0000000..a4e2135 --- /dev/null +++ b/lib/view/pages/home/home_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/core/utils/typography.dart'; + +import '../../cubit/favorite/favorite.dart'; +import '../../cubit/restaurants/restaurants.dart'; +import 'widgets/favorites_tab_widget.dart'; +import 'widgets/restaurants_tab_widget.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.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(); + favoriteCubit.loadRestaurants(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + 'RestauranTour', + style: AppTextStyles.loraRegularHeadline, + ), + bottom: TabBar( + controller: tabController, + tabs: const [ + Tab( + child: Text( + 'All Restaurants', + textAlign: TextAlign.center, + style: AppTextStyles.openRegularTitleSemiBold, + ), + ), + Tab( + child: Text( + 'Favorite Restaurants', + textAlign: TextAlign.center, + style: AppTextStyles.openRegularTitleSemiBold, + ), + ), + ], + ), + ), + body: SafeArea( + child: TabBarView( + controller: tabController, + children: const [ + RestaurantsTabWidget(), + FavoritesTabWidget(), + ], + ), + ), + ); +} diff --git a/lib/view/pages/home/widgets/favorites_tab_widget.dart b/lib/view/pages/home/widgets/favorites_tab_widget.dart new file mode 100644 index 0000000..f81cf4d --- /dev/null +++ b/lib/view/pages/home/widgets/favorites_tab_widget.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; + +import '../../favorites/favorites_page.dart'; + +class FavoritesTabWidget extends StatefulWidget { + const FavoritesTabWidget({super.key}); + + @override + State createState() => _FavoritesTabWidgetState(); +} + +class _FavoritesTabWidgetState extends State { + @override + Widget build(BuildContext context) => + 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 || FavoriteStatus.removed: + return FavoritesListBuilder( + restaurants: state.favorites, + ); + default: + return const SizedBox.shrink(); + } + }, + ); +} diff --git a/lib/view/pages/home/widgets/restaurants_tab_widget.dart b/lib/view/pages/home/widgets/restaurants_tab_widget.dart new file mode 100644 index 0000000..560856f --- /dev/null +++ b/lib/view/pages/home/widgets/restaurants_tab_widget.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; + +import '../../../cubit/restaurants/restaurants.dart'; +import '../../../widgets/restaurant_card_widget.dart'; +import '../../restaurant/restaurant_page.dart'; + +class RestaurantsTabWidget extends StatefulWidget { + const RestaurantsTabWidget({super.key}); + + @override + State createState() => _RestaurantsTabWidgetState(); +} + +class _RestaurantsTabWidgetState extends State { + FavoriteCubit get favoriteCubit => context.read(); + + @override + Widget build(BuildContext context) => + 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), + ); + } + }, + ); +} diff --git a/lib/view/pages/restaurant/restaurant_page.dart b/lib/view/pages/restaurant/restaurant_page.dart new file mode 100644 index 0000000..ad24ce5 --- /dev/null +++ b/lib/view/pages/restaurant/restaurant_page.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../data/models/restaurant.dart'; +import '../../../core/utils/typography.dart'; +import '../../cubit/favorite/favorite.dart'; +import '../../widgets/restaurant_rating_widget.dart'; + +class RestaurantPage extends StatefulWidget { + final Restaurant restaurant; + + const RestaurantPage({ + super.key, + required this.restaurant, + }); + + @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: AppTextStyles.loraRegularHeadline, + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + onPressed: () { + favoriteCubit.favoriteRestaurant(widget.restaurant); + + favoriteCubit.loadRestaurants(); + }, + 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: AppTextStyles.openRegularText, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + widget.restaurant.isOpen ? 'Open now' : 'Closed', + style: AppTextStyles.openRegularItalic, + ), + 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: AppTextStyles.openRegularText, + ), + const SizedBox(height: 24.0), + Text( + widget.restaurant.location?.formattedAddress ?? '', + style: AppTextStyles.openRegularTitleSemiBold, + ), + ], + ), + ), + 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: AppTextStyles.openRegularText, + ), + const SizedBox(height: 24.0), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '${widget.restaurant.rating ?? 0}', + style: AppTextStyles.loraRegularHeadline.copyWith( + fontSize: 28.0, + ), + ), + 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: AppTextStyles.openRegularText, + ), + ), + 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: AppTextStyles.openRegularHeadline, + ), + 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: AppTextStyles.openRegularText, + ), + const SizedBox(height: 16.0), + ], + ), + const Divider(color: Color(0xFFEEEEEE)), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/widgets/restaurant_card_widget.dart b/lib/view/widgets/restaurant_card_widget.dart new file mode 100644 index 0000000..1592616 --- /dev/null +++ b/lib/view/widgets/restaurant_card_widget.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../../data/models/restaurant.dart'; +import '../../core/utils/typography.dart'; +import 'restaurant_rating_widget.dart'; + +class RestaurantCardWidget extends StatelessWidget { + final Restaurant restaurant; + final VoidCallback onTap; + + const RestaurantCardWidget({ + super.key, + required this.restaurant, + required this.onTap, + }); + + @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: AppTextStyles.loraRegularTitle, + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + Text( + '${restaurant.price} ${restaurant.displayCategory}', + style: AppTextStyles.openRegularText, + ), + 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: AppTextStyles.openRegularText, + ), + 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/view/widgets/restaurant_rating_widget.dart b/lib/view/widgets/restaurant_rating_widget.dart new file mode 100644 index 0000000..46f421d --- /dev/null +++ b/lib/view/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({ + super.key, + this.starCount = 5, + this.rating = .0, + required this.color, + }); + + 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/pubspec.lock b/pubspec.lock index f95a63e..52cdbf3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,31 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.7.0" args: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.5.0" async: dependency: transitive description: @@ -33,6 +38,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: @@ -45,10 +66,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -61,34 +82,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04 url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.12" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "7.2.3" + version: "7.3.2" built_collection: dependency: transitive description: @@ -101,10 +122,10 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "8.9.2" characters: dependency: transitive description: @@ -113,22 +134,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: dd007e4fb8270916820a0d66e24f619266b60773cddd082c6439341645af2659 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" clock: dependency: transitive description: @@ -157,26 +170,50 @@ packages: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + url: "https://pub.dev" + source: hosted + version: "1.9.2" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.5" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.7" + dartz: + dependency: "direct main" + description: + name: dartz + sha256: e6acf34ad2e31b1eb00948692468c30ab48ac8250e0f0df661e29f12dd252168 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" fake_async: dependency: transitive description: @@ -185,27 +222,43 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" fixnum: dependency: transitive description: name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" flutter_lints: dependency: "direct dev" description: @@ -219,30 +272,43 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.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: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" http: dependency: "direct main" description: @@ -255,34 +321,34 @@ packages: dependency: transitive description: name: http_multi_server - sha256: bfb651625e251a88804ad6d596af01ea903544757906addcb2dcdf088b5ea185 + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185 + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.7.1" json_annotation: dependency: "direct main" description: @@ -303,18 +369,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -335,10 +401,18 @@ packages: dependency: transitive description: name: logging - sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" matcher: dependency: transitive description: @@ -351,34 +425,66 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mime: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.6" + mocktail: + dependency: "direct main" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + mocktail_image_network: + dependency: "direct main" + description: + name: mocktail_image_network + sha256: a1fccbba780343517cfc552e0af2b3834d8bdb8f9f55a746c4d495ed1a8d50d6 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + 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: name: package_config - sha256: a4d5ede5ca9c3d88a2fef1147a078570c861714c806485c596b109819135bc12 + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: dependency: transitive description: @@ -387,46 +493,166 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f + url: "https://pub.dev" + source: hosted + version: "2.5.2" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" + 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: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.0" sky_engine: dependency: transitive description: flutter @@ -448,6 +674,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: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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: @@ -476,10 +718,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -496,30 +738,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + url: "https://pub.dev" + source: hosted + version: "1.25.7" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + test_core: + dependency: transitive + description: + name: test_core + sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.4" timing: dependency: transitive description: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_math: dependency: transitive description: @@ -532,18 +790,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.4" watcher: dependency: transitive description: name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web: dependency: transitive description: @@ -552,22 +810,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.1" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" yaml: dependency: transitive description: name: yaml - sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.6" + dart: ">=3.5.0-259.0.dev <4.0.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index bc8a205..798283e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,10 +11,18 @@ environment: flutter: ">=3.19.6" dependencies: + bloc: ^8.1.4 + bloc_test: ^9.1.7 + dartz: ^0.10.1 flutter: sdk: flutter + flutter_bloc: ^8.1.6 + get_it: ^7.7.0 http: ^1.2.2 json_annotation: ^4.9.0 + mocktail: ^1.0.4 + mocktail_image_network: ^1.2.0 + shared_preferences: ^2.3.2 dev_dependencies: flutter_test: diff --git a/restaurants.json b/restaurants.json new file mode 100644 index 0000000..09f956a --- /dev/null +++ b/restaurants.json @@ -0,0 +1,1245 @@ +{ + "data":{ + "search":{ + "total":7520, + "business":[ + { + "id":"vHz2RLtfUMVRPFmd7VBEHA", + "name":"Gordon Ramsay Hell's Kitchen", + "price":"$$$", + "rating":4.4, + "photos":[ + "https://s3-media2.fl.yelpcdn.com/bphoto/q771KjLzI5y638leJsnJnQ/o.jpg" + ], + "reviews":[ + { + "id":"F88H5ow44AmiwisbrbswPw", + "rating":5, + "text":"This entire experience is always so amazing. Every single dish is cooked to perfection. Every beef dish was so tender. The desserts were absolutely...", + "user":{ + "id":"y742Fi1jF_JAqq5sRUlLEw", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/rEWek1sYL0F35KZ0zRt3sw/o.jpg", + "name":"Ashley L." + } + }, + { + "id":"VJCoQlkk4Fjac0OPoRP8HQ", + "rating":5, + "text":"Me and my husband came to celebrate my birthday here and it was a 10/10 experience. Firstly, I booked the wrong area which was the Gordon Ramsay pub and...", + "user":{ + "id":"0bQNLf0POLTW4VhQZqOZoQ", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/i_0K5RUOQnoIw1c4QzHmTg/o.jpg", + "name":"Glydel L." + } + }, + { + "id":"EeCKH7eUVDsZv0Ii9wcPiQ", + "rating":5, + "text":"phenomenal! Bridgette made our experience as superb as the food coming to the table! would definitely come here again and try everything else on the menu,...", + "user":{ + "id":"gL7AGuKBW4ne93_mR168pQ", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/iU1sA7y3dEEc4iRL9LnWQQ/o.jpg", + "name":"Sydney O." + } + } + ], + "categories":[ + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Seafood", + "alias":"seafood" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3570 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id":"faPVqws-x-5k2CQKDNtHxw", + "name":"Yardbird", + "price":"$$", + "rating":4.5, + "photos":[ + "https://s3-media1.fl.yelpcdn.com/bphoto/xYJaanpF3Dl1OovhmpqAYw/o.jpg" + ], + "reviews":[ + { + "id":"CN9oD1ncHKZtsGN7U1EMnA", + "rating":5, + "text":"The food was delicious and the host and waitress were very nice, my husband and I really loved all the food, their cocktails are also amazing.", + "user":{ + "id":"HArOfrshTW9s1HhN8oz8rg", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/4sDrkYRIZxsXKCYdo9d1bQ/o.jpg", + "name":"Snow7 C." + } + }, + { + "id":"Qd-GV_v5gFHYO4VHw_6Dzw", + "rating":5, + "text":"Their Chicken and waffles are the best! I thought it was too big for one person, you had better to share it with some people", + "user":{ + "id":"ww0-zb-Nv5ccWd1Vbdmo-A", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/g-9Uqpy-lNszg0EXTuqwzQ/o.jpg", + "name":"Eri O." + } + }, + { + "id":"cqMrOWT9kRQOt3VUqOUbHg", + "rating":5, + "text":"Our last meal in Vegas was amazing at Yardbird. We have been to the Yardbird in Chicago so we thought we knew what to expect; however, we were blown away by...", + "user":{ + "id":"10oig4nwHnOAnAApdYvNrg", + "image_url":null, + "name":"Ellie K." + } + } + ], + "categories":[ + { + "title":"Southern", + "alias":"southern" + }, + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Cocktail Bars", + "alias":"cocktailbars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3355 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id":"QXV3L_QFGj8r6nWX2kS2hA", + "name":"Nacho Daddy", + "price":"$$", + "rating":4.4, + "photos":[ + "https://s3-media4.fl.yelpcdn.com/bphoto/pu9doqMplB5x5SEs8ikW6w/o.jpg" + ], + "reviews":[ + { + "id":"ZUmf3YPOAfJFmNxZ0G2sAA", + "rating":5, + "text":"First - the service is incredible here. But the food is out of this world! Not to mention the margs - You will not leave disappointed.", + "user":{ + "id":"J0MRFwpKN06MCOj9vv78dQ", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/YZpS54TUdmdcok38lZAI_Q/o.jpg", + "name":"Chris A." + } + }, + { + "id":"hBgZYMYRptmOiEur5gwMYA", + "rating":5, + "text":"The food here is very good. I enjoyed the atmosphere as well. My server Daisy was very attentive and personable.", + "user":{ + "id":"nz3l8hjtsnbrp1xcN8zk4Q", + "image_url":null, + "name":"Joe B." + } + }, + { + "id":"ksJ6G7Jwq9x6J-st2Z-ynw", + "rating":5, + "text":"Service was so fast and friendly! The nachos are truly good and kept hot by flame! Highly recommend!", + "user":{ + "id":"ZyJIBp75lHEa4Ve-J-I1Bg", + "image_url":null, + "name":"Sadie G." + } + } + ], + "categories":[ + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Mexican", + "alias":"mexican" + }, + { + "title":"Breakfast & Brunch", + "alias":"breakfast_brunch" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3663 Las Vegas Blvd\nSte 595\nLas Vegas, NV 89109" + } + }, + { + "id":"syhA1ugJpyNLaB0MiP19VA", + "name":"888 Japanese BBQ", + "price":"$$$", + "rating":4.8, + "photos":[ + "https://s3-media1.fl.yelpcdn.com/bphoto/V_zmwCUG1o_vR29xfkb-ng/o.jpg" + ], + "reviews":[ + { + "id":"S7ftRkufT8eOlmW1jpgH0A", + "rating":5, + "text":"The GOAT of Kbbq in Vegas!\nCoz yelp wanted me to type more than 85 characters so dont mind this...gnsgngenv gebg dhngdngbscgejegjfjegnfsneybgssybgsbye", + "user":{ + "id":"MYfJmm9I5u1jsMg9JearYg", + "image_url":null, + "name":"Leonard L." + } + }, + { + "id":"wFIuXMZFCrGhx6iQIW1fxg", + "rating":5, + "text":"Fantastic meet selection! Great quality of food! Definitely come back soon! The cobe beef is melting in your mouth", + "user":{ + "id":"4Wx67UxwYv3YshUQTPAgfA", + "image_url":null, + "name":"Gongliang Y." + } + }, + { + "id":"mb9gfnkSopq00f4LBZVPig", + "rating":5, + "text":"Food service and Ambiance are so high quality.povw and always come back every other week .", + "user":{ + "id":"AKEHRiPmlrwKHxiiJlLGEQ", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/GdoKcKDBW0fWQ4To-X_clA/o.jpg", + "name":"Mellon D." + } + } + ], + "categories":[ + { + "title":"Barbeque", + "alias":"bbq" + }, + { + "title":"Japanese", + "alias":"japanese" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3550 S Decatur Blvd\nLas Vegas, NV 89103" + } + }, + { + "id":"2iTsRqUsPGRH1li1WVRvKQ", + "name":"Carson Kitchen", + "price":"$$", + "rating":4.5, + "photos":[ + "https://s3-media2.fl.yelpcdn.com/bphoto/LhaPvLHIrsHu8ZMLgV04OQ/o.jpg" + ], + "reviews":[ + { + "id":"PzKQYLK6skSfAUP73P8YXQ", + "rating":5, + "text":"Our son gave his mother a birthday gift of a meal at Carson Kitchen. He's the kind of guy that does thorough reviews on everything he's interested in...", + "user":{ + "id":"Cvlm-uNVOY2i5zPWQdLupA", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/ZT4s2popID75p_yJbo1xjg/o.jpg", + "name":"Bill H." + } + }, + { + "id":"pq6VEb97OpbB-KwvsJVyfw", + "rating":4, + "text":"Came here during my most recent Vegas trip and was intrigued by the menu options! There's a parking lot close by (pay by the booth) but since I came on a...", + "user":{ + "id":"TMeT1a_1MJLOYobdY6Bs-A", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/CxCo55gIOATctXc5wLa5CQ/o.jpg", + "name":"Amy E." + } + }, + { + "id":"5LF6EKorAR01mWStVYmYBw", + "rating":4, + "text":"The service and the atmosphere were amazing! Our server was very knowledgeable about the menu and helped guide our selections. We tired five different...", + "user":{ + "id":"a71YY9h3GRv7F-4_OGGiRQ", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/3EDvhfkljrLyodxSrn8Fqg/o.jpg", + "name":"May G." + } + } + ], + "categories":[ + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Desserts", + "alias":"desserts" + }, + { + "title":"Cocktail Bars", + "alias":"cocktailbars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"124 S 6th St\nSte 100\nLas Vegas, NV 89101" + } + }, + { + "id":"JPfi__QJAaRzmfh5aOyFEw", + "name":"Shang Artisan Noodle - Flamingo Road", + "price":"$$", + "rating":4.6, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/TqV2TDWH-7Wje5B9Oh1EZw/o.jpg" + ], + "reviews":[ + { + "id":"GcGUAH0FPeyfw7rw7eu2Sg", + "rating":5, + "text":"Best beef noodle soup I've ever had. Portion sizes huge. Family of 5 could have shared 3 bowls with some appetizers. Spicy wonton and beef dumplings were...", + "user":{ + "id":"4H2AFePQc7B4LGWhGkAb2g", + "image_url":null, + "name":"AA K." + } + }, + { + "id":"T4pf_Ea3AjFUCCc5T0uc8A", + "rating":5, + "text":"Damn! Quite possibly my new favorite restaurant in Vegas and will be in my rotation of my trips in town.\n\nEverything was delicious but their speciality is...", + "user":{ + "id":"CQUDh80m48xnzUkx-X5NAw", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/R0G1VPVoe_YjmITQOOJX1A/o.jpg", + "name":"David N." + } + }, + { + "id":"fIxGDenpGq6z517SyCh7Rw", + "rating":4, + "text":"Overall 4.5. Yummy food, great atmosphere!\n\nGot there around 7:15pm and got seated right away.\n\nBeef pancake (4/5)\nSpicy wonton (4/5)\nShang fried rice...", + "user":{ + "id":"jg23eiZehaDhp-aBuYZlhg", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/GX--5VghTJVN2JtBwu7YAA/o.jpg", + "name":"Allison J." + } + } + ], + "categories":[ + { + "title":"Noodles", + "alias":"noodles" + }, + { + "title":"Chinese", + "alias":"chinese" + }, + { + "title":"Soup", + "alias":"soup" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"4983 W Flamingo Rd\nSte B\nLas Vegas, NV 89103" + } + }, + { + "id":"rdE9gg0WB7Z8kRytIMSapg", + "name":"Lazy Dog Restaurant & Bar", + "price":"$$", + "rating":4.5, + "photos":[ + "https://s3-media2.fl.yelpcdn.com/bphoto/_Wz-fNXawmbBinSf9Ev15g/o.jpg" + ], + "reviews":[ + { + "id":"la_qZrx85d4b3WkeWBdbJA", + "rating":5, + "text":"Returned to celebrate our 20th Wedding Anniversary and was best ever! Anthony F. is exceptional! His energy amazing and recommendations on the ale's is...", + "user":{ + "id":"VHG6QeWwufacGY0M1ohJ3A", + "image_url":null, + "name":"Cheryl K." + } + }, + { + "id":"BCpLW2R6MIF23ePczZ9hew", + "rating":3, + "text":"Fish & chips don't bother ordering. Bland. Burger was dry for medium rare. Pink but dry, frozen patty? Root beer & vanilla cream excellent. Dog friendly a...", + "user":{ + "id":"gsOZjtJX8i3FezAMPt4kFw", + "image_url":null, + "name":"Christopher C." + } + }, + { + "id":"n5R8ulxap3NlVvFI9Jpt7g", + "rating":5, + "text":"Amazing food. Super yummy drinks. Great deals. All around great place to bring yourself, your family, and your doggies!! Always get excellent service....", + "user":{ + "id":"mpHWQc0QfftpIJ8BK9pQlQ", + "image_url":null, + "name":"Michelle N." + } + } + ], + "categories":[ + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Comfort Food", + "alias":"comfortfood" + }, + { + "title":"Burgers", + "alias":"burgers" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"6509 S Las Vegas Blvd\nLas Vegas, NV 89119" + } + }, + { + "id":"nUpz0YiBsOK7ff9k3vUJ3A", + "name":"Buddy V's Ristorante", + "price":"$$", + "rating":4.2, + "photos":[ + "https://s3-media2.fl.yelpcdn.com/bphoto/cQxDwddn5H6c8ZGBQnjwnQ/o.jpg" + ], + "reviews":[ + { + "id":"JGb9E8nERjsNFM2F7SqCNA", + "rating":5, + "text":"Great food and great service.\nNice location.. they have outdoor and indoor seating.\nMeatballs are highly recommended!", + "user":{ + "id":"loDGoLca5JC6dARvBQCUmg", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/It7kRVx2aq3EPC9amExlPA/o.jpg", + "name":"Daniel V." + } + }, + { + "id":"vKNoy0gx2hyXABmM2sGX2A", + "rating":3, + "text":"Not impressed at all. Service was slow even though they weren't crowded. I know this is Vegas but they weren't too busy at all. The ambiance was your...", + "user":{ + "id":"dNUpq4OiK2J2185__17__A", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/qevpEGx3xWkEtDDwrzI37w/o.jpg", + "name":"Jaquita L." + } + }, + { + "id":"37kIixegf3pTb3jb6i1Y5g", + "rating":3, + "text":"Overall, the restaurant was average. The calamari was the redeeming aspect since it was one of the best I had, so make sure to get that (Hoboken style, as...", + "user":{ + "id":"IAOAGReoxWaxhZm5-EpmOg", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/YI-5O4mLRjh3-o0keMuzbA/o.jpg", + "name":"Juliet M." + } + } + ], + "categories":[ + { + "title":"Italian", + "alias":"italian" + }, + { + "title":"American", + "alias":"tradamerican" + }, + { + "title":"Wine Bars", + "alias":"wine_bars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3327 S Las Vegas Blvd\nLas Vegas, NV 89109" + } + }, + { + "id":"SAIrNOB4PtDA4gziNCucwg", + "name":"Herbs & Rye", + "price":"$$$", + "rating":4.4, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/95wd9m1E7A5Fuou1eUc3Bw/o.jpg" + ], + "reviews":[ + { + "id":"eYWs3etppqtg5qvRORwVpQ", + "rating":5, + "text":"Went for dinner tonight and our bartender, Sean, was absolutely incredible. The service was perfect, and the ribeyes were extraordinary! We will absolutely...", + "user":{ + "id":"lJjf-QPnNFZSDBIstB9_9w", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/H0qtUihKn4eXcUbp757VCw/o.jpg", + "name":"Connor W." + } + }, + { + "id":"_DJM84FO9CREfFD0yuVXLw", + "rating":5, + "text":"Always consistent with great vibe, food, service, and hospitality! Hands down one of the best in the city!", + "user":{ + "id":"jek0voQcahZGkM8V3Lh0FA", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/7td8s4dxonwE2kWMNks7aQ/o.jpg", + "name":"Ryan James C." + } + }, + { + "id":"7T3Ycz88VP7B9EmnPCewTQ", + "rating":5, + "text":"We had the best experience at Herbs and Rye. We were celebrating my Dads birthday and we treated like royalty. The service was impeccable and unobtrusive....", + "user":{ + "id":"dOOEi2Qig6jsU-lDhdtcDw", + "image_url":null, + "name":"Cynthia A." + } + } + ], + "categories":[ + { + "title":"Steakhouses", + "alias":"steak" + }, + { + "title":"Cocktail Bars", + "alias":"cocktailbars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3713 W Sahara Ave\nLas Vegas, NV 89102" + } + }, + { + "id":"gOOfBSBZlffCkQ7dr7cpdw", + "name":"CHICA", + "price":"$$", + "rating":4.3, + "photos":[ + "https://s3-media2.fl.yelpcdn.com/bphoto/FxmtjuzPDiL7vx5KyceWuQ/o.jpg" + ], + "reviews":[ + { + "id":"xXQzEfd0czYwW_PW_QW1RQ", + "rating":5, + "text":"Came here with a group of 8 for brunch and we all had a wonderful experience. Our waitress, Karena, was amazing! She was super attentive and such a good...", + "user":{ + "id":"A8wuelxCSNiuS6IFY6WKbw", + "image_url":null, + "name":"Joanna M." + } + }, + { + "id":"k0mR3x34X9bXMZfyTsO8nQ", + "rating":5, + "text":"The food was amazing. I had the Latin breakfast. Our table shared the donuts...delicious. We had drinks and they were made with fresh ingredients. They...", + "user":{ + "id":"47SO7vTL6Louu9Gbkq8UeA", + "image_url":null, + "name":"Brandi T." + } + }, + { + "id":"jG_bhu9-7aQfHjdM9kn0MA", + "rating":5, + "text":"I came to CHICA with a group of 4 for dinner on a Saturday night and it was absolutely amazing. We went during Labor Day weekend so we made sure to make...", + "user":{ + "id":"xDwRFFuIP0Kk1gXVwtJx7g", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/pUoAQbE_-tQOJ9uOLGIDFA/o.jpg", + "name":"Christie L." + } + } + ], + "categories":[ + { + "title":"Latin American", + "alias":"latin" + }, + { + "title":"Breakfast & Brunch", + "alias":"breakfast_brunch" + }, + { + "title":"Cocktail Bars", + "alias":"cocktailbars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3355 South Las Vegas Blvd\nSte 106\nLas Vegas, NV 89109" + } + }, + { + "id":"I6EDDi4-Eq_XlFghcDCUhw", + "name":"Joe's Seafood Prime Steak & Stone Crab", + "price":"$$$", + "rating":4.4, + "photos":[ + "https://s3-media1.fl.yelpcdn.com/bphoto/i5DVfdS-wOEPHBlVdw_Pvw/o.jpg" + ], + "reviews":[ + { + "id":"87zJUacg5ksnwF3-aJUo7g", + "rating":5, + "text":"100/10. Food, service and atmosphere are TOP notch. Our server Danny was the most amazing waiter we have ever experienced. He was patient, attentive and...", + "user":{ + "id":"xMmxDGs9DWhB4X1lgkERkA", + "image_url":null, + "name":"Jeff N." + } + }, + { + "id":"WYKcaMOPhZ__qqQJlI44ng", + "rating":4, + "text":"Anniversary Dinner \nFood was outstanding\nPrices were spot on\nAmbience was beautiful\nBuser was top notch\nServer needs a personality! \n\nOur server Mindy was...", + "user":{ + "id":"9m-AG--3nt_8P8lSmdWpKw", + "image_url":null, + "name":"Diane P." + } + }, + { + "id":"gR_sU8D3SvogzALreBwyQQ", + "rating":5, + "text":"So my friend and I were in Vegas a couple of weeks ago to celebrate his birthday, and he decided he wanted to go here for his birthday dinner. There's also...", + "user":{ + "id":"GkhswbL80CZnYGwaXNHMcA", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/xrLeqfrG7eu0gCAY-hFW-g/o.jpg", + "name":"Scott T." + } + } + ], + "categories":[ + { + "title":"Seafood", + "alias":"seafood" + }, + { + "title":"Steakhouses", + "alias":"steak" + }, + { + "title":"Wine Bars", + "alias":"wine_bars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3500 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id":"4JNXUYY8wbaaDmk3BPzlWw", + "name":"Mon Ami Gabi", + "price":"$$$", + "rating":4.2, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/FFhN_E1rV0txRVa6elzcZw/o.jpg" + ], + "reviews":[ + { + "id":"rAHgAhEdG0xoQspXc_6sZw", + "rating":4, + "text":"Great food and great atmosphere but I still feel that everything here in Vegas has gotten out of control with the pricing. Two salads and a pasta plate with...", + "user":{ + "id":"EE1M_Gq7uwGQhDb_v1POQQ", + "image_url":null, + "name":"Bert K." + } + }, + { + "id":"baBnM1ontpOLgoeu2xv6Wg", + "rating":5, + "text":"the breakfast was amazing, possibly the best french toast i've ever eaten. i'd love to try more items in the future, super appetizing. ate an entire french...", + "user":{ + "id":"xSvgz_-dtVa_GINcR85wzA", + "image_url":null, + "name":"Lilly H." + } + }, + { + "id":"ZlBhxy_izcFJzn34h8BwPg", + "rating":5, + "text":"I have had too many meals to count here and one this is always perfect, their gluten allergy protocol. \n\nNever felt ill after eating here. They have a...", + "user":{ + "id":"m_LEVtvivKIjIubE_7Jdhw", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/y0uCpU1HtJdr9HHq6BkI1Q/o.jpg", + "name":"Hex T." + } + } + ], + "categories":[ + { + "title":"French", + "alias":"french" + }, + { + "title":"Steakhouses", + "alias":"steak" + }, + { + "title":"Breakfast & Brunch", + "alias":"breakfast_brunch" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3655 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id":"-1m9o3vGRA8IBPNvNqKLmA", + "name":"Bavette's Steakhouse & Bar", + "price":"$$$$", + "rating":4.5, + "photos":[ + "https://s3-media2.fl.yelpcdn.com/bphoto/pgcnYRHtbw_x_-OG8K4xVg/o.jpg" + ], + "reviews":[ + { + "id":"SV29OIiCP3KLyC_8Du7Tyw", + "rating":5, + "text":"Few steaks wow me, but this one did. I've been to my share of steakhouses, and while steak is generally good anywhere that you get it, the filet mignon here...", + "user":{ + "id":"k0HPyDqzf7NuzGk9p570nw", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/9ObAXwt_jOnhmOTsf4Phsw/o.jpg", + "name":"Anh N." + } + }, + { + "id":"PbKZJlLCWVcnHLUV0AK45g", + "rating":5, + "text":"For a great dining experience look no further!\n\nBavette's has it all; delicious food, fantastic cocktails, and a service staff above them all.\n\nWe were a...", + "user":{ + "id":"IJxjNg4fMDar8WTcY_s1NQ", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/DN4xv1FYk_5yvPBhydRZGg/o.jpg", + "name":"Lisha K." + } + }, + { + "id":"Bk8AQJD8APVBWR6Y_Opvpw", + "rating":5, + "text":"First time at Bavettes and not sure what took us so long. Upon entry you feel whisked into a whole other atmosphere from the casino. The dark woods and...", + "user":{ + "id":"c1sHJlr0MizIANx49BTXWQ", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/y9JnzleHF9G9Lx6EHIu8SA/o.jpg", + "name":"Alyssa Y." + } + } + ], + "categories":[ + { + "title":"Steakhouses", + "alias":"steak" + }, + { + "title":"Bars", + "alias":"bars" + }, + { + "title":"New American", + "alias":"newamerican" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3770 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id":"7hWNnAj4VwK6FAUBN8E8lg", + "name":"Edo Gastro Tapas And Wine", + "price":"$$", + "rating":4.7, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/1TT9VdPSVZ3Fwfw8ITn5JQ/o.jpg" + ], + "reviews":[ + { + "id":"8SNBw1F5yqi8iJKwf1g1tw", + "rating":5, + "text":"Tasting menu is definitely the way to go here for the fullest experience (interestingly enough, few other tables seemed to be doing it...). The chef's...", + "user":{ + "id":"6ZEIvCcj3xCx8TNH7-R64A", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/xsROks2lA4ZUGOVkNyNPMA/o.jpg", + "name":"Brian P." + } + }, + { + "id":"CN6HmmrBduwye_1h20yFKQ", + "rating":4, + "text":"A quaint restaurant in such an unassuming location. \nIt's busy and hectic outside in the plaza that this restaurant is located at. The plaza is a little old...", + "user":{ + "id":"WPre6Q2d6-6GFLD027fYPg", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/is4aaKXtCOMRng_FavKK5w/o.jpg", + "name":"Ann N." + } + }, + { + "id":"5VI9DhR07Xci2a4D3oz7oQ", + "rating":5, + "text":"I was in heaven eating the jamón, with cheese plate and the pan con tomato...wooooo weeeee!!! I literally closed my eyes and transported to myself to Spain...", + "user":{ + "id":"Y7LNldoENmAignc9S37t6g", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/YuI0oh9GeJYzM4Zj3Jni9w/o.jpg", + "name":"Nicole P." + } + } + ], + "categories":[ + { + "title":"Tapas/Small Plates", + "alias":"tapasmallplates" + }, + { + "title":"Spanish", + "alias":"spanish" + }, + { + "title":"Wine Bars", + "alias":"wine_bars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3400 S Jones Blvd\nSte 11A\nLas Vegas, NV 89146" + } + }, + { + "id":"QCCVxVRt1amqv0AaEWSKkg", + "name":"Esther's Kitchen", + "price":"$$", + "rating":4.5, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/uk6-4u8H6BpxaJAKDEzFOA/o.jpg" + ], + "reviews":[ + { + "id":"exJ7J1xtJgfYX8wKnOJb7g", + "rating":5, + "text":"Sat at the bar, place was jumping at lunch time, spotting the whos who of Vegas, Friendly staff with amazing food and service. Cant wait to get back there...", + "user":{ + "id":"fJuUotyAX1KtJ7yXmfwzXA", + "image_url":null, + "name":"Barry D." + } + }, + { + "id":"VjmUIlp_Y0_0ISEjqZvKAw", + "rating":5, + "text":"Our server Josh was AMAZING! He was so attentive and sweet I've been to their on location and the new one does not disappoint. I tried something new...", + "user":{ + "id":"59qcS7L8sHAaxziIg4_i5A", + "image_url":null, + "name":"Caitlin S." + } + }, + { + "id":"fYGyOGLuDQcZJva0tHjdxQ", + "rating":5, + "text":"Esther's Kitchen\n\nWe had a wonderful lunch experience! Rocco was our waiter, and he was exceptional--so friendly, talkative, and made us feel right at home....", + "user":{ + "id":"jsH3aUC_UuFYv5etKNNgLQ", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/zG63zZ6Bx8M47sanNzUTUg/o.jpg", + "name":"S M." + } + } + ], + "categories":[ + { + "title":"Italian", + "alias":"italian" + }, + { + "title":"Pizza", + "alias":"pizza" + }, + { + "title":"Cocktail Bars", + "alias":"cocktailbars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"1131 S Main St\nLas Vegas, NV 89104" + } + }, + { + "id":"mU3vlAVzTxgmZUu6F4XixA", + "name":"Momofuku", + "price":"$$", + "rating":4.1, + "photos":[ + "https://s3-media1.fl.yelpcdn.com/bphoto/mB1g53Nqa62Q04u4oNuCSw/o.jpg" + ], + "reviews":[ + { + "id":"mAEPxxFflcYD6ZtzvnxzKg", + "rating":3, + "text":"Service subpar. Lamb was average. Pork belly for kids bad. Overall not worth the prices.", + "user":{ + "id":"s4qyTcSQtHzlW8O4nm867A", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/lbb5PhyDftjXRuTV8mdBsA/o.jpg", + "name":"Jon L." + } + }, + { + "id":"40BE2te-wIXkc3xevcp4Ew", + "rating":3, + "text":"Service is pretty good.\n\nFor food, ordered corn rib, and it was fantastic. The ramen was just so so: mushroom ramen was too salty. kid ordered the other...", + "user":{ + "id":"Dk68URVdrfDzQJvghTs9nA", + "image_url":null, + "name":"Peng Z." + } + }, + { + "id":"2Gq0rU2lqnHKlFK1Lrn2xA", + "rating":5, + "text":"Food was amazing \nRamen 5/5 great flavor even the vegan one \nAppetizer 6/5 the asparagus sauce dipped everything in it. \nDessert 5/5 love the asain flavors...", + "user":{ + "id":"ercYn3dqoUjZxUawQED4kA", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/cBS38RP3-jD5yG40Xo53UQ/o.jpg", + "name":"Tina T." + } + } + ], + "categories":[ + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Asian Fusion", + "alias":"asianfusion" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3708 Las Vegas Blvd S\nLevel 2\nBoulevard Tower\nLas Vegas, NV 89109" + } + }, + { + "id":"igHYkXZMLAc9UdV5VnR_AA", + "name":"Echo & Rig", + "price":"$$$", + "rating":4.4, + "photos":[ + "https://s3-media1.fl.yelpcdn.com/bphoto/Q9swks1BO-w-hVskIHrCVg/o.jpg" + ], + "reviews":[ + { + "id":"vbEuCit3l5lLrMkxEoaPNg", + "rating":4, + "text":"I've been a regular at Echo & Rig for some time, and it's always been a pleasant experience--until our visit this evening. From the moment we walked in, we...", + "user":{ + "id":"e9Mwwtzm7X5kiM7RcJRmsg", + "image_url":null, + "name":"Stacie E." + } + }, + { + "id":"cH3e_BfQnIMT8Bv4NrmQSg", + "rating":5, + "text":"We went on a Monday night and we were able to get a seat within 5 minutes. \n\nThe venue is 2 stories and beautifully decorated. Perfect for a date night and...", + "user":{ + "id":"-PXJEs_9T0lRKpssxf3otg", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/eBKTnyOnHYTMNvLBcgrGwQ/o.jpg", + "name":"Cynthia H." + } + }, + { + "id":"1-YbhlzRDykg4BwukjXGAQ", + "rating":4, + "text":"Excellent destination for small plates. I've enjoyed making it a point to try a new dish each time I've come here. \n\nThe pork belly burnt ends are probably...", + "user":{ + "id":"JN-F23BIngBKd9MSaXoI8w", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/CfZ3sLM1OHNwXKbK9OKQnQ/o.jpg", + "name":"Kevin B." + } + } + ], + "categories":[ + { + "title":"Steakhouses", + "alias":"steak" + }, + { + "title":"Butcher", + "alias":"butcher" + }, + { + "title":"Tapas/Small Plates", + "alias":"tapasmallplates" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"440 S Rampart Blvd\nLas Vegas, NV 89145" + } + }, + { + "id":"UidEFF1WpnU4duev4fjPlQ", + "name":"Therapy ", + "price":"$$", + "rating":4.3, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/otaMuPtauoEb6qZzmHlAlQ/o.jpg" + ], + "reviews":[ + { + "id":"a3UISKdTa1aMxok4mmzNsQ", + "rating":5, + "text":"Step into Therapy and take a sit, Chris the bartender is pretty chill. Talking to him is like talking to a long time friend, the type you don't see for a...", + "user":{ + "id":"SbMQm6pAPRwg04y44S5zLA", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/3ZuAxm31p7iwQ_zV2lgWOA/o.jpg", + "name":"Vittor V." + } + }, + { + "id":"hfZ-9d6Xxztb9o-cEJmR7Q", + "rating":5, + "text":"The food and drinks great! Try the loaded crab fries ~ got seated and served quick- Dallas was the best!", + "user":{ + "id":"7_uRkPfh8fvewEHDnhx6mg", + "image_url":null, + "name":"Patricia L." + } + }, + { + "id":"yVHXlr736j2rSOCbJZOyMg", + "rating":5, + "text":"This place was the all time party vibe!!! We had heard great things about the atmosphere drinks and food, so we had to try it out. Luckily it was our...", + "user":{ + "id":"idFOQhuCk-yoeu1LGLAI0g", + "image_url":"https://s3-media4.fl.yelpcdn.com/photo/FuUFgNOFmE5ZTS6JzLQ2Kg/o.jpg", + "name":"Brianna M." + } + } + ], + "categories":[ + { + "title":"Bars", + "alias":"bars" + }, + { + "title":"New American", + "alias":"newamerican" + }, + { + "title":"Dance Clubs", + "alias":"danceclubs" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"518 Fremont St\nLas Vegas, NV 89101" + } + }, + { + "id":"wmId49_BwzfWd3ww6GDMeA", + "name":"Cleaver - Butchered Meats, Seafood & Cocktails", + "price":"$$$", + "rating":4.5, + "photos":[ + "https://s3-media3.fl.yelpcdn.com/bphoto/htN_B2atKva2hsKorxEgrg/o.jpg" + ], + "reviews":[ + { + "id":"zQvOnn54BB8c4gcxHHG8AQ", + "rating":5, + "text":"The food, the cocktails... the SERVICE... all amazing!! We really enjoyed this dinning experience. Parking was easy and free. This is not far from the...", + "user":{ + "id":"Zpo7e6uD1MYGk0RpeGyEhg", + "image_url":"https://s3-media1.fl.yelpcdn.com/photo/mgiNwxyQkG0kEL8SOia31A/o.jpg", + "name":"Jennifer O." + } + }, + { + "id":"otnuRPgB3lQIfhD1AUViOw", + "rating":5, + "text":"Easily the best meal I've had in my life. Everything cooked to perfection. Cocktails were the right balance of flavor and alcohol. Our waiter was attentive...", + "user":{ + "id":"49hRCMad22gCJCN40p--nQ", + "image_url":null, + "name":"Daisy M." + } + }, + { + "id":"vUvlNBgdtarV9AHmE1_y8w", + "rating":5, + "text":"We went for a bachelor party that I was hosting. We had 28 of us and everything was perfect. Best decision we made and very reasonable for price. 1000%...", + "user":{ + "id":"Z-8mXl3jRGhwZqmnALrrEg", + "image_url":null, + "name":"Patrick V." + } + } + ], + "categories":[ + { + "title":"Steakhouses", + "alias":"steak" + }, + { + "title":"Seafood", + "alias":"seafood" + }, + { + "title":"Cocktail Bars", + "alias":"cocktailbars" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3900 Paradise Rd\nSte D1\nLas Vegas, NV 89169" + } + }, + { + "id":"XnJeadLrlj9AZB8qSdIR2Q", + "name":"Joel Robuchon", + "price":"$$$$", + "rating":4.5, + "photos":[ + "https://s3-media4.fl.yelpcdn.com/bphoto/8282ZD9hrsGH9a-kejFzxw/o.jpg" + ], + "reviews":[ + { + "id":"r7FpihYh8TtwfpKgrI2syw", + "rating":5, + "text":"Rating: 4.5/5\n\nJoel Robuchon is a paragon of luxury dining. The opulent ambiance, characterized by soft lighting, a grand chandelier, and lavish floral...", + "user":{ + "id":"dvTlsNXCiLzBmGPcQPMA9A", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/-XaQAXzr8og8SY7SyaNjLw/o.jpg", + "name":"Ayush K." + } + }, + { + "id":"aAUIYHJCTkXOufvSDxRoXA", + "rating":4, + "text":"We have tried some French restaurants but never a big fan. So far, Joel Robuchon is my favorite. \nA kind reminder if you make the reservation through MGM...", + "user":{ + "id":"BFFDzZR0ixxD3azljG5ysA", + "image_url":"https://s3-media2.fl.yelpcdn.com/photo/R2ixq_srpqu10cTZ1uMZWw/o.jpg", + "name":"Felicity C." + } + }, + { + "id":"XMmZhe0rGtNkHub372PyTQ", + "rating":4, + "text":"We had our anniversary dinner at Joel Robuchon in Las Vegas this year.  It is always a pleasure to celebrate with our beloved daughter. Joel Robuchon is the...", + "user":{ + "id":"bv3sEZrvDqUguzlZeQDBUg", + "image_url":"https://s3-media3.fl.yelpcdn.com/photo/mZGY1nkIZjadOpP4RjMdmg/o.jpg", + "name":"Kitty L." + } + } + ], + "categories":[ + { + "title":"French", + "alias":"french" + } + ], + "hours":[ + { + "is_open_now":true + } + ], + "location":{ + "formatted_address":"3799 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + } + ] + } + } + } \ No newline at end of file diff --git a/test/data/repositories/yelp_dev_repository_test.dart b/test/data/repositories/yelp_dev_repository_test.dart new file mode 100644 index 0000000..2c4d314 --- /dev/null +++ b/test/data/repositories/yelp_dev_repository_test.dart @@ -0,0 +1,160 @@ +// ignore_for_file: empty_catches + +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/core/http_service/http_client.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/data/repositories/yelp_repository.dart'; + +class MockYelpRepository extends Mock implements YelpRepository {} + +class MockIHttpClient extends Mock implements IHttpClient {} + +late YelpRepository repository; +late IHttpClient client; + +void main() { + setUp(() { + repository = MockYelpRepository(); + client = MockIHttpClient(); + }); + + test('Verify if function get restaurants is being called', () async { + try { + await repository.getRestaurants(); + } catch (e) {} + + verify(() => repository.getRestaurants()).called(1); + }); + + test('Verify is returning correct data after status 200', () async { + try { + when(() => repository.getRestaurants()).thenAnswer( + (_) async => Some( + RestaurantQueryResult.fromJson( + _mockResponseBody, + ), + ), + ); + } catch (e) {} + + when(() => client.get(any())).thenAnswer( + (_) async => Future.value( + HttpResponse( + statusCode: 200, + body: _mockResponseBody.toString(), + ), + ), + ); + final result = await repository.getRestaurants(); + + expect(result, isA()); + }); + + group('Repository errors', () { + test('Error when api return is an empty map', () async { + try { + when(() => repository.getRestaurants()) + .thenAnswer((_) async => const None()); + } catch (e) {} + + when(() => client.get(any())).thenAnswer( + (_) async => Future.value( + HttpResponse( + statusCode: 400, + body: _mockResponseBody.toString(), + ), + ), + ); + final result = await repository.getRestaurants(); + + expect(result, isA()); + }); + + test('Error when api exception', () async { + when(() => client.get(any())).thenThrow(Exception('API error')); + when(() => repository.getRestaurants()) + .thenAnswer((_) async => const None()); + + final result = await repository.getRestaurants(); + + expect(result, const None()); + }); + }); +} + +final _mockResponseBody = { + "data": { + "search": { + "total": 7520, + "business": [ + { + "id": "vHz2RLtfUMVRPFmd7VBEHA", + "name": "Gordon Ramsay Hell's Kitchen", + "price": "\$\$\$", + "rating": 4.4, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/q771KjLzI5y638leJsnJnQ/o.jpg", + ], + "reviews": [ + { + "id": "F88H5ow44AmiwisbrbswPw", + "rating": 5, + "text": + "This entire experience is always so amazing. Every single dish is cooked to perfection. Every beef dish was so tender. The desserts were absolutely...", + "user": { + "id": "y742Fi1jF_JAqq5sRUlLEw", + "image_url": + "https://s3-media2.fl.yelpcdn.com/photo/rEWek1sYL0F35KZ0zRt3sw/o.jpg", + "name": "Ashley L.", + }, + }, + { + "id": "VJCoQlkk4Fjac0OPoRP8HQ", + "rating": 5, + "text": + "Me and my husband came to celebrate my birthday here and it was a 10/10 experience. Firstly, I booked the wrong area which was the Gordon Ramsay pub and...", + "user": { + "id": "0bQNLf0POLTW4VhQZqOZoQ", + "image_url": + "https://s3-media3.fl.yelpcdn.com/photo/i_0K5RUOQnoIw1c4QzHmTg/o.jpg", + "name": "Glydel L.", + }, + }, + { + "id": "EeCKH7eUVDsZv0Ii9wcPiQ", + "rating": 5, + "text": + "phenomenal! Bridgette made our experience as superb as the food coming to the table! would definitely come here again and try everything else on the menu,...", + "user": { + "id": "gL7AGuKBW4ne93_mR168pQ", + "image_url": + "https://s3-media1.fl.yelpcdn.com/photo/iU1sA7y3dEEc4iRL9LnWQQ/o.jpg", + "name": "Sydney O.", + }, + } + ], + "categories": [ + { + "title": "New American", + "alias": "newamerican", + }, + { + "title": "Seafood", + "alias": "seafood", + } + ], + "hours": [ + { + "is_open_now": true, + } + ], + "location": { + "formatted_address": "3570 Las Vegas Blvd S\nLas Vegas, NV 89109", + }, + }, + ], + }, + }, +}; diff --git a/test/view/cubit/favorite/favorite_cubit_test.dart b/test/view/cubit/favorite/favorite_cubit_test.dart new file mode 100644 index 0000000..c3910e4 --- /dev/null +++ b/test/view/cubit/favorite/favorite_cubit_test.dart @@ -0,0 +1,160 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/data/shared_services.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class FakeRestaurant extends Fake implements Restaurant {} + +class MockSharedServices extends Mock implements SharedServices {} + +Restaurant _restaurant = Restaurant( + categories: [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() { + SharedPreferences.setMockInitialValues({}); + late FavoriteCubit favoriteCubit; + late MockSharedServices sharedServices; + + setUp(() { + sharedServices = MockSharedServices(); + favoriteCubit = FavoriteCubit(sharedServices: sharedServices); + }); + + tearDown(() { + favoriteCubit.close(); + }); + + group('Favorite Restaurant function test', () { + blocTest( + 'Should emit favoriteSuccess and Success status when an restaurant is added to empty list', + build: () { + when( + () => sharedServices.saveListString( + SharedPreferencesKeys.savedRestaurants, + [_restaurant], + ), + ).thenAnswer((_) async {}); + return favoriteCubit; + }, + act: (cubit) => cubit.favoriteRestaurant(_restaurant), + expect: () => [ + FavoriteState(status: FavoriteStatus.favoriteSuccess), + FavoriteState(status: FavoriteStatus.success, favorites: [_restaurant]), + ], + ); + blocTest( + 'When removing last restaurant should emit an ' + 'empty list and removed and initial states', + build: () { + when( + () => sharedServices.saveListString( + SharedPreferencesKeys.savedRestaurants, + [], + ), + ).thenAnswer((_) async {}); + + return 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 restaurant ', + build: () { + when( + () => sharedServices.saveListString( + SharedPreferencesKeys.savedRestaurants, + [const Restaurant(id: '2', name: 'Better call saul')], + ), + ).thenAnswer((_) async {}); + + return 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')], + ), + ], + ); + }); + + group('Load Restaurant function test ', () { + blocTest( + 'Should emit an FavoriteStatus.initial if restaurantList is empty', + build: () { + when( + () => sharedServices.getListString( + SharedPreferencesKeys.savedRestaurants, + ), + ).thenAnswer((_) async => []); + + return favoriteCubit; + }, + act: (cubit) => cubit.loadRestaurants(), + expect: () => [ + isA() + .having((f) => f.status, 'status', FavoriteStatus.initial) + .having((f) => f.favorites, 'favorites', const []), + ], + ); + + blocTest( + 'Should emit an FavoriteStatus.success if restaurantList is not empty', + build: () { + when( + () => sharedServices.getListString( + SharedPreferencesKeys.savedRestaurants, + ), + ).thenAnswer((_) async => [_restaurant]); + + return favoriteCubit; + }, + act: (cubit) => cubit.loadRestaurants(), + expect: () => [ + isA() + .having((f) => f.status, 'status', FavoriteStatus.success) + .having((f) => f.favorites, 'favorites', [_restaurant]), + ], + ); + }); +} diff --git a/test/view/cubit/restaurants/restaurants_cubit_test.dart b/test/view/cubit/restaurants/restaurants_cubit_test.dart new file mode 100644 index 0000000..28f5822 --- /dev/null +++ b/test/view/cubit/restaurants/restaurants_cubit_test.dart @@ -0,0 +1,101 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/data/repositories/yelp_repository.dart'; +import 'package:restaurant_tour/view/cubit/restaurants/restaurants.dart'; + +class MockYelpRepository extends Mock implements YelpRepository {} + +void main() { + late RestaurantsCubit restaurantsCubit; + late YelpRepository repository; + + setUp(() { + repository = MockYelpRepository(); + restaurantsCubit = RestaurantsCubit(repository); + }); + + tearDown(() { + restaurantsCubit.close(); + }); + + blocTest( + 'Should emit a RestaurantsStatus.success when ' + 'api returns a list of restaurants', + build: () => restaurantsCubit, + act: (cubit) async { + when(() => repository.getRestaurants()).thenAnswer( + (_) async => Future.value( + const Some( + RestaurantQueryResult( + restaurants: [ + Restaurant(id: '2', name: 'Better call saul'), + ], + ), + ), + ), + ); + + await cubit.fetchRestaurants(); + }, + expect: () => [ + RestaurantsState(status: RestaurantsStatus.loading), + RestaurantsState( + status: RestaurantsStatus.success, + restaurants: const [Restaurant(id: '2', name: 'Better call saul')], + ), + ], + ); + + group('Error tests', () { + blocTest( + 'Should emit a RestaurantsStatus.failure when' + 'api returns none', + build: () => restaurantsCubit, + act: (cubit) async { + when(() => repository.getRestaurants()).thenAnswer( + (_) async => Future.value( + const None(), + ), + ); + + await cubit.fetchRestaurants(); + }, + expect: () => [ + RestaurantsState(status: RestaurantsStatus.loading), + RestaurantsState( + status: RestaurantsStatus.failure, + errorMessage: 'An unexpected error occurred', + ), + ], + ); + + blocTest( + 'Should emit a RestaurantsStatus.failure when' + 'api returns an invalid list', + build: () => restaurantsCubit, + act: (cubit) async { + when(() => repository.getRestaurants()).thenAnswer( + (_) async => Future.value( + const Some( + RestaurantQueryResult( + restaurants: null, + ), + ), + ), + ); + + await cubit.fetchRestaurants(); + }, + expect: () => [ + RestaurantsState(status: RestaurantsStatus.loading), + RestaurantsState( + status: RestaurantsStatus.failure, + errorMessage: 'Invalid restaurants', + ), + ], + ); + }); +} diff --git a/test/view/pages/favorites/favorites_page_test.dart b/test/view/pages/favorites/favorites_page_test.dart new file mode 100644 index 0000000..ee324a9 --- /dev/null +++ b/test/view/pages/favorites/favorites_page_test.dart @@ -0,0 +1,85 @@ +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:mocktail_image_network/mocktail_image_network.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; +import 'package:restaurant_tour/view/pages/favorites/favorites_page.dart'; +import 'package:restaurant_tour/view/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.state).thenReturn( + FavoriteState( + status: FavoriteStatus.success, + favorites: [_restaurant], + ), + ); + + await mockNetworkImages(() async => _createWidget(tester, [_restaurant])); + + await tester.pumpAndSettle(); + + expect(find.byType(RestaurantCardWidget), findsOneWidget); + }); + + testWidgets('Find two favorited restaurant', (tester) async { + when(() => favoriteCubit.state).thenReturn( + FavoriteState( + status: FavoriteStatus.success, + favorites: _restaurantList, + ), + ); + + await mockNetworkImages(() async => _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: FavoritesListBuilder( + 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/view/pages/home/home_page_test.dart b/test/view/pages/home/home_page_test.dart new file mode 100644 index 0000000..7af2ebd --- /dev/null +++ b/test/view/pages/home/home_page_test.dart @@ -0,0 +1,76 @@ +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:mocktail_image_network/mocktail_image_network.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; +import 'package:restaurant_tour/view/cubit/restaurants/restaurants.dart'; +import 'package:restaurant_tour/view/pages/home/home_page.dart'; +import 'package:restaurant_tour/view/pages/home/widgets/restaurants_tab_widget.dart'; + +class MockRestaurantsCubit extends MockCubit + implements RestaurantsCubit {} + +class MockFavoriteCubit extends MockCubit + implements FavoriteCubit {} + +late RestaurantsCubit restaurantsCubit; +late FavoriteCubit favoriteCubit; + +void main() { + setUp( + () { + restaurantsCubit = MockRestaurantsCubit(); + favoriteCubit = MockFavoriteCubit(); + }, + ); + + tearDown( + () { + restaurantsCubit.close(); + favoriteCubit.close(); + }, + ); + + testWidgets('Find restaurants tab after RestaurantsStatus.success', + (tester) async { + when(() => restaurantsCubit.fetchRestaurants()) + .thenAnswer((_) => Future.value()); + + when(() => favoriteCubit.loadRestaurants()) + .thenAnswer((_) => Future.value()); + + when(() => restaurantsCubit.state).thenReturn( + RestaurantsState( + status: RestaurantsStatus.success, + restaurants: [const Restaurant(id: '1', name: 'Pollos hermanos')], + ), + ); + + await mockNetworkImages(() async => _createWidget(tester)); + + expect(find.byType(AppBar), findsOneWidget); + + expect(find.byType(RestaurantsTabWidget), findsOneWidget); + }); +} + +Future _createWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: restaurantsCubit, + ), + BlocProvider.value( + value: favoriteCubit, + ), + ], + child: const HomePage(), + ), + ), + ); +} diff --git a/test/view/pages/home/widgets/favorites_tab_widget_test.dart b/test/view/pages/home/widgets/favorites_tab_widget_test.dart new file mode 100644 index 0000000..51248ef --- /dev/null +++ b/test/view/pages/home/widgets/favorites_tab_widget_test.dart @@ -0,0 +1,70 @@ +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:mocktail_image_network/mocktail_image_network.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; +import 'package:restaurant_tour/view/pages/favorites/favorites_page.dart'; +import 'package:restaurant_tour/view/pages/home/widgets/favorites_tab_widget.dart'; + +class MockFavoriteCubit extends MockCubit + implements FavoriteCubit {} + +late FavoriteCubit favoriteCubit; + +void main() { + setUp( + () => favoriteCubit = MockFavoriteCubit(), + ); + + tearDown( + () => favoriteCubit.close(), + ); + + testWidgets('Find favorites restaurants inital state', (tester) async { + when(() => favoriteCubit.state) + .thenReturn(FavoriteState(status: FavoriteStatus.initial)); + + await _createWidget(tester); + + expect( + find.text('You have not added any favorite resaturants!'), + findsOneWidget, + ); + }); + + testWidgets('Find favorites restaurants loading state', (tester) async { + when(() => favoriteCubit.state) + .thenReturn(FavoriteState(status: FavoriteStatus.loading)); + + await _createWidget(tester); + + expect( + find.byType(CircularProgressIndicator), + findsOneWidget, + ); + }); + + testWidgets('Find restaurants success state', (tester) async { + when(() => favoriteCubit.state) + .thenReturn(FavoriteState(status: FavoriteStatus.success)); + + await _createWidget(tester); + + await mockNetworkImages(() async => _createWidget(tester)); + + expect(find.byType(FavoritesListBuilder), findsOneWidget); + }); +} + +Future _createWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: favoriteCubit, + child: const FavoritesTabWidget(), + ), + ), + ); +} diff --git a/test/view/pages/home/widgets/restaurants_tab_widget_test.dart b/test/view/pages/home/widgets/restaurants_tab_widget_test.dart new file mode 100644 index 0000000..676a61f --- /dev/null +++ b/test/view/pages/home/widgets/restaurants_tab_widget_test.dart @@ -0,0 +1,82 @@ +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:mocktail_image_network/mocktail_image_network.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/view/cubit/restaurants/restaurants.dart'; +import 'package:restaurant_tour/view/pages/home/widgets/restaurants_tab_widget.dart'; +import 'package:restaurant_tour/view/widgets/restaurant_card_widget.dart'; + +class MockRestaurantsCubit extends MockCubit + implements RestaurantsCubit {} + +late RestaurantsCubit restaurantsCubit; + +void main() { + setUp( + () { + restaurantsCubit = MockRestaurantsCubit(); + }, + ); + + tearDown( + () { + restaurantsCubit.close(); + }, + ); + + testWidgets('Find restaurants inital state', (tester) async { + when(() => restaurantsCubit.fetchRestaurants()) + .thenAnswer((_) => Future.value()); + + when(() => restaurantsCubit.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(() => restaurantsCubit.fetchRestaurants()) + .thenAnswer((_) => Future.value()); + + when(() => restaurantsCubit.state) + .thenReturn(RestaurantsState(status: RestaurantsStatus.loading)); + + await _createWidget(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('Find restaurants success state', (tester) async { + when(() => restaurantsCubit.fetchRestaurants()) + .thenAnswer((_) => Future.value()); + + when(() => restaurantsCubit.state).thenReturn( + RestaurantsState( + status: RestaurantsStatus.success, + restaurants: [const Restaurant(id: '1', name: 'POllos hermanos')], + ), + ); + + await mockNetworkImages(() async => _createWidget(tester)); + + expect(find.byType(RestaurantCardWidget), findsOneWidget); + }); +} + +Future _createWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: restaurantsCubit, + child: const RestaurantsTabWidget(), + ), + ), + ), + ); +} diff --git a/test/view/pages/restaurant/restaurant_page_test.dart b/test/view/pages/restaurant/restaurant_page_test.dart new file mode 100644 index 0000000..636c93b --- /dev/null +++ b/test/view/pages/restaurant/restaurant_page_test.dart @@ -0,0 +1,91 @@ +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:mocktail_image_network/mocktail_image_network.dart'; +import 'package:restaurant_tour/data/models/restaurant.dart'; +import 'package:restaurant_tour/view/cubit/favorite/favorite.dart'; +import 'package:restaurant_tour/view/pages/restaurant/restaurant_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 mockNetworkImages(() async => _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 mockNetworkImages(() async => _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 mockNetworkImages(() async => _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 b729d48..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,19 +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:restaurant_tour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const RestaurantTour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -}